feat(catalog): page and filter the storefront server-side (steps 1-2)

The real catalogue reaches ~16k products and GET /catalog/products had no
paging parameters at all: it returned every active product with its
variants, images, category, brand and collections in one response. The
apps' PAGE_SIZE = 20 was only a render window over that full array, so
scrolling made zero requests. The reference paged this server-side; the
port had moved it to the client (STUB-03), so this is a return to
reference behaviour.

Step 1 — indexes, which were not optional. Product carried NO index
whatsoever (not isActive, categoryId, brandId or createdAt) and
ProductVariant had none on productId, so every per-product include
scanned its whole table. Adding take/skip on top of that would have
paged the response without making the query faster, which is the exact
failure this change exists to avoid. Product_isActive_createdAt_idx is
composite so one index serves the filter, the default ordering and the
OFFSET walk.

CREATE INDEX, not CONCURRENTLY: the deploy image's migration runner
wraps each file in a transaction, which CONCURRENTLY cannot join (D15).
Every statement is IF NOT EXISTS because both migration paths run it.

Step 2 — the routes. Paging is opt-in, which is the whole
backward-compatibility story: with no query parameters the route returns
the same plain array from the same cache as before, so the admin panel,
the smoke scripts and installed app builds are untouched. Pass page,
limit or any filter and it becomes { rows, total, page, limit }.

The filters had to move too, not just the paging. The apps filtered
title, category, brand and gram range in the browser over the full
array; paging the response while leaving that in place would have made
search quietly WRONG — searching only the first page and reporting
"محصولی پیدا نشد" for products that exist. So every filter is built into
the Prisma query, and count uses the same where clause, so `total`
describes the set the app is actually scrolling.

Three routes added so the apps can stop fetching everything to find one
thing (wired up in step 3): ?ids=a,b,c for the cart, favourites and
order items; /:id for the product screen, which today downloads the
whole catalogue to find one row; /facets for the filter sheet's options,
which the apps currently derive by scanning the catalogue in the
browser.

Deliberately not cached: CatalogCacheService exists because the unpaged
query is expensive and every screen wanted it. A paged, filtered query
is neither — bounded by limit and index-served — and caching it would
need a key per filter combination, i.e. unbounded memory for cheap
queries plus a second place for stale rows to hide.

D27's two storefront rules (active, has an image) live in one shared
where clause, so the list, its count, the single-product route, by-ids
and the facets can never disagree about what is visible. limit and ids
are both capped at 100; an uncapped limit is just the old "give me
everything" query spelled differently.

17 new cases, 329/329 overall. All 17 passed on the first run, which is
not evidence by itself, so one filter was deliberately broken to confirm
the suite catches it (1 failed/16 passed) and then restored.

