# طرح — صفحه‌بندی واقعی کاتالوگ (۱۶ هزار رکورد) تاریخ: ۱۴۰۵/۰۷/۲۰ (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 خانه → صفحه‌بندی واقعی | ✅ **انجام شد 2026-10-17** — هر دو اپ؛ facets + `listProductsPaged`؛ multi-brand با `brandId=a,b` | | ۵ | حذف خوانش کل کاتالوگ و هشدار لاگ | ⏳ `catalog.load` هنوز یک صفحهٔ ۱۰۰تایی می‌گیرد برای callerهای باقی‌مانده؛ هشدار لاگ بعد از پاک‌سازی | ### یادداشت مرحله ۳ (انجام‌شده) «برچسب آیتم سفارش» در طرح اولیه نبود و سر راه پیدا شد: `OrderItem` فقط `productVariantId` دارد نه `productId`، پس `?ids=` جوابش نمی‌داد. سرور همان را با یک `include` برمی‌گرداند — که برای محصول حذف‌شده هم کار می‌کند، برعکس روش قبلی که ردیف را خالی می‌گذاشت. یک باگ هم در حین کار ساخته و گرفته شد: انتقال دسته‌ها به `/facets` باعث شد `memory.init()` دیگر کاتالوگ را بار نزند و rails خانه خالی شود؛ `HomeView` حالا صریحاً کاتالوگ را بار می‌کند. **مرحله ۴ هنوز باقی است** — یعنی rails خانه، جستجو و «جدیدترین» همچنان کل کاتالوگ را می‌گیرند. پس تا آن مرحله، سود واقعی ۱۶ هزار رکوردی کامل نشده. ## ۵. دو چیز که باید بدانی **۱. مرجع صفحه‌بندی سرور داشت.** `ApiMobileController` برای لیست محصولات صفحه‌بندی می‌فرستاد؛ پورت فعلی آن را به کلاینت منتقل کرده. پس این کار **بازگشت به رفتار مرجع** است، نه قابلیت جدید — در ماتریس به‌عنوان STUB-03 ثبت است. **۲. ریسک واقعی این کار، جستجو و فیلتر است.** امروز روی کلاینت فوری و دقیق کار می‌کنند. منتقل‌شدن به سرور یعنی: debounce، حالت بارگذاری، و «نتیجه‌ای نیست» که باید با صفحه‌ی خالی اشتباه نشود. مرحله ۴ را عمداً آخر گذاشتم و پیشنهادم این است که وقتی رسیدیم، **یک بار با هم در مرورگر امتحانش کنیم** — چون این بخش تنها جایی است که تست واحد کفایت نمی‌کند. --- **برای تصمیم تو:** مرحله ۱ و ۲ کاملاً بی‌ریسک‌اند و هیچ‌چیز را نمی‌شکنند. اگر بگویی «برو»، از آن‌ها شروع می‌کنم و بعد از هر مرحله گزارش می‌دهم. اگر عجله داری، مرحله ۳ همان‌جایی است که کاربر فوراً تفاوت را حس می‌کند (باز شدن صفحه محصول).