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.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-17
|
||||
@@ -0,0 +1,67 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
The mall is the only frontend still on the fixed-data mock adapter; `shop-admin` and `admin` run live. Every mall catalog surface also bypasses the `@vmall/shared` contract — 18 files import `~/mock/data` and no `$api` catalog call exists. The storefront cannot outgrow demo data, and no later domain can flip until real SKU ids come from the live catalog.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Replace the all-or-nothing `mockApi` boolean with a per-domain switch; Wave 1 flips `catalog` and `currency`, leaving auth, cart, orders, shipments and invoices on mock.
|
||||
- Rewire the browse surfaces (home floors, `/search`, `/goods/[id]`, category menu) from `~/mock/data` onto `listCategories`, `listProducts` and `getProduct`.
|
||||
- **BREAKING** (browse UX): remove the brand facet and the `sales`/`comments` sorts, which have no backing model; add `sort=price` with `order=asc|desc`.
|
||||
- Filter public product listing by category **subtree**, as the mock does today, instead of exact `category_id`.
|
||||
- Grow `scripts/seed-demo.mjs` to parity: categories with children and grandchildren, ~24 bilingual products, 4 shops (live: 3 childless categories, 4 products, 1 shop).
|
||||
- Banners, promos, quick links and floor art stay local.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(none)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `catalog`: add a public browse requirement — category subtree filtering and an optional price sort on `/api/products`.
|
||||
- `frontend-mall`: "Mock API adapter" becomes a per-domain switch with catalog and currency live; browse surfaces re-sourced from the API without the brand facet or non-price sorts.
|
||||
|
||||
## Impact
|
||||
|
||||
`apps/mall/plugins/api.ts`; 18 files under `apps/mall/{pages,components}`; `apps/mall/mock/data.ts` (marketing content only); `packages/shared/src/api.ts` (`ProductListQuery` gains `sort`/`order`); `apps/api/src/routes/catalog.rs`; `scripts/seed-demo.mjs`. The shared contract change means all three frontends must rebuild.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Auth, cart, orders, shipments and invoices stay mock. No backend capability for brands, storefront content, store directory, favorites, coupons or addresses; those stay mock. No change to money handling, i18n storage or the other apps.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Public product browse
|
||||
Public `GET /api/products` SHALL return only `published` products whose shop is active, and SHALL remain readable without authentication. When `category_id` is supplied, the filter SHALL match that category **and every category beneath it**, so requesting a parent category returns products assigned to its child and grandchild categories. The listing SHALL accept an optional `sort` of `price` together with an `order` of `asc` or `desc`, ordering by each product's lowest active SKU price; any other `sort` value SHALL be rejected with a 400 `ApiError` rather than silently ignored. An unsorted listing SHALL order newest first. Paging SHALL keep returning `page` and `per_page` alongside the filtered `total`.
|
||||
|
||||
#### Scenario: parent category includes descendant products
|
||||
- **WHEN** a shopper requests products for a category that has child categories holding published products
|
||||
- **THEN** the response contains the products assigned to those descendant categories, not only those assigned directly to the requested category
|
||||
|
||||
#### Scenario: sort by lowest active SKU price
|
||||
- **WHEN** a shopper requests the product list with `sort=price` and `order=asc`
|
||||
- **THEN** products come back ordered by their lowest active SKU price ascending
|
||||
|
||||
#### Scenario: unsupported sort is rejected
|
||||
- **WHEN** a client requests a `sort` value that is not `price`
|
||||
- **THEN** the API responds 400 with an `ApiError` body instead of ignoring the parameter
|
||||
|
||||
#### Scenario: unpublished products never appear
|
||||
- **WHEN** any public listing or filter is applied
|
||||
- **THEN** products that are not `published`, or whose shop is not active, are absent from both `items` and `total`
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Mock API adapter
|
||||
The mall SHALL select its API adapter per domain, so one domain can be served by the live backend while the others stay on fixed data. The mall SHALL still ship a fixed-data adapter implementing the whole `@vmall/shared` API client surface, and the live/fixed choice SHALL be configurable per domain without changing page call sites. The fixed-data adapter SHALL remain able to serve every domain when the live backend is unavailable.
|
||||
|
||||
#### Scenario: mall runs without backend
|
||||
- **WHEN** the mall starts with the API service unavailable and every domain configured to fixed data
|
||||
- **THEN** browsing, cart, checkout, payment, orders and invoice pages return deterministic fixed data and remain functional
|
||||
|
||||
#### Scenario: domains migrate independently
|
||||
- **WHEN** the live backend serves the catalog and currency domains while auth, cart, orders, shipments and invoices remain on fixed data
|
||||
- **THEN** browsing and prices come from the backend while those other flows keep working against fixed data
|
||||
|
||||
### Requirement: Mock PC home page
|
||||
The mall home page SHALL render a hero row composed of a pinned 240px category sidebar on the left and a hero carousel filling the remainder of the 1200px grid, both 450px tall and occupying layout space (not overlaid). The sidebar SHALL list the catalog's top-level categories with up to three child links each; hovering a top-level category SHALL expand the mega-menu panel to the right over the carousel. Banner images SHALL render at fixed 450px height, center-cropped horizontally to the narrower carousel width. Below the hero, the page SHALL render a six-item quick-link strip with promotion tiles and bilingual product floors, where each floor's products come from the catalog API while the banner, promotion, quick-link and floor advert assets remain local content.
|
||||
|
||||
#### Scenario: shopper lands on home
|
||||
- **WHEN** `/` loads
|
||||
- **THEN** the category sidebar is visible to the left of the carousel without any hover or click, the carousel renders center-cropped banners at 450px height, and the quick links, promotions and every non-empty product floor render, with floor products sourced from the catalog API
|
||||
|
||||
#### Scenario: sidebar stays while scrolling
|
||||
- **WHEN** a shopper scrolls the home page beyond 200px
|
||||
- **THEN** the category sidebar remains rendered in the hero row and does not auto-hide
|
||||
|
||||
#### Scenario: expand a category
|
||||
- **WHEN** a shopper hovers a top-level category in the pinned sidebar
|
||||
- **THEN** the mega-menu panel expands to the right, overlaying the carousel with that category's child and grandchild links from the catalog API
|
||||
|
||||
### Requirement: Product discovery pages
|
||||
The mall SHALL provide `/search` with breadcrumb, category and sort controls, a five-column desktop product grid, pagination and an empty state, listing products from the catalog API filtered by the selected category's subtree. The sort control SHALL offer newest-first and price ascending/descending only. It SHALL provide `/goods/[id]` rendering product and SKU data from the catalog API with image gallery/zoom, bilingual name/subtitle, integer-minor-unit prices, attribute and SKU selection, stock-aware quantity, store card, and detail/comments/after-sale tabs whose comment, coupon and sales content stays local display-only content.
|
||||
|
||||
#### Scenario: filter and inspect a product
|
||||
- **WHEN** a shopper filters the search page by a parent category and opens a product
|
||||
- **THEN** products from that category and its descendants are listed, and selecting an in-stock SKU updates the displayed price, stock and cart target from the catalog API
|
||||
@@ -0,0 +1,34 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Backend: catalog browse
|
||||
|
||||
- [x] 1.1 Add an append-only migration seeding child and grandchild categories under the existing `electronics`, `fashion` and `home-living` rows, matching the mock's 3-level shape; verify `GET /api/categories` returns more than 3 rows with populated `parent_id`
|
||||
- [x] 1.2 Replace the exact `p.category_id = $1` filter in `apps/api/src/routes/catalog.rs` with a `WITH RECURSIVE` subtree CTE shared by the count and page queries; verify a parent-category request returns its descendants' products
|
||||
- [x] 1.3 Add optional `sort=price` and `order=asc|desc` to `list_products`, ordering by each product's lowest active SKU price with `NULLS LAST`, and reject any other value with an `ApiError` 400 whose body is `{"error":{"code","message"}}`; verify the 400 shape with curl
|
||||
- [x] 1.4 Extend `apps/api/tests/catalog.rs` with cases for subtree listing, ascending/descending price order and the rejected-sort 400; verify `cargo test -p vmall-api` is green and repeatable
|
||||
|
||||
## 2. Shared contract
|
||||
|
||||
- [x] 2.1 Add optional `sort` and `order` to `ProductListQuery` in `packages/shared/src/api.ts` and pass them through in `createApi.listProducts`; verify `pnpm --filter @vmall/mall build` still type-checks
|
||||
|
||||
## 3. Demo data
|
||||
|
||||
- [x] 3.1 Grow `scripts/seed-demo.mjs` to roughly 24 bilingual products spread across the new category tree plus 4 shops, keeping the slug-lookup idempotency; verify a second run reports no duplicate creates and `GET /api/products?per_page=100` returns a full first page
|
||||
|
||||
## 4. Mall: per-domain adapter switch
|
||||
|
||||
- [x] 4.1 Replace the `mockApi` boolean in `apps/mall/plugins/api.ts` with a `liveDomains` list defaulting to `["catalog", "currency"]`, composing the live client over the fixed-data base through a typed per-domain pick map (no `any`); verify catalog requests hit `:8080` while `getCart` still resolves from fixed data with the backend stopped
|
||||
|
||||
## 5. Mall: rewire browse surfaces
|
||||
|
||||
- [x] 5.1 `apps/mall/pages/index.vue`: build floors from `listCategories` plus `listProducts` per category, leaving banners, promos, quick links and floor advert art local; verify every non-empty floor renders live products
|
||||
- [x] 5.2 `apps/mall/components/shell/CategoryMenu.vue`: source the tree from `listCategories`, keeping the header dropdown mode and the pinned home mode intact; verify the pinned sidebar shows children from the API
|
||||
- [x] 5.3 `apps/mall/pages/search.vue`: list through `listProducts` with the selected category's subtree and the new sort, and delete the brand facet plus the `sales`/`comments` sort options; verify filtering by a parent category lists descendant products
|
||||
- [x] 5.4 `apps/mall/pages/goods/[id].vue`: load the product and SKUs through `getProduct`, keeping the comment, coupon and sales-rail sections on local display-only content; verify an in-stock SKU selection updates price, stock and the cart target
|
||||
- [x] 5.5 Remove only the catalog helpers that lost their last consumer (`topCategories`, `childCategories`, `homeFloors`, `MOCK_BRANDS`, `brandOf`, `HomeFloor`, `MockBrand`) from `apps/mall/mock/data.ts`; amended from the original wording, which also removed `MOCK_PRODUCTS` and would have broken the rollback the `Mock API adapter` requirement promises, so `MOCK_PRODUCTS`/`searchMockProducts`/`productById` stay; verify `pnpm --filter @vmall/mall build` passes with no page importing a removed symbol
|
||||
|
||||
## 6. Verification
|
||||
|
||||
- [x] 6.1 Run `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/shop-admin build` and `pnpm --filter @vmall/admin build`, since the shared contract changed; verify all three pass
|
||||
- [x] 6.2 With the live backend and seeds running, verify in a browser that home, search, product detail and the pinned category menu render live data, and that no console errors appear
|
||||
- [x] 6.3 Stop the backend and confirm the mall still starts and the unmigrated domains (auth, cart, orders, invoices) keep working from fixed data; verify the browse pages degrade without crashing
|
||||
Reference in New Issue
Block a user