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>
11 KiB
طرح — صفحهبندی واقعی کاتالوگ (۱۶ هزار رکورد)
تاریخ: ۱۴۰۵/۰۷/۲۰ (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، حالت بارگذاری، و «نتیجهای نیست» که باید با صفحهی خالی اشتباه نشود. مرحله ۴ را عمداً آخر گذاشتم و پیشنهادم این است که وقتی رسیدیم، یک بار با هم در مرورگر امتحانش کنیم — چون این بخش تنها جایی است که تست واحد کفایت نمیکند.
برای تصمیم تو: مرحله ۱ و ۲ کاملاً بیریسکاند و هیچچیز را نمیشکنند. اگر بگویی «برو»، از آنها شروع میکنم و بعد از هر مرحله گزارش میدهم. اگر عجله داری، مرحله ۳ همانجایی است که کاربر فوراً تفاوت را حس میکند (باز شدن صفحه محصول).