Files
vmall/openspec/changes/archive/2026-09-17-replace-mock-api-wave-1/design.md
T
james 2136a48fbe chore(openspec): archive the mock-migration waves and green the spec set
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.
2026-09-17 16:36:10 +00:00

6.4 KiB

Design

Context

See proposal.md — Why. Three facts shape the approach:

  • apps/mall/plugins/api.ts chooses 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 ApiClient catalog methods at all; 18 files import ~/mock/data directly (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.sql seeds 3 childless categories, and scripts/seed-demo.mjs seeds 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 catalog and currency run 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/admin surfaces — 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) → liveDomains defaults to ["catalog", "currency"] only, and docs/TBD-migrate-wave.md records 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=price adds a correlated subquery per row → acceptable at MVP scale; if it becomes hot, denormalise a min_price_minor onto products.

Migration Plan

  1. Ship the category-tree migration and the catalog route changes first; the backend stays additive and backwards compatible (unsorted, exact-category requests keep working).
  2. Re-run scripts/seed-demo.mjs to fill products and shops.
  3. Flip liveDomains to ["catalog", "currency"]. Rollback is removing a string from that list — no data migration to reverse, because the fixed-data adapter remains complete.