Files
tehran_tala/docs/CATALOG-PAGINATION-PLAN.md
T
s.esmaeiliandClaude Opus 5 9fc0886a57 feat(apps): read products by id instead of loading the whole catalogue
Step 3 of docs/CATALOG-PAGINATION-PLAN.md. Step 2 added the targeted
routes; these are the five callers that were fetching all ~16k products
to find a handful of rows:

  product screen    whole catalogue, then find(id)  -> GET /products/:id
  cart re-sync      whole catalogue for 2-3 lines   -> ?ids=
  favourites        whole catalogue, filtered here  -> ?ids=
  home categories   categories derived from it      -> /products/facets
  order row labels  joined on variantId             -> included with the order

Three of these were not merely slow, they were going to be wrong.
Favourites filtered the loaded catalogue, so once the list pages a
favourite outside the loaded slice silently disappears. Home categories
were derived the same way, so a category whose products all sit further
down would vanish from the home screen. Order rows looked their title up
in the catalogue, which ALREADY failed for a product that had since left
the storefront — exactly the old orders history exists for — and
rendered a blank row.

Order labels went to the server rather than to another id lookup: the
item carries productVariantId, not a product id, so ?ids= could not
answer it. One nested include in getOrders returns the variant, its
product and the product's first image. Deliberately NOT filtered by
storefront visibility, so a de-listed product still shows what was
bought; asserted in the new spec. OrderItemCard is synchronous now — no
fetch, no onMounted, the row renders complete on its first frame.

variantLookup is deleted from catalogStore rather than left behind: it
existed only to label order rows, and keeping it would have kept a
reason to load the full catalogue.

getProductsByIds chunks at 100 because the route caps ids there and
drops the rest. A partial answer reads to callers as "these products are
no longer on the storefront" — the cart would silently drop valid lines.
Favourites already run to 874 rows in the imported data (FAV-01).

A failed cart re-sync now leaves the cart alone: it is the person's own
data in localStorage, and dropping lines because the network blinked is
worse than a slightly stale price. The product screen tells a real 404
from a failed request, so only the former renders the not-found page.

One bug introduced and caught while doing this: moving the home
categories to /facets meant memory.init() no longer loaded the catalogue
as a side effect, and the home rails read catalog.products — the home
screen would have rendered empty rails. HomeView now loads it
explicitly. Found by grepping every caller of configStore.init, which is
also how I know HomeView is the only one.

Still on the unpaged list by design: the home rails and the
search/newest lists. Moving those onto paged reads is step 4, kept
separate because it is the risky part — debounce, loading state, and
telling "no results" from "still loading".

api 334/334 across 30 suites; both apps vue-tsc clean. getOrders has a
spec now (3 cases) because its include is a contract both apps depend on
and the method had no test at all. Nothing click-tested — the cart
re-sync and the favourites screen are the two worth a manual pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 15:19:50 +03:30

