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.
68 lines
6.4 KiB
Markdown
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.
|