Stage M5 and Stage M6
This commit is contained in:
+62
-6
@@ -130,7 +130,8 @@ it is not a design-system primitive.
|
||||
|
||||
## 5. Backend module boundaries
|
||||
|
||||
Twenty modules, each owning its tables exclusively. Full anatomy in
|
||||
Twenty-one modules, each owning its tables exclusively — `checkout` is the exception and owns
|
||||
none, existing purely to coordinate cart, catalog and inventory. Full anatomy in
|
||||
[`apps/api/src/modules/README.md`](../apps/api/src/modules/README.md).
|
||||
|
||||
| Group | Modules |
|
||||
@@ -508,16 +509,57 @@ was assumed rather than asserted. `packages/validation/src/catalog-admin.spec.ts
|
||||
|
||||
So: **when a schema encodes an intent, assert the intent — not the shape.** "Absent means leave
|
||||
alone" and "one language is enough" are claims about behaviour, and a type signature cannot make
|
||||
either of them true. Seed data has been through every code path already; new data has been through none. The
|
||||
either of them true.
|
||||
|
||||
The review before M6 found the sharpest one yet, and no amount of reading would have caught it.
|
||||
Checkout reserved stock by reading `on_hand - reserved`, checking it, then writing
|
||||
`reserved + n` — all inside a transaction, which _looks_ safe and is not. Two concurrent
|
||||
checkouts for a single unit both read `reserved = 0`, both wrote `1`, and **both orders were
|
||||
created**: one unit sold twice, with the reservation count showing one. A transaction gives
|
||||
atomicity, not isolation from a concurrent read-modify-write; under Postgres's default READ
|
||||
COMMITTED the second writer simply overwrites.
|
||||
|
||||
The fix is to let the database do the arithmetic and carry its own guard:
|
||||
|
||||
```sql
|
||||
UPDATE stock_levels SET reserved = reserved + $n
|
||||
WHERE variant_id = $v AND location_id = $l AND on_hand - reserved >= $n
|
||||
```
|
||||
|
||||
Zero rows affected means someone got there first. The same shape now applies to releasing and
|
||||
committing reservations, and order status transitions use a compare-and-set on the status they
|
||||
were validated against.
|
||||
|
||||
So: **any state that two requests can contend for must be changed in one statement that re-checks
|
||||
its own precondition.** Reading, deciding in JavaScript and then writing is a lost update wearing
|
||||
a transaction as a disguise — and the test for it is not a code review, it is two requests fired
|
||||
at once. Seed data has been through every code path already; new data has been through none. The
|
||||
inventory case is pinned in `apps/api/src/modules/inventory/inventory-list.spec.ts`.
|
||||
|
||||
M6 added a variant that is worth naming separately, because the symptom pointed at the wrong
|
||||
layer entirely. Search returned nothing for typos, SKUs or Vietnamese without diacritics — but
|
||||
matched exact names perfectly. Two separate causes, and the first masked the second:
|
||||
|
||||
1. `listByIds` passed the whole filter through to the catalog, which re-applied `q` as a plain
|
||||
`name CONTAINS q`. Everything search found by brand, SKU, typo or diacritic was then
|
||||
intersected away by a substring match on the name.
|
||||
2. A SQL comment inside a tagged template literal contained a backtick. That terminated the
|
||||
template, produced invalid JavaScript, and the API **crashed on startup** — while an older
|
||||
process kept serving port 4000. Every test I ran was answered by the previous build.
|
||||
|
||||
The second is the one to remember. `pkill -f "node dist/main.js"` had not matched the running
|
||||
process, `/health` returned 200 the whole time, and the build itself reported success. Restarts
|
||||
are now done by killing whatever holds the port and then confirming the new process actually
|
||||
logged a successful start — checking that the port answers proves nothing about _which_ build is
|
||||
answering.
|
||||
|
||||
---
|
||||
|
||||
## 16. Deliberate limitations
|
||||
|
||||
Stated plainly so they are choices rather than oversights.
|
||||
|
||||
**Shipped (M0–M3)**
|
||||
**Shipped (M0–M6)**
|
||||
|
||||
- Catalog reads: products, variants, options, categories, collections, brands, navigation —
|
||||
localised, cached, filtered and faceted.
|
||||
@@ -527,6 +569,15 @@ Stated plainly so they are choices rather than oversights.
|
||||
- Admin catalog authoring: product create/edit with per-locale content tabs, an option builder that
|
||||
regenerates the variant matrix, per-variant SKU and pricing, imagery assigned per colourway,
|
||||
media uploaded straight to storage, and stock received through the append-only ledger.
|
||||
- Storefront listing controls: sort, load-more pagination that stays crawlable, and a mobile
|
||||
filter sheet.
|
||||
- Search: PostgreSQL full-text with weighted documents, diacritic folding and trigram typo
|
||||
tolerance, behind a `SearchProvider` seam (ADR-0012). The index is a projection owned by
|
||||
SearchModule and rebuilt from a domain event, so nothing else knows it exists.
|
||||
- Commerce: a Redis-backed guest bag priced from the live catalog on every read, guest checkout,
|
||||
orders that snapshot everything they display (ADR-0018), stock reserved at placement and either
|
||||
released on cancel or shipped on fulfilment, and an admin order lifecycle with an explicit
|
||||
transition table.
|
||||
|
||||
**Not built yet, and why**
|
||||
|
||||
@@ -539,9 +590,14 @@ Stated plainly so they are choices rather than oversights.
|
||||
endpoint. Noted in the code.
|
||||
- **No email.** Password reset, order confirmations and back-in-stock alerts all need it; Mailpit
|
||||
is already running locally for when it lands.
|
||||
- **No cart, order or payment tables.** M5, so the migration history stays reviewable.
|
||||
- **`best_selling` and `relevance` sorts fall back to newest.** No order data (M5), no ranking
|
||||
(M6). Falling back is honest; a fake ranking would not be.
|
||||
- **No payment tables.** M9. An order lands `PENDING` / `UNPAID`; nothing pretends money moved.
|
||||
- **No shipping cost.** M9. `shippingAmount` is a stored zero rather than an absent field, so a
|
||||
total is always the sum of parts someone can name.
|
||||
- **Carts are not promoted to PostgreSQL.** A guest bag lives in Redis and is the acceptable loss
|
||||
named in ADR-0010; attaching one to a customer account arrives with M8.
|
||||
- **`best_selling` still falls back to newest.** Orders exist now, but ranking by them needs enough
|
||||
of them to mean something. `relevance` is real as of M6. Falling back is honest; a fake ranking
|
||||
is not.
|
||||
- **No sport/gender facet counts.** Navigated by route, not refined in-page, so a count would
|
||||
render nowhere.
|
||||
- **Facet counts are computed per request.** Fine at this catalog size; the fix when it is not is
|
||||
|
||||
Reference in New Issue
Block a user