156 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# طرح — صفحه‌بندی واقعی کاتالوگ (۱۶ هزار رکورد)
تاریخ: ۱۴۰۵/۰۷/۲۰ (2026-10-12) — **طرح؛ منتظر تأیید**
> **مالک:** «دیتاها در اپ واقعی تا ۱۶ هزار رکورد می‌رود و گرفتن همه یک‌جا اشتباه
> است، حتماً باید پیجینیشن داشته باشد.»
موافقم. با این عدد، وضعیت فعلی قابل دفاع نیست. این سند می‌گوید دقیقاً چه چیزی
شکسته است، چرا کار از «اضافه کردن `take`/`skip`» بزرگ‌تر است، و با چه ترتیبی
بی‌ریسک انجام می‌شود.
---
## ۱. وضعیت امروز (بررسی‌شده در کد، نه حدس)
| لایه | واقعیت | مدرک |
|---|---|---|
| مسیر API | `GET /catalog/products` **هیچ پارامتری نمی‌پذیرد** | `catalog.controller.ts:10-12` |
| کوئری | یک `findMany` بدون `take`/`skip`، همراه `variants`+`images`+`category`+`brand`+`collections` | `catalog.service.ts:19-42` |
| کش سرور | کل خروجی، ۳۰ ثانیه در حافظه، با هر نوشتن باطل | `catalog-cache.service.ts` |
| «صفحه‌بندی» اپ | `PAGE_SIZE = 20` فقط یک **پنجره رندر** روی آرایه‌ی کامل است: `slice(0, visibleCount)` | `SearchView.vue:204-206`، `NewestView.vue:55-57` |
| در هر اسکرول | **صفر درخواست**، ۲۰ کارت بیشتر رندر می‌شود | همان |
**ایندکس‌ها — یافته‌ی مهم:** مدل `Product` **هیچ ایندکسی ندارد** (نه
`isActive`، نه `categoryId`، نه `brandId`، نه `createdAt`) و `ProductVariant`
ایندکسی روی `productId` ندارد. با ۱۶ هزار رکورد، صفحه‌بندی بدون ایندکس همان
کندی را می‌دهد؛ پس ایندکس **بخشی از این کار است**، نه یک بهبود جداگانه.
## ۲. چرا کار از «اضافه کردن take/skip» بزرگ‌تر است
هفت مصرف‌کننده روی فرض «کل کاتالوگ در حافظه است» نوشته شده‌اند. سه‌تا بحرانی‌اند
— هر سه کل لیست را می‌گیرند تا **یک چیز** پیدا کنند:
| جا | الان چه می‌کند | با ۱۶ هزار رکورد |
|---|---|---|
| `useProductDetail` | کل کاتالوگ را می‌گیرد تا **یک محصول** را `find` کند | بازکردن هر محصول = دانلود کل کاتالوگ |
| `cartStore.init` | کل کاتالوگ برای همگام‌سازی **۲-۳ قلم سبد** | هر بار باز شدن اپ |
| `configStore.init` | کل کاتالوگ فقط برای **لیست دسته‌ها** | صفحه خانه |
| `SearchView` | متن/دسته/برند/بازه وزن را روی کلاینت فیلتر می‌کند | فیلتر روی داده‌ی ناقص = نتیجه غلط |
| `Filters.vue` | گزینه‌های فیلتر را از کل لیست مشتق می‌کند | گزینه‌ها ناقص می‌شوند |
| `FavoritesView` | علاقه‌مندی‌ها را با `find` در کل لیست پیدا می‌کند | علاقه‌مندیِ خارج از صفحه دیده نمی‌شود |
| `useOrderItemProduct` | عنوان محصولِ آیتم سفارش را از کل لیست می‌خواند | سفارش قدیمی بی‌عنوان می‌شود |
**نتیجه:** اگر فقط `take`/`skip` به مسیر اضافه شود، جستجو و فیلتر **بی‌صدا غلط**
می‌شوند (روی ۲۰ رکورد اول فیلتر می‌کنند) و این بدتر از کندی است. پس هر
مصرف‌کننده باید مسیر مخصوص خودش را بگیرد.
## ۳. طراحی پیشنهادی
### (الف) چهار مسیر، به‌جای یک مسیر
| مسیر | برای چه | چرا جدا |
|---|---|---|
| `GET /catalog/products?page=&limit=&search=&categoryId=&brandId=&minGram=&maxGram=&sort=` | لیست و جستجو | **فیلتر و صفحه‌بندی سمت سرور**؛ تنها راه درست بودن نتیجه |
| `GET /catalog/products/:id` | صفحه محصول | یک رکورد، نه ۱۶ هزار |
| `GET /catalog/facets` | گزینه‌های فیلتر (دسته‌ها، برندها، بازه وزن) | کوچک، `distinct`/`aggregate`؛ جایگزین مشتق‌کردن از کل لیست |
| `POST /catalog/products/by-ids` | سبد، علاقه‌مندی، آیتم‌های سفارش | چند شناسه مشخص در یک درخواست |
شکل پاسخ لیست: `{ rows, total, page, limit }`. **عمداً** از `TableService`
استفاده نمی‌کنم: آن برای قرارداد XTable پنل ادمین است و شکل پاسخش
(`{rows, query:{...}}`) برای اپ بی‌مورد پیچیده است.
### (ب) سازگاری عقب‌رو — مهم
مسیر فعلی **نباید بشکند**: پنل ادمین، اسکریپت‌های smoke و نسخه‌های نصب‌شده‌ی اپ
از آن استفاده می‌کنند.
- بدون `page`/`limit` ⇒ **رفتار امروز** (کل لیست). پس هیچ مصرف‌کننده‌ای نمی‌شکند.
- با `page`/`limit` ⇒ پاسخ صفحه‌بندی‌شده.
- در لاگ، درخواست بدون `page` روی کاتالوگ بزرگ **هشدار** می‌دهد تا بفهمیم چه
کسی هنوز قدیمی است.
### (ج) کش سرور — بازطراحی
کش فعلی **یک** خروجی را نگه می‌دارد (`value: {at, data}`). با پارامترها این
نادرست می‌شود: صفحهٔ ۲ جواب صفحهٔ ۱ را می‌گیرد.
پیشنهاد: کلید کش = رشته‌ی پارامترهای نرمال‌شده، با سقف تعداد کلید (مثلاً ۵۰،
LRU) تا حافظه بی‌نهایت رشد نکند. باطل‌سازی موجود (middleware پریزما) **همه** را
با هم پاک می‌کند — که درست است، چون هر نوشتنی می‌تواند هر صفحه‌ای را عوض کند.
⚠️ سقف کلید لازم است: بدون آن، هر ترکیب فیلترِ کاربران یک ردیف کش می‌سازد و
حافظه‌ی سرور به‌تدریج پر می‌شود.
### (د) ایندکس‌ها (مهاجرت)
```
Product: @@index([isActive, createdAt]) ← لیست و مرتب‌سازی پیش‌فرض
@@index([categoryId])
@@index([brandId])
ProductVariant: @@index([productId]) ← الان نیست؛ هر include یک اسکن
ProductImage: @@index([productId])
```
جستجوی متنی: `contains` فارسی روی ۱۶ هزار ردیف با ایندکس معمولی سریع نمی‌شود.
پیشنهاد **فعلی**: همان `contains` با ایندکس بالا (برای ۱۶ هزار قابل قبول است).
اگر کند بود، مرحله‌ی بعد `pg_trgm` است — ولی **اول اندازه‌گیری، بعد اضافه کردن**.
### (ه) سمت اپ
| کار | تغییر |
|---|---|
| `catalogStore` | از «یک آرایه‌ی کامل» به «صفحات + `total`»؛ `load()` صفحه می‌گیرد، `loadMore()` صفحه بعد |
| `SearchView` / `NewestView` | `IntersectionObserver` به‌جای زیاد کردن `visibleCount`، **درخواست صفحه بعد** می‌زند؛ فیلتر/جستجو با debounce به سرور می‌رود و از صفحه ۱ شروع می‌کند |
| `Filters.vue` | گزینه‌ها از `/catalog/facets` |
| `useProductDetail` | `GET /catalog/products/:id` |
| `cartStore.init` | `by-ids` با شناسه‌های سبد |
| `FavoritesView` | `by-ids` با شناسه‌های علاقه‌مندی |
| `useOrderItemProduct` | `by-ids`، یا بهتر: عنوان از خود `OrderItem` snapshot (اگر دارد) |
| `configStore` | دسته‌ها از `/catalog/facets` |
**اندازه صفحه:** همان **۲۰** که الان پنجره‌ی رندر است، تا تجربه عوض نشود.
## ۴. ترتیب اجرا (هر مرحله قابل تست و بی‌شکست)
| مرحله | کار | وضعیت |
|---|---|---|
| ۱ | مهاجرت ایندکس‌ها | ✅ **انجام شد** (`cbb93de`) — `Product` هیچ ایندکسی نداشت |
| ۲ | مسیرهای سرور + تست | ✅ **انجام شد** (`cbb93de`) — صفحه‌بندی opt-in، فیلتر سمت سرور، ۱۷ تست |
| ۳ | صفحه محصول، سبد، علاقه‌مندی، دسته‌ها، برچسب آیتم سفارش | ✅ **انجام شد** — پنج مصرف‌کننده منتقل شد |
| ۴ | `SearchView` / `NewestView` / rails خانه → صفحه‌بندی واقعی | ⏳ **باقی** — پرریسک‌ترین (فیلتر + اسکرول) |
| ۵ | حذف خوانش کل کاتالوگ و هشدار لاگ | ⏳ فقط وقتی هیچ مصرف‌کننده‌ای نمانده |
### یادداشت مرحله ۳ (انجام‌شده)
«برچسب آیتم سفارش» در طرح اولیه نبود و سر راه پیدا شد: `OrderItem` فقط
`productVariantId` دارد نه `productId`، پس `?ids=` جوابش نمی‌داد. سرور همان را
با یک `include` برمی‌گرداند — که برای محصول حذف‌شده هم کار می‌کند، برعکس روش
قبلی که ردیف را خالی می‌گذاشت.
یک باگ هم در حین کار ساخته و گرفته شد: انتقال دسته‌ها به `/facets` باعث شد
`memory.init()` دیگر کاتالوگ را بار نزند و rails خانه خالی شود؛ `HomeView` حالا
صریحاً کاتالوگ را بار می‌کند.
**مرحله ۴ هنوز باقی است** — یعنی rails خانه، جستجو و «جدیدترین» همچنان کل
کاتالوگ را می‌گیرند. پس تا آن مرحله، سود واقعی ۱۶ هزار رکوردی کامل نشده.
## ۵. دو چیز که باید بدانی
**۱. مرجع صفحه‌بندی سرور داشت.** `ApiMobileController` برای لیست محصولات
صفحه‌بندی می‌فرستاد؛ پورت فعلی آن را به کلاینت منتقل کرده. پس این کار
**بازگشت به رفتار مرجع** است، نه قابلیت جدید — در ماتریس به‌عنوان STUB-03 ثبت
است.
**۲. ریسک واقعی این کار، جستجو و فیلتر است.** امروز روی کلاینت فوری و دقیق
کار می‌کنند. منتقل‌شدن به سرور یعنی: debounce، حالت بارگذاری، و «نتیجه‌ای نیست»
که باید با صفحه‌ی خالی اشتباه نشود. مرحله ۴ را عمداً آخر گذاشتم و پیشنهادم این
است که وقتی رسیدیم، **یک بار با هم در مرورگر امتحانش کنیم** — چون این بخش
تنها جایی است که تست واحد کفایت نمی‌کند.
---
**برای تصمیم تو:** مرحله ۱ و ۲ کاملاً بی‌ریسک‌اند و هیچ‌چیز را نمی‌شکنند. اگر
بگویی «برو»، از آن‌ها شروع می‌کنم و بعد از هر مرحله گزارش می‌دهم. اگر عجله داری،
مرحله ۳ همان‌جایی است که کاربر فوراً تفاوت را حس می‌کند (باز شدن صفحه محصول).