Files
vmall/openspec/changes/replace-mock-api-wave-1/design.md
T
james e0e833d0e5 feat(mall): serve catalog and currency from the live API
Wave 1 of replacing the fixed-data mock adapter. The mall now selects its API
adapter per domain, with catalog and currency served live while auth, cart,
orders, shipments and invoices stay on fixed data.

Backend:
- seed the 6 x 2 x 2 category tree as reference data (migration 0005). The API
  exposes no category write route, so this cannot come from the seed script
- filter public product listing by the category subtree with a recursive CTE,
  matching the mock's existing behaviour instead of exact-match
- add sort=price with order=asc|desc, validated by hand so an unsupported value
  returns the project's ApiError 400 shape rather than axum's own rejection

Mall:
- replace the all-or-nothing mockApi boolean with a liveDomains list composed
  through a typed per-domain pick map
- source home floors, the category menu, search and product detail from the
  catalog API; banners, promos, quick links, store card and comment/coupon
  content stay local display-only content
- drop the brand facet and the sales/comments sorts: no backend model backs them
- fix salesOf/commentCountOf, which parsed digits out of the product id and so
  rendered "NaN sold" for live UUID ids; they now hash the id

Seed: 24 products across 4 shops, idempotent on re-run.

Note: the mall defaults to a live catalog, so pnpm dev:mall now expects the API
to be running; set NUXT_PUBLIC_LIVE_DOMAINS to an empty array for all-mock work.

OpenSpec change: openspec/changes/replace-mock-api-wave-1
2026-09-17 15:15:25 +00:00

68 lines
6.4 KiB
Markdown

# 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:
```ts
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.