Not measured on real data — the database container was not running, so
16k-row behaviour is reasoned from the query plan, not timed. Worth one
EXPLAIN ANALYZE on a full dataset before step 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-12 15:01:32 +03:30
co-authored by Claude Opus 5
parent bfbac92df5
commit cbb93dec5d
8 changed files with 813 additions and 9 deletions
+141
View File
@@ -0,0 +1,141 @@
# طرح — صفحه‌بندی واقعی کاتالوگ (۱۶ هزار رکورد)
تاریخ: ۱۴۰۵/۰۷/۲۰ (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` |
**اندازه صفحه:** همان **۲۰** که الان پنجره‌ی رندر است، تا تجربه عوض نشود.
## ۴. ترتیب اجرا (هر مرحله قابل تست و بی‌شکست)
| مرحله | کار | چرا این ترتیب |
|---|---|---|
| ۱ | مهاجرت ایندکس‌ها | بدون این، صفحه‌بندی هم کند است. مستقل و بی‌ریسک. |
| ۲ | چهار مسیر سرور + کش کلیدی + تست | سازگار عقب‌رو؛ اپ‌ها هنوز دست‌نخورده کار می‌کنند |
| ۳ | `useProductDetail`، `cartStore`، `FavoritesView`، `configStore` → مسیرهای نقطه‌ای | بزرگ‌ترین برد با کمترین ریسک؛ رفتار دیداری عوض نمی‌شود |
| ۴ | `SearchView` / `NewestView` → صفحه‌بندی واقعی سرور | پرریسک‌ترین (فیلتر + اسکرول)؛ آخر، وقتی بقیه پایدار است |
| ۵ | حذف خوانش کل کاتالوگ و هشدار لاگ | فقط وقتی هیچ مصرف‌کننده‌ای نمانده |
## ۵. دو چیز که باید بدانی
**۱. مرجع صفحه‌بندی سرور داشت.** `ApiMobileController` برای لیست محصولات
صفحه‌بندی می‌فرستاد؛ پورت فعلی آن را به کلاینت منتقل کرده. پس این کار
**بازگشت به رفتار مرجع** است، نه قابلیت جدید — در ماتریس به‌عنوان STUB-03 ثبت
است.
**۲. ریسک واقعی این کار، جستجو و فیلتر است.** امروز روی کلاینت فوری و دقیق
کار می‌کنند. منتقل‌شدن به سرور یعنی: debounce، حالت بارگذاری، و «نتیجه‌ای نیست»
که باید با صفحه‌ی خالی اشتباه نشود. مرحله ۴ را عمداً آخر گذاشتم و پیشنهادم این
است که وقتی رسیدیم، **یک بار با هم در مرورگر امتحانش کنیم** — چون این بخش
تنها جایی است که تست واحد کفایت نمی‌کند.
---
**برای تصمیم تو:** مرحله ۱ و ۲ کاملاً بی‌ریسک‌اند و هیچ‌چیز را نمی‌شکنند. اگر
بگویی «برو»، از آن‌ها شروع می‌کنم و بعد از هر مرحله گزارش می‌دهم. اگر عجله داری،
مرحله ۳ همان‌جایی است که کاربر فوراً تفاوت را حس می‌کند (باز شدن صفحه محصول).
+82
View File
@@ -1176,3 +1176,85 @@ guards, and `grep back-to=` across both apps. api suite untouched at 308/308.
worth one pass by hand.
_No data was deleted in this entry._
---
## 2026-10-12 — Catalogue paging, steps 1–2: indexes and the server routes
- **What:** `Product` and its two child tables get the indexes the storefront
query actually needs, and `GET /catalog/products` learns paging, filtering,
sorting, a by-ids mode, a single-product route and a facets route.
- **Why:** owner — «دیتاها در اپ واقعی تا 16 هزار رکورد میرود و گرفتن همه یک جا
اشتباه است، حتما باید پیج نیشن داشته باشد». The route had **no** paging
parameters at all and returned every active product with its variants, images,
category, brand and collections in one response; the apps' `PAGE_SIZE = 20`
was only a render window over that full array, so scrolling made zero
requests.
- **Reference:** the reference DID page this server-side; the port moved it to
the client. Logged as STUB-03, so this is a return to reference behaviour.
- **Target:** `apps/api/prisma/schema.prisma`,
`prisma/migrations/20261012120000_catalog_paging_indexes/`,
`modules/catalog/catalog.controller.ts`, `catalog.service.ts`,
`dto/list-products.dto.ts`, `catalog-paging.spec.ts`.
- **Matrix row:** STUB-03 in `ISSUES.md`; plan in `docs/CATALOG-PAGINATION-PLAN.md`.
- **Commit:** see below.
**The indexes were not optional.** `Product` carried **no index whatsoever** — not
`isActive`, not `categoryId`, not `brandId`, not `createdAt` — and
`ProductVariant` had none on `productId`, so each per-product include scanned
its whole table. Adding `take`/`skip` on top of that would have paged the
response without making the query any faster, which is the failure mode this
change exists to avoid. `Product_isActive_createdAt_idx` is composite on purpose:
it serves the filter, the default ordering and the OFFSET walk from one index.
`CREATE INDEX` and not `CONCURRENTLY`: the deploy image's own migration runner
wraps each file in a transaction, which `CONCURRENTLY` cannot join (D15). At 16k
rows the write lock is a few hundred milliseconds. Every statement is
`IF NOT EXISTS`, because these run under both migration paths.
**Paging is opt-in, and that is the whole backward-compatibility story.** With no
query parameters the route returns the same plain array from the same cache it
always did — the admin panel, the smoke scripts and installed app builds are
untouched. Pass `page` or `limit` (or any filter) and the answer becomes
`{ rows, total, page, limit }`.
**The filters had to move server-side, not just the paging.** The apps filtered
title, category, brand and gram range in the browser over the full array. Paging
the response while leaving that in place would have made search **quietly
wrong** — searching only the first page and reporting "محصولی پیدا نشد" for
products that exist. That is worse than the slowness, so `storefrontWhere`
builds every filter into the Prisma query, and `count` uses the same `where` so
`total` describes the filtered set the app is scrolling.
**Three routes exist so the apps can stop fetching everything to find one thing**
(step 3 wires them up): `?ids=a,b,c` for the cart, favourites and order items;
`/:id` for the product screen, which currently downloads the entire catalogue to
`find()` one row; and `/facets` for the filter sheet's options, which the apps
derive today by scanning the whole catalogue in the browser.
**Deliberately NOT cached.** `CatalogCacheService` holds one built response
because the unpaged query is expensive and every screen asked for it. A paged,
filtered query is neither — it is bounded by `limit` and index-served. Caching it
would need a key per filter combination: unbounded memory for queries that are
already cheap, and a second place for stale rows to hide. Noted in the code so
it is not "optimised" later.
**Guards kept everywhere.** D27's two storefront rules (active, and has at least
one image) are in one shared `where`, so a paged list, its count, the single
product route, the by-ids route and the facets can never disagree about what is
visible. Asking for a hidden product by id still 404s. `limit` is capped at 100
and `ids` at 100 — an uncapped `limit` would just be the old "give me
everything" query spelled differently.
**Verification.** 17 new cases (329/329 overall, 29 suites), covering the
compatibility shape, skip/take translation, that `total` counts the filtered set,
each filter reaching the DB, the gram range matching ANY variant as the app did,
by-ids ignoring paging, and a hidden product 404ing. All 17 passed on the first
run, which is not evidence on its own — so one filter was deliberately broken to
confirm the suite fails (it did, 1 failed/16 passed) and then restored.
⚠️ Measured on **no real data**: the database container was not running, so the
16k-row behaviour is reasoned from the query plan and the new indexes, not timed.
Worth one `EXPLAIN ANALYZE` on a full dataset before step 4.
_No data was deleted in this entry._