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.
5.3 KiB
Design
Context
See proposal.md — Why. After waves 1 and 2 the mall serves catalog, currency and auth live through the per-domain liveDomains switch (apps/mall/plugins/api.ts); cart, orders, shipments and invoices remain on fixed data. Facts that shape this design:
- The fixed-data adapter holds
state.cart -> state.orders -> state.shipmentsin one localStorage blob (apps/mall/mock/api.ts:31-43):checkout()readsstate.cart(:194),requestInvoice()looks upstate.orders(:285),listMyShipments()returnsstate.shipments(:282). - The live cart is Redis-backed per user;
cart_viewalready joins products and shops (apps/api/src/cart.rs:68-74) but selects onlyprice_minorandcurrency. - The live cart checks purchasability, not stock (
apps/api/src/routes/cart.rs:31-47); checkout is where stock is enforced, answering 409. packages/shared/src/types.tsrequiresShipment.itemsand a non-nullInvoice.invoice_no; the API returns neither (apps/api/src/models.rs:172-182,:192-205).- Order and payment pages already fall back to a generic store label when
storeByIdmisses (pages/user/orders/index.vue:94,pages/checkout/pay.vue:42), so live orders degrade rather than break. pages/goods/[id].vueis public and is not behindmiddleware/auth.ts.
Goals / Non-Goals
Goals:
- One purchase runs end to end against the backend: cart → per-shop orders → payment → shipment → invoice.
- The cart keeps grouping per shop and capping quantity at real stock.
- The shared contract stops describing fields the API never sends.
Non-Goals:
- No stock check on add-to-cart; checkout stays the authority.
- No store names on order or shipment surfaces — the Wave 4 public store read owns that.
- No addresses model, and no deletion of the mock cart/order code, which the rollback path needs.
Decisions
1. The four domains flip together, in dependency order. They share entities, so any subset leaves the mock half reading state the live half never writes. Cart must precede orders, orders precede shipments and invoices, and the flip lands in a single commit so no shopper can reach a live cart in front of a mock checkout. Alternative: bridge a live cart into the mock's order state — rejected as throwaway code that would still be wrong for invoice lookups.
2. Add shop_id, shop_name and stock to CartItemView rather than reading the mock catalog.
The join already exists (cart.rs:68-74), so this is three columns. Without it every live cart line collapses into one "unknown" group (pages/cart.vue:31-42) and the quantity stepper falls back to a hard-coded 999 (pages/cart.vue:60) — both visible regressions of things the UI currently does correctly.
Alternative: derive the shop client-side — impossible, a live cart line carries no shop.
3. Stock stays advisory in the cart.
Exposing stock lets the stepper cap, matching the mock's behaviour, but the API deliberately does not re-check it when adding: two shoppers can race regardless, and checkout's 409 is the real gate. Recorded explicitly so nobody mistakes the cart for a stock reservation.
4. An anonymous add-to-cart redirects to /login?redirect=….
The alternative — an inline "sign in to buy" panel — still leaves the shopper to find sign-in themselves, and a return path is needed either way. The redirect value is accepted only as a same-origin path, so it cannot become an open redirect.
5. Fix the contract by relaxing the types, not by inventing API fields.
Shipment.items becomes optional and Invoice.invoice_no becomes nullable. Nothing consumes shipment items, and the API genuinely does not send them, so adding fields nobody reads would be speculative. The invoices table renders a placeholder for a null number, mirroring how it already handles a missing order_no (pages/user/invoices.vue:46).
6. Keep the fixed-data cart and order code.
The Mock API adapter requirement promises the adapter can still serve every domain, so deleting it would break the documented rollback. This wave adds nothing to it, and touches no requirement that waves 1 or 2 modify — so archive order cannot clobber their text.
Risks / Trade-offs
- [Shoppers lose their existing mock cart and orders] → intended and marked BREAKING; the localStorage blob is left untouched, so rolling
liveDomainsback restores it. - [Exposed stock can go stale between read and checkout] → advisory by design (decision 3); checkout remains authoritative.
- [Order surfaces lose real store names] → pre-existing fallback to a generic label, unchanged here, owned by Wave 4.
- [A live cart needs a token while the product page is public] → decision 4 is the gate, verified explicitly for a signed-out shopper.
- [Four domains moving at once is a large diff] → the milestone order in
tasks.mdkeeps the backend additive and each verification step independent.
Migration Plan
- Backend and contract first: extend
cart_view's selected columns and the shared types. Both are additive and still serve the fixed-data path. - Flip the four domains in
liveDomainsin the same commit as the page changes. - Verify one purchase end to end, then verify the all-fixed-data rollback with the backend stopped.
- Rollback: remove the four names from
liveDomains; no data migration to reverse.