Archive the three completed changes behind replace-mock-api-wave-1/2/3. Each merge applied cleanly to the main specs: - catalog gains the Public product browse requirement (subtree filtering and price sort) - frontend-mall picks up the per-domain adapter, the pinned home page, the discovery-page changes, the live auth panels and the live transaction flows - cart's Server-side cart requirement now documents the shop and stock carried by every line Also replace the TBD Purpose placeholder in all eleven specs with a one-line description of what each capability covers. Those placeholders predate this work and were the only reason `openspec validate --all --strict` reported 0 passed / 11 failed; it now reports 11 passed / 0 failed.
6.4 KiB
Design
Context
See proposal.md — Why. Three facts shape the approach:
apps/mall/plugins/api.tschooses one adapter for the entire app from a single boolean, so nothing can migrate domain-by-domain today.- The mall's browse surfaces never call the
ApiClientcatalog methods at all; 18 files import~/mock/datadirectly (apps/mall/pages/{index,search}.vue,pages/goods/[id].vue,components/shell/CategoryMenu.vue, and others). - The live catalog is far thinner than the mock:
apps/api/migrations/0003_catalog.sqlseeds 3 childless categories, andscripts/seed-demo.mjsseeds 4 products and 1 shop, against the mock's 6 categories with children/grandchildren, 24 products, 4 shops and 6 brands.
packages/shared/src/api.ts defines ApiClient as flat top-level methods plus shop and admin sub-objects, which is what makes per-domain composition cheap. ProductListQuery currently carries only page, per_page, category_id, q, shop_id.
Goals / Non-Goals
Goals:
- Make the adapter choice per domain, so
catalogandcurrencyrun live while the rest stays on fixed data. - Move every browse surface onto the catalog contract without changing what the UI claims to show.
- Make the live catalog good enough that the home page, search and product detail render convincingly.
Non-Goals:
- No change to the
shop/adminsurfaces — both apps already run live. - No new content model. Banners, promos, quick links, floor advert art and the goods-page comment/coupon/sales rails stay local display-only content.
- No category CRUD API. Categories remain read-only reference data.
Decisions
1. Compose the client from a typed per-domain pick map, not a string allowlist.
plugins/api.ts builds both clients and overlays the live one, domain by domain:
const LIVE_PICKS = {
auth: (a: ApiClient) => ({ register: a.register, login: a.login, me: a.me }),
catalog: (a: ApiClient) => ({ listProducts: a.listProducts, getProduct: a.getProduct, listCategories: a.listCategories }),
currency: (a: ApiClient) => ({ listCurrencies: a.listCurrencies, convert: a.convert }),
// cart, orders, shipments, invoices
} satisfies Record<string, (a: ApiClient) => Partial<ApiClient>>;
liveDomains comes from runtimeConfig.public.liveDomains (default ["catalog", "currency"]). The mock client stays whole and is the base object, so an unmigrated domain cannot regress and a live domain can be rolled back by removing one string.
Alternatives: a bare keyof ApiClient[] string list needs an unsafe index into a union of methods (AGENTS.md forbids any); a per-domain boolean in each page pushes the choice into 18 files. The pick map keeps the type checker honest and the config in one place.
2. Category subtree filtering becomes a recursive CTE inside the existing query.
list_products currently matches p.category_id = $1 exactly (apps/api/src/routes/catalog.rs:80). Replace it with a WITH RECURSIVE subtree AS (...) CTE seeded from the requested category, referenced by both the count and the page query, so items and total cannot drift apart. This is the first recursive CTE in the codebase; at this tree size (3 levels, tens of rows) it is cheaper than a second round trip to resolve ids in Rust.
Alternative: resolve subtree ids in Rust then bind = ANY($ids) — mirrors the mock's categorySubtreeIds, but issues two queries and risks the count/list disagreeing.
3. Price sort, validated by hand so errors keep the project's shape.
sort=price orders by a correlated (SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active), with order of asc/desc and NULLS LAST so a product without a sellable SKU never sorts to the top. Parse sort and order as Option<String> and whitelist them, returning ApiError 400 on anything else — deserializing straight into a serde enum would make axum's own Query rejection answer with a body that is not {"error":{"code","message"}}, which AGENTS.md requires.
4. Child categories ship as a migration, not as demo seed.
The proposal said to grow seed-demo.mjs with categories, but there is no category write endpoint (GET /categories is the only route), so the script cannot create them. 0003_catalog.sql already seeds categories as reference data, so a new append-only migration adds the children/grandchildren on the same terms. This is taxonomy, not demo data: it also gives shop-admin a usable category picker for the first time. Products and shops stay in seed-demo.mjs, which is idempotent by slug. No test asserts category contents (apps/api/tests/catalog.rs does not mention them), so this cannot break the suite.
5. Facets without a model are removed, not faked.
The brand facet and the sales/comments sorts are deleted from /search; only newest-first and price remain. Behaviour the spec does claim — the goods-page comment, coupon and sales-rail sections — is preserved and re-declared as local display-only content rather than dropped.
Risks / Trade-offs
- A per-domain switch can produce incoherent intermediate states (e.g. live cart with mock catalog posts mock SKU ids that the live DB rejects) →
liveDomainsdefaults to["catalog", "currency"]only, anddocs/TBD-migrate-wave.mdrecords that auth and catalog must be live before cart. - Rewiring 18 files can silently lose page behaviour that relied on mock-only fields (sales counts, ratings, store cards) → keep those sections on local content, and verify each touched route in a real browser rather than trusting the build.
- The live catalog may still look sparse after the migration (3 top-level categories until the new migration lands) → seed parity is a Wave 1 task with the same weight as the wiring, not a follow-up.
- Removing the brand facet is a visible regression → deliberate; recorded as a Wave 4 capability in
docs/TBD-migrate-wave.md. sort=priceadds a correlated subquery per row → acceptable at MVP scale; if it becomes hot, denormalise amin_price_minorontoproducts.
Migration Plan
- Ship the category-tree migration and the catalog route changes first; the backend stays additive and backwards compatible (unsorted, exact-category requests keep working).
- Re-run
scripts/seed-demo.mjsto fill products and shops. - Flip
liveDomainsto["catalog", "currency"]. Rollback is removing a string from that list — no data migration to reverse, because the fixed-data adapter remains complete.