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>
This commit is contained in:
s.esmaeili
2026-10-12 15:19:50 +03:30
co-authored by Claude Opus 5
parent cbb93dec5d
commit 9fc0886a57
22 changed files with 743 additions and 191 deletions
+20 -6
View File
@@ -113,13 +113,27 @@ ProductImage: @@index([productId])
## ۴. ترتیب اجرا (هر مرحله قابل تست و بی‌شکست)
| مرحله | کار | چرا این ترتیب |
| مرحله | کار | وضعیت |
|---|---|---|
| ۱ | مهاجرت ایندکس‌ها | بدون این، صفحه‌بندی هم کند است. مستقل و بی‌ریسک. |
| ۲ | چهار مسیر سرور + کش کلیدی + تست | سازگار عقب‌رو؛ اپ‌ها هنوز دست‌نخورده کار می‌کنند |
| ۳ | `useProductDetail`، `cartStore`، `FavoritesView`، `configStore` → مسیرهای نقطه‌ای | بزرگ‌ترین برد با کمترین ریسک؛ رفتار دیداری عوض نمی‌شود |
| ۴ | `SearchView` / `NewestView` → صفحه‌بندی واقعی سرور | پرریسک‌ترین (فیلتر + اسکرول)؛ آخر، وقتی بقیه پایدار است |
| ۵ | حذف خوانش کل کاتالوگ و هشدار لاگ | فقط وقتی هیچ مصرف‌کننده‌ای نمانده |
| ۱ | مهاجرت ایندکس‌ها | ✅ **انجام شد** (`cbb93de`) — `Product` هیچ ایندکسی نداشت |
| ۲ | مسیرهای سرور + تست | ✅ **انجام شد** (`cbb93de`) — صفحه‌بندی opt-in، فیلتر سمت سرور، ۱۷ تست |
| ۳ | صفحه محصول، سبد، علاقه‌مندی، دسته‌ها، برچسب آیتم سفارش | ✅ **انجام شد** — پنج مصرف‌کننده منتقل شد |
| ۴ | `SearchView` / `NewestView` / rails خانه → صفحه‌بندی واقعی | ⏳ **باقی** — پرریسک‌ترین (فیلتر + اسکرول) |
| ۵ | حذف خوانش کل کاتالوگ و هشدار لاگ | ⏳ فقط وقتی هیچ مصرف‌کننده‌ای نمانده |
### یادداشت مرحله ۳ (انجام‌شده)
«برچسب آیتم سفارش» در طرح اولیه نبود و سر راه پیدا شد: `OrderItem` فقط
`productVariantId` دارد نه `productId`، پس `?ids=` جوابش نمی‌داد. سرور همان را
با یک `include` برمی‌گرداند — که برای محصول حذف‌شده هم کار می‌کند، برعکس روش
قبلی که ردیف را خالی می‌گذاشت.
یک باگ هم در حین کار ساخته و گرفته شد: انتقال دسته‌ها به `/facets` باعث شد
`memory.init()` دیگر کاتالوگ را بار نزند و rails خانه خالی شود؛ `HomeView` حالا
صریحاً کاتالوگ را بار می‌کند.
**مرحله ۴ هنوز باقی است** — یعنی rails خانه، جستجو و «جدیدترین» همچنان کل
کاتالوگ را می‌گیرند. پس تا آن مرحله، سود واقعی ۱۶ هزار رکوردی کامل نشده.
## ۵. دو چیز که باید بدانی
+85
View File
@@ -1258,3 +1258,88 @@ confirm the suite fails (it did, 1 failed/16 passed) and then restored.
Worth one `EXPLAIN ANALYZE` on a full dataset before step 4.
_No data was deleted in this entry._
---
## 2026-10-12 — Catalogue paging, step 3: the apps stop fetching everything
- **What:** Five places that loaded the whole catalogue to find a few rows now
read exactly what they need — the product screen, the cart re-sync,
favourites, the home categories, and order-row labels.
- **Why:** step 3 of `docs/CATALOG-PAGINATION-PLAN.md`. Step 2 added the
targeted routes; these are the callers. At ~16k products each of these was
downloading the entire catalogue, and three of them would have been **wrong**
as well as slow once the list is paged.
- **Target:** both apps' `api/client.ts`, `composables/useProductDetail.ts`,
`composables/useOrderItemProduct.ts`, `stores/cartStore.ts`,
`stores/configStore.ts`, `stores/catalogStore.ts`,
`views/user/FavoritesView.vue`, `views/HomeView.vue`,
`components/product/OrderItemCard.vue`; api
`modules/customers/customers.service.ts` + `order-items-include.spec.ts`.
- **Matrix row:** STUB-03 in `ISSUES.md`.
- **Commit:** see below.
| Caller | Was | Now |
|---|---|---|
| product screen | whole catalogue, then `find(id)` | `GET /catalog/products/:id` |
| cart re-sync | whole catalogue for 2–3 lines | `?ids=` for the cart's products |
| favourites | whole catalogue, filtered in the browser | `?ids=` for the saved ids |
| home categories | whole catalogue, categories derived from it | `GET /catalog/products/facets` |
| order row labels | whole catalogue, joined on variantId | included with the order |
**Three of these were not just 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. The 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
that history exists for — and rendered a blank row.
**Order labels moved 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: one join instead of 16k rows, and it answers for
de-listed products too, because order history is deliberately NOT filtered by
storefront visibility (asserted in the new spec). `OrderItemCard` is now
synchronous — no fetch, no `onMounted`, the row renders complete on its first
frame.
**`variantLookup` was deleted from `catalogStore`**, not 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 would be read by 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), so this is a
live case.
**A failed cart re-sync now leaves the cart alone.** It is the person's own
data in localStorage; dropping lines because the network blinked is worse than
showing a slightly stale price.
**The product screen tells 404 from "request failed".** Before, a failed
catalogue fetch left `notFound` false, which was right — the product probably
exists. Only a real 404 renders the not-found page. If the page was already
seeded from the tapped card and the product has since been de-listed, the card's
copy stays on screen rather than blanking mid-read; the cart refuses it at
checkout.
**One bug introduced and caught here:** 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`, so the home screen would have rendered empty
rails. `HomeView` now loads the catalogue explicitly. Found by grepping every
caller of `configStore.init` rather than by running the app, which is also how I
know `HomeView` is the only caller.
**Still on the unpaged list, by design:** the home rails and the search/newest
lists. They slice locally today; moving them onto paged reads with server-side
filtering is step 4, kept separate because it is the risky part (debounce,
loading state, telling "no results" from "still loading").
**Verification.** api 334/334 across 30 suites; both apps `vue-tsc` clean. The
`getOrders` include has a new spec (3 cases) because it is now 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.
_No data was deleted in this entry._