# 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 Partial>; ``` `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` 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.