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:
co-authored by
Claude Opus 5
parent
bfbac92df5
commit
cbb93dec5d
@@ -667,6 +667,18 @@ model Product {
|
||||
promotions Promotion[]
|
||||
allocationItems BranchAllocationItem[]
|
||||
favoritedBy CustomerFavorite[]
|
||||
|
||||
// Storefront paging (owner: the real catalogue reaches ~16k products, so
|
||||
// GET /catalog/products must page rather than return everything). This model
|
||||
// had NO index at all, so every storefront query was a full scan and paging
|
||||
// alone would not have been any faster.
|
||||
//
|
||||
// The composite matches the list's own shape: filter on isActive, order by
|
||||
// createdAt — Postgres can satisfy both from this one index, including the
|
||||
// OFFSET walk. The other two serve the category and brand filters.
|
||||
@@index([isActive, createdAt])
|
||||
@@index([categoryId])
|
||||
@@index([brandId])
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
@@ -684,6 +696,11 @@ model ProductImage {
|
||||
/// variants (one photo, several weights), which is why this is a plain
|
||||
/// one-to-many and not a join table.
|
||||
variants ProductVariant[]
|
||||
|
||||
// The storefront list filters on `images: { some: {} }` and includes the
|
||||
// gallery ordered by sortOrder, both per product. Unindexed, each one scanned
|
||||
// the whole table.
|
||||
@@index([productId, sortOrder])
|
||||
}
|
||||
|
||||
enum ProductImageRole {
|
||||
@@ -721,6 +738,10 @@ model ProductVariant {
|
||||
orderItems OrderItem[]
|
||||
|
||||
@@index([imageId])
|
||||
// Every storefront product carries its variants, so this include ran once per
|
||||
// product with no index to use. The composite covers the `isActive: true`
|
||||
// filter the list applies to them.
|
||||
@@index([productId, isActive])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user