Stage M5 and Stage M6

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 7688657d3c
commit 624a6402bf
77 changed files with 4879 additions and 176 deletions
+62 -6
View File
@@ -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