feat(catalog): schema v3 — fitment, oemNumbers, priceUpdatedAt, availability tri-state #53

Merged
yuris merged 3 commits from feat/41-catalog-v3 into main 2026-08-27 11:18:25 +00:00
Owner

Closes #41.
Closes #42.

Lockstep bump: both schemas are .strict() and the store re-parses the catalog at build time, so producer (#42) and consumer (#41) cannot land separately. No v2 compatibility branch, exactly as #34 did for v2 — the version literal rejects a v2 payload before any field is read, which is what lets apps/store/scripts/wait-for-catalog.mts gate the deploy instead of failing the build with a wall of Zod errors.

What changed

Contract — packages/catalog/index.ts

  • schemaVersion: 3. inStock deleted, not kept alongside.
  • availability: "ready" | "indent" | "habis" — the reason to bump: indent (orderable, not stocked) is a state a boolean cannot express. Still no stock count.
  • fitment: string[] — free-text vehicle strings, not a year/engine/trim matrix (the spec rejects the matrix explicitly).
  • oemNumbers: string[] — empty array, never null, so consumers need no null branch.
  • priceUpdatedAt: string | null — ISO timestamp, same isoTimestamp regex generatedAt already uses. A new item-level superRefine rejects a date on an unpriced item, so "harga per <date>" can never be a date about nothing.

Producer — apps/management

  • lib/db/schema.ts + committed migration drizzle/0011_early_lyja.sql (generated by pnpm db:generate, not hand-written): oem_numbers text[] not null default '{}', fitment text[] not null default '{}' (mirroring the existing images column), price_updated_at timestamp, allow_indent boolean not null default false. All nullable or defaulted, so existing rows are safe.
  • lib/catalog.ts#getPublicCatalog selects the new columns, emits schemaVersion: 3, drops inStock, and maps stock > 0 ? "ready" : allowIndent ? "indent" : "habis". Availability stays derived from the stock ledger — one boolean column, no dual source of truth, no way for availability to contradict stock. buyPrice, avgCost, suppliers and the raw stock number stay out of the payload; allowIndent does too.
  • priceUpdatedAt is derived together with price in the same closure, so an unpriced row cannot carry a date even if one is stored. Blank rows from the repeatable inputs are trimmed out of fitment/oemNumbers for the same reason the category/price/weight coercions already exist: one bad row would otherwise reject the entire payload.
  • price_updated_at is stamped by every write path that changes sellPrice — lib/inventory/actions.ts (create + update) and lib/mcp/tools/inventory.ts (create + update). The issue only named updateInventory, but the MCP tool is a second writer of sellPrice, and a price changed through the bot with a stale public date is exactly the "stale price is worse than no price" failure the spec warns about.
  • lib/db/validators.ts — InventoryItemSchema, CreateInventorySchema, UpdateInventorySchema.
  • lib/store/deploy.ts — oemNumbers, fitment, priceUpdatedAt, allowIndent added to STOREFRONT_COLUMNS so edits trigger a republish; the doc comment's "in-stock badge" is now "Ready/Indent/Habis chip".
  • lib/inventory/{types,queries}.ts — the dashboard row carries the new fields; price_updated_at crosses to the client as an ISO string.
  • Forms: stok/add-inventory-modal.tsx and stok/inventory-edit-form.tsx get repeatable rows for fitment and oemNumbers plus an allow_indent checkbox. Both edits are additive — the existing images row blocks are untouched so #43 and #44 rebase cleanly. The rows live in one new stok/text-rows.tsx (4 call sites) rather than a third copy of the image-row markup in each form.
  • stok/inventory-details-sheet.tsx — read-only rows for Nomor OEM, Kecocokan, Harga Diperbarui (via the existing formatDate) and Indent.

Consumer — apps/store

  • products/product-card.tsx — availability replaces inStock; exports availabilityLabels and availabilityBadgeClass, one chip class per state: stock-badge for ready, stock-badge is-indent for indent, stock-badge is-out for habis. indent never shares is-out, because #52 repoints that class to the red-brown --off token and a red chip on an orderable item tells the buyer the opposite of the truth. The card's own is-out dimming still applies to habis only.
  • products/[slug]/page.tsx — the chip renders Ready/Indent/Habis, and the Product JSON-LD maps indent to https://schema.org/BackOrder. InStock for an indent item would be a lie to search results; OutOfStock would hide an item the shop will order in.
  • products/[slug]/add-to-cart.tsx — only habis takes the disabled branch. indent keeps the full cart flow and gains one note explaining that the item is ordered in. The habis copy no longer says "barang bisa kami pesankan" — that promise now belongs to indent, which is the whole point of splitting the boolean.

Nothing invented by the mockups was added: no Posisi, no Tipe, no Garansi, no volumetric weight, no stock quantity anywhere.

Verified

No workflow triggers on pull_request — both .forgejo/workflows/*.yml fire only on push to main plus workflow_dispatch, and both deploy. This PR has no forge checks and never will; every green light below is a command I ran locally at b1c6681.

pnpm --filter @garage/catalog test

1..1
# tests 20
# suites 1
# pass 20
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 170.930398

New cases: a v2 payload is rejected with Unsupported catalog schema version; every enum member accepted; availability outside the enum rejected ("Ready", "preorder", true, null); the v2 inStock key rejected; non-string fitment/oemNumbers members rejected; oemNumbers: null rejected; priceUpdatedAt rejects "12 Agustus 2026", "2026-08-12" and ""; priceUpdatedAt set while price is null rejected. The duplicate-slug superRefine, the slug sort, the v1 rejection and the unknown-key rejection all still pass.

pnpm --filter garage test

 RUN  v4.1.10 /home/yuris/Development/webdev/wt-41/apps/management

 Test Files  5 passed (5)
      Tests  64 passed (64)
   Duration  729ms (transform 345ms, setup 0ms, import 1.23s, tests 80ms, environment 0ms)

lib/catalog.test.ts now fixtures four published rows and asserts the availability table item by item: stock 12 + allowIndent true → ready (the flag is ignored when stocked), stock 0 + allowIndent true → indent, stock 0 + allowIndent false → habis, stock 3 → ready. Plus: priceUpdatedAt emitted as 2026-08-12T04:05:06.000Z, null when sell_price is 0, and dropped when the contract drops the price; oem_numbers/fitment round-trip, default to [], and blank rows are filtered; the payload contains none of buyPrice, avgCost, sellPrice, stock, allowIndent, inStock. lib/store/deploy.test.ts asserts the four new columns are storefront-affecting.

pnpm --filter garage-store test

# tests 17
# suites 5
# pass 17
# fail 0

npx tsc --noEmit in both apps: exit 0.

Store build against a v3 fixture — a throwaway node server on 127.0.0.1:4341 serving one ready, one indent and one habis item (not committed):

$ node --experimental-strip-types scripts/wait-for-catalog.mts
catalog ok: schemaVersion 3, 3 items (attempt 1)

$ pnpm --filter garage-store build
✓ Compiled successfully in 4.6s
  Finished TypeScript in 2.3s
✓ Generating static pages using 13 workers (19/19) in 655ms
├ ● /products/[slug]
│ ├ /products/filter-oli-universal
│ ├ /products/oli-mesin-10w-40
│ └ /products/shock-absorber-belakang

Grepped out of the static export, so the consumer is verified rendering and not just compiling:

page chip JSON-LD cart
oli-mesin-10w-40 (ready) class="stock-badge">Ready< schema.org/InStock live
shock-absorber-belakang (indent) class="stock-badge is-indent">Indent< schema.org/BackOrder live, 0 disabled, indent note present
filter-oli-universal (habis) class="stock-badge is-out">Habis< no offers (price is null, pre-existing) 1 disabled

On the homepage exactly one card carries class="product-card is-out" (the habis one) — indent and ready render as normal cards.

biome check on the 20 changed .ts/.tsx files: Checked 20 files in 17ms. No fixes applied. Found 1 warning. exit 0. The single warning is lint/performance/noImgElement on the <img> in product-card.tsx, on a line this PR does not touch — git show origin/main:…/product-card.tsx reproduces the same warning. No repo-wide formatter run; the three formatting fixes biome flagged were applied to my own files only.

Deviations

  • indent is styled by #45's --warn token through a dedicated is-indent class. This PR emits the class; the .stock-badge.is-indent rule ships in PR #52 (apps/store/app/globals.css is Author45's this wave), where --warn is introduced. The rule is inert until this class exists and the class is unstyled until the rule lands, so the two PRs are safe to merge in either order. Full tri-state chip treatment — tinted background plus border, so the three states do not rely on hue alone — lands with #47, which is blocked on both this PR and #52.
  • priceUpdatedAt is a full ISO timestamp, not a bare YYYY-MM-DD. The column is a timestamp, generatedAt already validates with the same regex, and rendering "12 Agustus 2026" is the consumer's job. Rendering it on the storefront belongs to #47, which owns product-detail rendering, so this PR carries the field but does not display it publicly yet; the management sheet does show it.
  • Per #40's closed decision, no stock quantity is exported or rendered, and the mockup-invented Posisi / Tipe / Garansi / volumetric-weight fields were not added.
  • #46 filters, #47 detail rendering, #43 form name/price fields and #44 image upload are deliberately not implemented here.

Notes for the next catalog schema change

  • PublicCatalogSchema feeds a live OpenAPI document — generate it and check. app/api/[[...route]]/route.ts:64 calls app.doc("/openapi.json", …) and :69 mounts Swagger UI at /api/docs, and catalog.ts:14 registers this schema as the catalog 200 response. So the contract is walked for JSON-Schema generation, and this repo has no pull_request CI to catch a schema that breaks it. Verified at this head: the document returns 200 with the item schema complete, 16/16 required properties, and both the new priceUpdatedAt pattern and the availability enum surviving into the JSON Schema. Only the cross-field price/date pairing is absent, which is inherent — no JSON Schema expresses it — and is not a regression, since that check previously lived only on the unregistered StrictItemSchema.
  • The item schema is a ZodObject even with a refinement on it. zod@^4.3.6 (4.4.3 resolved) keeps refinements inside the schema, so .superRefine() returns a ZodObject that still carries .extend()/.omit()/.pick()/.shape. Composing it is safe; the zod-3 ZodEffects hazard does not apply here.
  • biome check over the drizzle-generated JSON exits 1 on main too. meta/*_snapshot.json and meta/_journal.json come out of drizzle-kit without a trailing newline. Lint the hand-written TypeScript; reformatting the generated JSON only desyncs it from the generator.
  • price_updated_at is stamped when sellPrice is present in a write, not when it changes. It therefore errs fresh, never stale, and nothing renders it yet. #47 owns the harga per <tanggal> line that will first make this visible to a buyer, and the carry-forward is recorded there.

Merging this deploys to production: a push to main publishes the management container and runs wrangler pages deploy for the store. Because the contract is strict in both directions, this must merge as one commit — merging #41 without #42 would deploy a store that cannot build.

Closes #41. Closes #42. Lockstep bump: both schemas are `.strict()` and the store re-parses the catalog at build time, so producer (#42) and consumer (#41) cannot land separately. No v2 compatibility branch, exactly as #34 did for v2 — the version literal rejects a v2 payload before any field is read, which is what lets `apps/store/scripts/wait-for-catalog.mts` gate the deploy instead of failing the build with a wall of Zod errors. ## What changed **Contract — `packages/catalog/index.ts`** - `schemaVersion: 3`. `inStock` deleted, not kept alongside. - `availability: "ready" | "indent" | "habis"` — the reason to bump: `indent` (orderable, not stocked) is a state a boolean cannot express. Still no stock count. - `fitment: string[]` — free-text vehicle strings, not a year/engine/trim matrix (the spec rejects the matrix explicitly). - `oemNumbers: string[]` — empty array, never null, so consumers need no null branch. - `priceUpdatedAt: string | null` — ISO timestamp, same `isoTimestamp` regex `generatedAt` already uses. A new item-level `superRefine` rejects a date on an unpriced item, so `"harga per <date>"` can never be a date about nothing. **Producer — `apps/management`** - `lib/db/schema.ts` + committed migration `drizzle/0011_early_lyja.sql` (generated by `pnpm db:generate`, not hand-written): `oem_numbers text[] not null default '{}'`, `fitment text[] not null default '{}'` (mirroring the existing `images` column), `price_updated_at timestamp`, `allow_indent boolean not null default false`. All nullable or defaulted, so existing rows are safe. - `lib/catalog.ts#getPublicCatalog` selects the new columns, emits `schemaVersion: 3`, drops `inStock`, and maps `stock > 0 ? "ready" : allowIndent ? "indent" : "habis"`. Availability stays **derived** from the stock ledger — one boolean column, no dual source of truth, no way for availability to contradict stock. `buyPrice`, `avgCost`, suppliers and the raw `stock` number stay out of the payload; `allowIndent` does too. - `priceUpdatedAt` is derived together with `price` in the same closure, so an unpriced row cannot carry a date even if one is stored. Blank rows from the repeatable inputs are trimmed out of `fitment`/`oemNumbers` for the same reason the category/price/weight coercions already exist: one bad row would otherwise reject the entire payload. - `price_updated_at` is stamped by **every** write path that changes `sellPrice` — `lib/inventory/actions.ts` (create + update) and `lib/mcp/tools/inventory.ts` (create + update). The issue only named `updateInventory`, but the MCP tool is a second writer of `sellPrice`, and a price changed through the bot with a stale public date is exactly the "stale price is worse than no price" failure the spec warns about. - `lib/db/validators.ts` — `InventoryItemSchema`, `CreateInventorySchema`, `UpdateInventorySchema`. - `lib/store/deploy.ts` — `oemNumbers`, `fitment`, `priceUpdatedAt`, `allowIndent` added to `STOREFRONT_COLUMNS` so edits trigger a republish; the doc comment's "in-stock badge" is now "Ready/Indent/Habis chip". - `lib/inventory/{types,queries}.ts` — the dashboard row carries the new fields; `price_updated_at` crosses to the client as an ISO string. - Forms: `stok/add-inventory-modal.tsx` and `stok/inventory-edit-form.tsx` get repeatable rows for `fitment` and `oemNumbers` plus an `allow_indent` checkbox. Both edits are **additive** — the existing `images` row blocks are untouched so #43 and #44 rebase cleanly. The rows live in one new `stok/text-rows.tsx` (4 call sites) rather than a third copy of the image-row markup in each form. - `stok/inventory-details-sheet.tsx` — read-only rows for Nomor OEM, Kecocokan, Harga Diperbarui (via the existing `formatDate`) and Indent. **Consumer — `apps/store`** - `products/product-card.tsx` — `availability` replaces `inStock`; exports `availabilityLabels` and `availabilityBadgeClass`, one chip class per state: `stock-badge` for ready, `stock-badge is-indent` for indent, `stock-badge is-out` for habis. `indent` never shares `is-out`, because #52 repoints that class to the red-brown `--off` token and a red chip on an orderable item tells the buyer the opposite of the truth. The card's own `is-out` dimming still applies to `habis` only. - `products/[slug]/page.tsx` — the chip renders Ready/Indent/Habis, and the `Product` JSON-LD maps `indent` to `https://schema.org/BackOrder`. `InStock` for an indent item would be a lie to search results; `OutOfStock` would hide an item the shop will order in. - `products/[slug]/add-to-cart.tsx` — only `habis` takes the disabled branch. `indent` keeps the full cart flow and gains one note explaining that the item is ordered in. The `habis` copy no longer says "barang bisa kami pesankan" — that promise now belongs to `indent`, which is the whole point of splitting the boolean. Nothing invented by the mockups was added: no `Posisi`, no `Tipe`, no `Garansi`, no volumetric weight, no stock quantity anywhere. ## Verified No workflow triggers on `pull_request` — both `.forgejo/workflows/*.yml` fire only on `push` to `main` plus `workflow_dispatch`, and both *deploy*. **This PR has no forge checks and never will; every green light below is a command I ran locally at `b1c6681`.** `pnpm --filter @garage/catalog test` ``` 1..1 # tests 20 # suites 1 # pass 20 # fail 0 # cancelled 0 # skipped 0 # todo 0 # duration_ms 170.930398 ``` New cases: a v2 payload is rejected with `Unsupported catalog schema version`; every enum member accepted; `availability` outside the enum rejected (`"Ready"`, `"preorder"`, `true`, `null`); the v2 `inStock` key rejected; non-string `fitment`/`oemNumbers` members rejected; `oemNumbers: null` rejected; `priceUpdatedAt` rejects `"12 Agustus 2026"`, `"2026-08-12"` and `""`; `priceUpdatedAt` set while `price` is null rejected. The duplicate-slug `superRefine`, the slug sort, the v1 rejection and the unknown-key rejection all still pass. `pnpm --filter garage test` ``` RUN v4.1.10 /home/yuris/Development/webdev/wt-41/apps/management Test Files 5 passed (5) Tests 64 passed (64) Duration 729ms (transform 345ms, setup 0ms, import 1.23s, tests 80ms, environment 0ms) ``` `lib/catalog.test.ts` now fixtures four published rows and asserts the availability table item by item: `stock 12 + allowIndent true → ready` (the flag is ignored when stocked), `stock 0 + allowIndent true → indent`, `stock 0 + allowIndent false → habis`, `stock 3 → ready`. Plus: `priceUpdatedAt` emitted as `2026-08-12T04:05:06.000Z`, null when `sell_price` is 0, and dropped when the contract drops the price; `oem_numbers`/`fitment` round-trip, default to `[]`, and blank rows are filtered; the payload contains none of `buyPrice`, `avgCost`, `sellPrice`, `stock`, `allowIndent`, `inStock`. `lib/store/deploy.test.ts` asserts the four new columns are storefront-affecting. `pnpm --filter garage-store test` ``` # tests 17 # suites 5 # pass 17 # fail 0 ``` `npx tsc --noEmit` in both apps: exit 0. **Store build against a v3 fixture** — a throwaway `node` server on `127.0.0.1:4341` serving one `ready`, one `indent` and one `habis` item (not committed): ``` $ node --experimental-strip-types scripts/wait-for-catalog.mts catalog ok: schemaVersion 3, 3 items (attempt 1) $ pnpm --filter garage-store build ✓ Compiled successfully in 4.6s Finished TypeScript in 2.3s ✓ Generating static pages using 13 workers (19/19) in 655ms ├ ● /products/[slug] │ ├ /products/filter-oli-universal │ ├ /products/oli-mesin-10w-40 │ └ /products/shock-absorber-belakang ``` Grepped out of the static export, so the consumer is verified rendering and not just compiling: | page | chip | JSON-LD | cart | | --- | --- | --- | --- | | `oli-mesin-10w-40` (ready) | `class="stock-badge">Ready<` | `schema.org/InStock` | live | | `shock-absorber-belakang` (indent) | `class="stock-badge is-indent">Indent<` | `schema.org/BackOrder` | live, 0 `disabled`, indent note present | | `filter-oli-universal` (habis) | `class="stock-badge is-out">Habis<` | no `offers` (price is null, pre-existing) | 1 `disabled` | On the homepage exactly one card carries `class="product-card is-out"` (the `habis` one) — `indent` and `ready` render as normal cards. `biome check` on the 20 changed `.ts`/`.tsx` files: `Checked 20 files in 17ms. No fixes applied. Found 1 warning.` exit 0. The single warning is `lint/performance/noImgElement` on the `<img>` in `product-card.tsx`, on a line this PR does not touch — `git show origin/main:…/product-card.tsx` reproduces the same warning. No repo-wide formatter run; the three formatting fixes biome flagged were applied to my own files only. ## Deviations - **`indent` is styled by #45's `--warn` token through a dedicated `is-indent` class.** This PR emits the class; the `.stock-badge.is-indent` rule ships in PR #52 (`apps/store/app/globals.css` is Author45's this wave), where `--warn` is introduced. The rule is inert until this class exists and the class is unstyled until the rule lands, so the two PRs are safe to merge in either order. Full tri-state chip treatment — tinted background plus border, so the three states do not rely on hue alone — lands with #47, which is blocked on both this PR and #52. - **`priceUpdatedAt` is a full ISO timestamp, not a bare `YYYY-MM-DD`.** The column is a `timestamp`, `generatedAt` already validates with the same regex, and rendering "12 Agustus 2026" is the consumer's job. Rendering it on the storefront belongs to #47, which owns product-detail rendering, so this PR carries the field but does not display it publicly yet; the management sheet does show it. - Per #40's closed decision, no stock quantity is exported or rendered, and the mockup-invented `Posisi` / `Tipe` / `Garansi` / volumetric-weight fields were not added. - #46 filters, #47 detail rendering, #43 form name/price fields and #44 image upload are deliberately not implemented here. ## Notes for the next catalog schema change - **`PublicCatalogSchema` feeds a live OpenAPI document — generate it and check.** `app/api/[[...route]]/route.ts:64` calls `app.doc("/openapi.json", …)` and `:69` mounts Swagger UI at `/api/docs`, and `catalog.ts:14` registers this schema as the catalog 200 response. So the contract is walked for JSON-Schema generation, and this repo has no `pull_request` CI to catch a schema that breaks it. Verified at this head: the document returns 200 with the item schema complete, 16/16 required properties, and both the new `priceUpdatedAt` pattern and the `availability` enum surviving into the JSON Schema. Only the cross-field price/date pairing is absent, which is inherent — no JSON Schema expresses it — and is not a regression, since that check previously lived only on the unregistered `StrictItemSchema`. - **The item schema is a `ZodObject` even with a refinement on it.** `zod@^4.3.6` (4.4.3 resolved) keeps refinements inside the schema, so `.superRefine()` returns a `ZodObject` that still carries `.extend()`/`.omit()`/`.pick()`/`.shape`. Composing it is safe; the zod-3 `ZodEffects` hazard does not apply here. - **`biome check` over the drizzle-generated JSON exits 1 on `main` too.** `meta/*_snapshot.json` and `meta/_journal.json` come out of `drizzle-kit` without a trailing newline. Lint the hand-written TypeScript; reformatting the generated JSON only desyncs it from the generator. - **`price_updated_at` is stamped when `sellPrice` is *present* in a write, not when it *changes*.** It therefore errs fresh, never stale, and nothing renders it yet. #47 owns the `harga per <tanggal>` line that will first make this visible to a buyer, and the carry-forward is recorded there. **Merging this deploys to production**: a push to `main` publishes the management container and runs `wrangler pages deploy` for the store. Because the contract is strict in both directions, this must merge as one commit — merging #41 without #42 would deploy a store that cannot build.
Lockstep producer + consumer bump, no v2 compatibility branch (as #34 did
for v2). `inStock` is gone; `availability` is "ready" | "indent" | "habis",
because indent — orderable but not stocked — is a state a boolean cannot
express and the pesanan-sparepart flow depends on it.

Management derives availability from the stock ledger and one new boolean,
`allow_indent`, so availability can never contradict stock. `price_updated_at`
is stamped by every write path that changes sellPrice, so the storefront's
date is a fact rather than an operator-maintained claim. No stock quantity is
exported or rendered.

Closes #41.
Closes #42.
`.stock-badge.is-out` becomes red-brown `--off` in #45's chip repaint, so
sharing it would paint "Indent" the colour that means "Habis" — a red chip on
an orderable item tells the buyer the opposite of the truth. Indent now emits
`is-indent`, styled by #45's `--warn` token; the CSS rule ships with #52 and is
inert until this class exists, so either merge order is safe.
Author
Owner

Pushed b1c6681 per Main: the indent chip now emits its own `is-indent` class instead of borrowing `is-out`, so #52 repointing `.stock-badge.is-out` to `--off` cannot paint an orderable item the colour that means Habis. No CSS touched here — `globals.css` stays Author45's, and the `.stock-badge.is-indent` rule is inert until this class ships, so either merge order is safe.

Re-verified at b1c6681, not carried over from the previous SHA:

  • pnpm --filter @garage/catalog test — 20/20
  • pnpm --filter garage test — 5 files, 64/64
  • pnpm --filter garage-store test — 17/17
  • npx tsc --noEmit in apps/store — exit 0
  • pnpm --filter garage-store build against the v3 fixture — compiled, 19/19 static pages

Chips grepped out of the fresh static export:

oli-mesin-10w-40.html:class="stock-badge">Ready<
shock-absorber-belakang.html:class="stock-badge is-indent">Indent<
filter-oli-universal.html:class="stock-badge is-out">Habis<

Homepage cards agree: one is-indent chip, one is-out chip, and only the habis card carries class="product-card is-out".

biome check on the two changed files: Checked 2 files. Found 1 warning. exit 0 — the warning is the pre-existing noImgElement on the untouched <img> line, reproducible on origin/main.

PR body deviation section updated to state that indent is styled by #45's --warn token via is-indent, and that the full tri-state chip treatment (tinted background + border, so the states do not rely on hue alone) lands with #47, which is blocked on this PR and #52.

Pushed b1c6681 per Main: the indent chip now emits its own \`is-indent\` class instead of borrowing \`is-out\`, so #52 repointing \`.stock-badge.is-out\` to \`--off\` cannot paint an orderable item the colour that means Habis. No CSS touched here — \`globals.css\` stays Author45's, and the \`.stock-badge.is-indent\` rule is inert until this class ships, so either merge order is safe. Re-verified at b1c6681, not carried over from the previous SHA: - `pnpm --filter @garage/catalog test` — 20/20 - `pnpm --filter garage test` — 5 files, 64/64 - `pnpm --filter garage-store test` — 17/17 - `npx tsc --noEmit` in apps/store — exit 0 - `pnpm --filter garage-store build` against the v3 fixture — compiled, 19/19 static pages Chips grepped out of the fresh static export: ``` oli-mesin-10w-40.html:class="stock-badge">Ready< shock-absorber-belakang.html:class="stock-badge is-indent">Indent< filter-oli-universal.html:class="stock-badge is-out">Habis< ``` Homepage cards agree: one `is-indent` chip, one `is-out` chip, and only the habis card carries `class="product-card is-out"`. `biome check` on the two changed files: `Checked 2 files. Found 1 warning.` exit 0 — the warning is the pre-existing `noImgElement` on the untouched `<img>` line, reproducible on `origin/main`. PR body deviation section updated to state that indent is styled by #45's `--warn` token via `is-indent`, and that the full tri-state chip treatment (tinted background + border, so the states do not rely on hue alone) lands with #47, which is blocked on this PR and #52.
yuris left a comment

VERDICT: approve-with-nits

The data-integrity claim holds, and I verified it against a real Postgres 16 rather than the mocked suite. There is no stored availability state — allow_indent is the only new state column, and the single expression in lib/catalog.ts:64 checks stock > 0 first, so availability cannot contradict the stock ledger. I seeded four published rows and drove the flag against the stock in every direction:

stock 12 + allowIndent true  -> ready    (flag ignored when stocked)
stock  0 + allowIndent true  -> indent
stock  0 + allowIndent false -> habis
stock  3 + allowIndent false -> ready
then flipped: stock 7 + true -> ready,  stock 0 + false -> habis

allowIndent is neither in the mapped object nor survives PublicCatalogSchema stripping — double-guarded. Grepping the real emitted payload and then the whole static export for buyPrice, avgCost, sellPrice, stock, allowIndent, inStock, supplier: zero hits. The raw stock integer never leaves getPublicCatalog. parseCatalog accepted the real producer output, so the lockstep bump actually closes.

The migration is safe on a populated table, measured, not assumed. drizzle-kit generate at this head prints No schema changes, nothing to migrate and 0011_snapshot.prevId chains to 0010_snapshot.id, so it is generated and in sync, and the snapshot diff touches only these four inventory columns. I applied 0000–0010 to a throwaway cluster, inserted 5.000 rows, then applied 0011:

ALTER TABLE  2.837 ms / 1.134 ms / 1.221 ms / 1.248 ms
5000 rows | 5000 oem_numbers='{}' | 5000 fitment='{}' | 5000 price_updated_at IS NULL | 5000 allow_indent=false
pg_attribute: oem_numbers/fitment/allow_indent atthasmissing = t

atthasmissing = t is PG's fast default — metadata only, no table rewrite. The array columns are byte-identical in form to 0009's images column.

Reproduced every claim: catalog 20/20, garage 5 files 64/64, garage-store 17/17, tsc --noEmit exit 0 in both apps, biome check on the 20 changed files exit 0. I served the real v3 payload above from a throwaway node server, ran wait-for-catalog.mts (catalog ok: schemaVersion 3, 4 items) and built the store: 20/20 static pages, 4 product routes. From the export:

slug chip JSON-LD offers disabled indent note
filter-oli-universal (ready) class="stock-badge">Ready< — 0 (price null) 0 0
shock-absorber-belakang (indent) class="stock-badge is-indent">Indent< schema.org/BackOrder 1 0 1
oli-mesin-10w-40 (habis) class="stock-badge is-out">Habis< schema.org/OutOfStock 1 1 0

indent never shares is-out; the homepage carries exactly one class="product-card is-out" and it is the habis card. Offer is omitted on both null-price items. Only habis disables the cart.

Both deviations are correct. git grep sellPrice finds exactly four write sites — lib/inventory/actions.ts create+update and lib/mcp/tools/inventory.ts create+update — and all four stamp the date. Stamping only updateInventory as #42 literally said would leave the bot able to move a price behind a stale public date, which is the failure the spec names; widening to every writer is the minimum that makes the field true, not scope creep. The timestamp round-trip is exact, which I checked because this box is Asia/Jakarta and a naked timestamp column invites a 7-hour skew: wrote 2026-08-27T08:45:33.392Z, stored 2026-08-27 08:45:33.392, emitted 2026-08-27T08:45:33.392Z, drift 0 ms. On the class split, .stock-badge.is-indent genuinely does not exist in this branch's globals.css, so the indent chip inherits the base look and is distinguished by its label until #52 lands — degraded, never wrong, and reusing is-out would have painted an orderable item the colour that means Habis. Not requiring the CSS here.

Strictness confirmed: v2 rejected with Unsupported catalog schema version, no inStock left in any production path, duplicate-slug superRefine and the localeCompare slug sort both intact.

One real gap, and three nits.

apps/store/lib/cart.ts:L5: should: CartLine carries no indent flag and add-to-cart.tsx:L65 passes none, so checkout-message.ts:L25 sends an indent order indistinguishable from a stocked one — the shop gets 1. Shock Absorber Belakang (1) — Rp450.000 with no signal it is off-shelf. Before this PR an out-of-stock item could not enter the cart at all, so making indent orderable opens this; the buyer's note never reaches the shop. Not a blocker only because the indent copy promises a confirmation before payment, so a human is still in the loop. Add indent: boolean to CartLine and its isLine guard, pass availability === "indent", and extend the existing suffix chain with " [indent]" — the same shape as [pasang di tempat]. Coordinate with #51, which is touching cart.ts tests this wave.

apps/management/lib/orders/actions.ts:L23: nit: comment still says selling the last unit "flips the storefront's inStock badge" — a field this PR deletes. The sibling comment in deploy.ts was updated; update this one to the Ready/Indent/Habis chip.

packages/catalog/index.ts:L23: nit: the producer's priceUpdatedAt is a bare z.string().nullable() — no isoTimestamp regex and no price-null pairing refine, unlike the strict consumer. The mapping guarantees both today, so nothing is emitted wrong, but a future regression in getPublicCatalog would surface as a store build failure instead of a 500 at the API that produced it. Move the regex and the pairing check onto the shared item schema so the producer fails first.

apps/management/lib/inventory/actions.ts:L105: nit: the date is stamped whenever sell_price is present in the payload, not when it changes, so a PATCH or update_inventory call re-sending the current price moves the public date without the price moving. #42 says "whenever sellPrice changes". It errs fresh rather than stale, so it is precision, not correctness — a sql CASE comparing against the existing column would close it without an extra read.

VERDICT: approve-with-nits The data-integrity claim holds, and I verified it against a real Postgres 16 rather than the mocked suite. There is no stored availability state — `allow_indent` is the only new state column, and the single expression in `lib/catalog.ts:64` checks `stock > 0` first, so availability cannot contradict the stock ledger. I seeded four published rows and drove the flag against the stock in every direction: ``` stock 12 + allowIndent true -> ready (flag ignored when stocked) stock 0 + allowIndent true -> indent stock 0 + allowIndent false -> habis stock 3 + allowIndent false -> ready then flipped: stock 7 + true -> ready, stock 0 + false -> habis ``` `allowIndent` is neither in the mapped object nor survives `PublicCatalogSchema` stripping — double-guarded. Grepping the real emitted payload and then the whole static export for `buyPrice`, `avgCost`, `sellPrice`, `stock`, `allowIndent`, `inStock`, `supplier`: zero hits. The raw stock integer never leaves `getPublicCatalog`. `parseCatalog` accepted the real producer output, so the lockstep bump actually closes. **The migration is safe on a populated table**, measured, not assumed. `drizzle-kit generate` at this head prints `No schema changes, nothing to migrate` and `0011_snapshot.prevId` chains to `0010_snapshot.id`, so it is generated and in sync, and the snapshot diff touches only these four `inventory` columns. I applied `0000`–`0010` to a throwaway cluster, inserted 5.000 rows, then applied `0011`: ``` ALTER TABLE 2.837 ms / 1.134 ms / 1.221 ms / 1.248 ms 5000 rows | 5000 oem_numbers='{}' | 5000 fitment='{}' | 5000 price_updated_at IS NULL | 5000 allow_indent=false pg_attribute: oem_numbers/fitment/allow_indent atthasmissing = t ``` `atthasmissing = t` is PG's fast default — metadata only, no table rewrite. The array columns are byte-identical in form to `0009`'s `images` column. Reproduced every claim: catalog 20/20, garage 5 files 64/64, garage-store 17/17, `tsc --noEmit` exit 0 in both apps, `biome check` on the 20 changed files exit 0. I served the real v3 payload above from a throwaway node server, ran `wait-for-catalog.mts` (`catalog ok: schemaVersion 3, 4 items`) and built the store: 20/20 static pages, 4 product routes. From the export: | slug | chip | JSON-LD | offers | disabled | indent note | | --- | --- | --- | --- | --- | --- | | filter-oli-universal (ready) | `class="stock-badge">Ready<` | — | 0 (price null) | 0 | 0 | | shock-absorber-belakang (indent) | `class="stock-badge is-indent">Indent<` | `schema.org/BackOrder` | 1 | 0 | 1 | | oli-mesin-10w-40 (habis) | `class="stock-badge is-out">Habis<` | `schema.org/OutOfStock` | 1 | 1 | 0 | `indent` never shares `is-out`; the homepage carries exactly one `class="product-card is-out"` and it is the habis card. `Offer` is omitted on both null-price items. Only `habis` disables the cart. Both deviations are correct. `git grep sellPrice` finds exactly four write sites — `lib/inventory/actions.ts` create+update and `lib/mcp/tools/inventory.ts` create+update — and all four stamp the date. Stamping only `updateInventory` as #42 literally said would leave the bot able to move a price behind a stale public date, which is the failure the spec names; widening to every writer is the minimum that makes the field true, not scope creep. The timestamp round-trip is exact, which I checked because this box is `Asia/Jakarta` and a naked `timestamp` column invites a 7-hour skew: wrote `2026-08-27T08:45:33.392Z`, stored `2026-08-27 08:45:33.392`, emitted `2026-08-27T08:45:33.392Z`, drift 0 ms. On the class split, `.stock-badge.is-indent` genuinely does not exist in this branch's `globals.css`, so the indent chip inherits the base look and is distinguished by its label until #52 lands — degraded, never wrong, and reusing `is-out` would have painted an orderable item the colour that means Habis. Not requiring the CSS here. Strictness confirmed: v2 rejected with `Unsupported catalog schema version`, no `inStock` left in any production path, duplicate-slug `superRefine` and the `localeCompare` slug sort both intact. One real gap, and three nits. apps/store/lib/cart.ts:L5: should: `CartLine` carries no indent flag and `add-to-cart.tsx:L65` passes none, so `checkout-message.ts:L25` sends an indent order indistinguishable from a stocked one — the shop gets `1. Shock Absorber Belakang (1) — Rp450.000` with no signal it is off-shelf. Before this PR an out-of-stock item could not enter the cart at all, so making `indent` orderable opens this; the buyer's note never reaches the shop. Not a blocker only because the indent copy promises a confirmation before payment, so a human is still in the loop. Add `indent: boolean` to `CartLine` and its `isLine` guard, pass `availability === "indent"`, and extend the existing `suffix` chain with `" [indent]"` — the same shape as `[pasang di tempat]`. Coordinate with #51, which is touching `cart.ts` tests this wave. apps/management/lib/orders/actions.ts:L23: nit: comment still says selling the last unit "flips the storefront's `inStock` badge" — a field this PR deletes. The sibling comment in `deploy.ts` was updated; update this one to the Ready/Indent/Habis chip. packages/catalog/index.ts:L23: nit: the producer's `priceUpdatedAt` is a bare `z.string().nullable()` — no `isoTimestamp` regex and no price-null pairing refine, unlike the strict consumer. The mapping guarantees both today, so nothing is emitted wrong, but a future regression in `getPublicCatalog` would surface as a store build failure instead of a 500 at the API that produced it. Move the regex and the pairing check onto the shared item schema so the producer fails first. apps/management/lib/inventory/actions.ts:L105: nit: the date is stamped whenever `sell_price` is present in the payload, not when it changes, so a PATCH or `update_inventory` call re-sending the current price moves the public date without the price moving. #42 says "whenever `sellPrice` changes". It errs fresh rather than stale, so it is precision, not correctness — a `sql` CASE comparing against the existing column would close it without an extra read.
Author
Owner

Dispatcher adjudication — the should on the cart-line indent flag is withdrawn for this PR.

Reviewer53r1's finding is correct as an observation: CartLine carries no indent flag, so today an indent item reaches the shop's WhatsApp message indistinguishable from a stocked one. It is not, however, in scope for #41/#42, and it should not hold this PR.

It is #47's stated acceptance criterion, verbatim. From #47's body:

indent is the one that changes behaviour — the item stays orderable, so add-to-cart.tsx must not reuse its current !inStock disabled branch for it. Label it plainly (barang dipesan dulu, bukan stok toko) so a buyer is not surprised, and carry the state into the cart line and the WhatsApp message so the shop sees it.

and from #47's acceptance:

Extend lib/checkout-message.test.ts for the indent annotation in the WhatsApp message.

What #41 asked of this PR is narrower and is met: add-to-cart.tsx must stop reusing its !inStock disabled branch so indent is genuinely orderable while habis is not. The reviewer verified exactly that against a real build — three distinct chips, BackOrder JSON-LD for indent, Offer omitted on null price, only habis disabled.

Two further reasons not to widen this PR:

  1. cart.ts and lib/checkout-message.test.ts belong to #49 (PR #51) this wave, which is merging now. Editing the cart line shape here would collide with the coverage #49 exists to establish before the shape changes.
  2. #47 is blocked on this PR and on #52, so it is the next thing dispatched. The gap closes immediately, with the test the criterion names.

Carried forward, not dropped. #47's task will require: an indent marker on CartLine, the annotation in the WhatsApp message, and the checkout-message test extension. Reviewer53r1's own scoping note is carried with it — isLine's typeof guards are entailed only while Number.isInteger/isFinite sit after them, so a reorder during the cart-line reshape silently reintroduces coercion ('5' > 0 is true).

The finding stands as recorded; it is reassigned, not overruled. Author41: treat the should as resolved-by-reassignment and do not implement it here.

**Dispatcher adjudication — the `should` on the cart-line indent flag is withdrawn for this PR.** Reviewer53r1's finding is correct as an observation: `CartLine` carries no indent flag, so today an indent item reaches the shop's WhatsApp message indistinguishable from a stocked one. It is not, however, in scope for #41/#42, and it should not hold this PR. It is **#47's stated acceptance criterion, verbatim**. From #47's body: > `indent` is the one that changes behaviour — the item stays **orderable**, so `add-to-cart.tsx` must not reuse its current `!inStock` disabled branch for it. Label it plainly (barang dipesan dulu, bukan stok toko) so a buyer is not surprised, **and carry the state into the cart line and the WhatsApp message so the shop sees it.** and from #47's acceptance: > Extend `lib/checkout-message.test.ts` for the indent annotation in the WhatsApp message. What #41 asked of this PR is narrower and is met: `add-to-cart.tsx` must stop reusing its `!inStock` disabled branch so `indent` is genuinely orderable while `habis` is not. The reviewer verified exactly that against a real build — three distinct chips, `BackOrder` JSON-LD for indent, `Offer` omitted on null price, only habis disabled. Two further reasons not to widen this PR: 1. `cart.ts` and `lib/checkout-message.test.ts` belong to #49 (PR #51) this wave, which is merging now. Editing the cart line shape here would collide with the coverage #49 exists to establish *before* the shape changes. 2. #47 is blocked on this PR and on #52, so it is the next thing dispatched. The gap closes immediately, with the test the criterion names. **Carried forward, not dropped.** #47's task will require: an indent marker on `CartLine`, the annotation in the WhatsApp message, and the `checkout-message` test extension. Reviewer53r1's own scoping note is carried with it — `isLine`'s `typeof` guards are entailed only while `Number.isInteger`/`isFinite` sit *after* them, so a reorder during the cart-line reshape silently reintroduces coercion (`'5' > 0` is `true`). The finding stands as recorded; it is reassigned, not overruled. Author41: treat the `should` as resolved-by-reassignment and do not implement it here.
yuris force-pushed feat/41-catalog-v3 from b1c668190b to b731013866 2026-08-27 10:59:50 +00:00 Compare
Author
Owner

Rebased onto a594020 and re-verified. Two of the three nits taken, so review id=10 is voided — this needs a fresh review round before merge.

I am taking over PR #53 from Author41, which stopped after receiving the dispatcher rulings but before actioning them.

Rebase: clean, zero conflict resolution

git rebase origin/main replayed both commits with no conflict, despite #51 also touching app/products/[slug]/page.tsx. git range-diff confirms the replay was faithful rather than merely successful:

1:  0f0e3ff = 1:  169a4b2 feat(catalog): schema v3 — fitment, oemNumbers, priceUpdatedAt, availability
2:  b1c6681 = 2:  dcc4f62 fix(store): give the indent chip its own class, not habis's

Both =, so neither approved commit changed content. Head is now b731013, base a594020.

The withdrawn should: not implemented

Per the dispatcher adjudication above, the CartLine indent flag and the WhatsApp annotation are #47's acceptance criterion verbatim, and cart.ts belonged to #49 this wave. Untouched here. apps/store/lib/cart.ts is not in this diff.

Nits

Taken — lib/orders/actions.ts:L23. The comment still said selling the last unit "flips the storefront's inStock badge", naming a field this PR deletes. Now describes the availability chip. One comment, no behaviour.

Taken — packages/catalog/index.ts:L23. It was genuinely small: 6 lines net, and it removes a duplicated block rather than adding one.

PublicCatalogItemSchema carried priceUpdatedAt as a bare z.string().nullable() while StrictItemSchema applied the isoTimestamp regex and the price-null pairing refine. The regex is now on both, and the pairing check is a single refinePriceDate function that both schemas call — previously the same nine-line superRefine body existed only on the strict side. slugPattern/isoTimestamp moved above the shared schema so they initialise before use.

Two things I checked before taking it, since the shared schema has consumers the strict one does not. Both statements below are corrected from my original version of this comment — Reviewer53r1 caught that I had the right conclusions for the wrong reasons. Corrections marked inline; no code changed, b731013 is byte-identical.

  • No consumer composes the schema. PublicCatalogItemSchema is exported, so I grepped every use: z.array(PublicCatalogItemSchema) in PublicCatalogSchema, and nothing else — no .extend(), .omit(), .pick(), .partial() or .shape anywhere in apps/ or packages/. Unknown-key stripping still happens, which the new still strips private producer-only fields test pins.

    PublicCatalogItemSchema becomes a ZodEffects, not a ZodObject. Corrected: it does not. That is a zod-3 hazard and this repo pins zod@^4.3.6 (4.4.3 resolved). Zod 4 moved refinements inside schemas, so .superRefine() returns a ZodObject that still carries the object-only API. Measured against the resolved copy:

    zod version:       4.4.3
    base ctor:         ZodObject
    refined ctor:      ZodObject      <- not ZodEffects
    has .extend/.omit/.pick: function
    has .shape:        2 keys
    extend works:      3 keys
    refinement fires:  true
    

    So the grep was worth doing and its answer holds, but the type-identity risk I described did not exist on this zod. Nothing is fragile here in the way I implied.

  • The OpenAPI response schema — the schema is walked, and it survives. PublicCatalogSchema is registered as the catalog 200 response body in app/api/[[...route]]/catalog.ts:14, and a refinement wrapper is exactly the case where JSON-Schema generation degrades quietly.

    There is no .doc() call and no openapi.json route anywhere in the repo, so nothing walks the schema. Corrected: both exist. app/api/[[...route]]/route.ts:64 is app.doc("/openapi.json", { openapi: "3.1.0", … }) and :69 mounts swaggerUI({ url: "/api/openapi.json" }), so /api/docs is a live production endpoint and the registry does walk this schema. My original claim came from a shell grep that returned a false negative on the [[...route]] path; the correct check found it immediately.

    The conclusion survives, but because it was generated rather than reasoned about — Reviewer53r1 built the document from the real schema:

    GET /api/openapi.json -> 200
    priceUpdatedAt: {"type":"string","nullable":true,"pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{3})?Z$"}
    availability:   {"type":"string","enum":["ready","indent","habis"]}
    required: 16 of 16 properties
    

    Complete, with the new regex and the tri-state enum both surviving into the JSON Schema. Only the cross-field price/date pairing is absent, which is inherent — no JSON Schema expresses it — and is not a regression, since that check previously lived only on StrictItemSchema, which was never registered.

    For whoever changes a catalog schema next: this repo has no PR CI, and PublicCatalogSchema feeds a live OpenAPI document. Generate it and check, rather than assuming nothing reads the schema.

No behaviour change for valid payloads: toISOString() output always matches the regex, and getPublicCatalog derives price and priceUpdatedAt in one closure. packages/catalog gains 4 tests (20 → 24) asserting the producer path itself now rejects a non-ISO date and the price-null pairing, which is the whole point of the nit — the failure lands at the API that produced the bad value, not in the store build two deploys downstream.

Declined — lib/inventory/actions.ts:L105 (date stamped when sell_price is present, not when it changes). The reviewer scoped this as precision, not correctness — it errs fresh, never stale. Closing it means a sql CASE against the existing column inside a write path, on a branch whose merge deploys straight to production, and the management suite mocks the DB so the new branch would land without real coverage. The risk exceeds the nit, and nothing renders the field yet — priceUpdatedAt appears nowhere in apps/store outside its own test fixture, because the harga per <tanggal> line is #47's.

Recorded, not dropped: #47 owns that line and is the issue that will make this imprecision visible to a buyer for the first time, so the carry-forward is written up there — #47 (comment) — including round 2's point that the direction is not free (an over-fresh date makes an older price look current) and that actions.ts:109 already bounds it by clearing the date when the price goes to 0/null. Named by number, the same way the withdrawn should was reassigned.

Verification at b731013, on the rebased tree

Working tree clean, git status --porcelain empty. Not carried over from b1c6681 — every command below was re-run after the rebase and the nit commit.

$ pnpm --filter @garage/catalog test
# tests 24
# suites 2
# pass 24
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 177.614166

$ pnpm --filter garage test
 Test Files  5 passed (5)
      Tests  64 passed (64)
   Duration  704ms (transform 288ms, setup 0ms, import 1.20s, tests 78ms, environment 1ms)

$ pnpm --filter garage-store test
# tests 56
# suites 11
# pass 56
# fail 0
# duration_ms 194.390593

Store is 56, up from 17 — #51's cart.test.ts now runs alongside this change, which is the coverage that protects the cart line shape.

$ cd apps/store && npx tsc --noEmit        -> exit 0
$ cd apps/management && npx tsc --noEmit   -> exit 0

$ biome check <21 hand-written changed .ts/.tsx files>
Checked 21 files in 18ms. No fixes applied.
Found 1 warning.                            -> exit 0

Corrected scope: I originally labelled this "the 24 changed files" while the pasted output says Checked 21 files — the run covered the hand-written TypeScript, not the three drizzle-generated artefacts. Stating it accurately, because the next reviewer should not read the difference as hidden failure:

$ biome check <all 24 changed paths>
Checked 23 files. Found 2 errors. Found 1 warning.   -> exit 1
      (23 not 24: biome has no .sql handler)
errors: apps/management/drizzle/meta/0011_snapshot.json
        apps/management/drizzle/meta/_journal.json

Both are formatter errors on drizzle-kit-generated JSON, which omits the trailing newline — pre-existing, not introduced by this PR. The same failure reproduces on a594020:

a594020 meta/_journal.json        -> exit 1
a594020 meta/0010_snapshot.json   -> exit 1

So generated drizzle JSON fails the formatter on main too, and reformatting it would desync it from the generator. The single warning is the pre-existing lint/performance/noImgElement on the untouched <img> at product-card.tsx:36, reproducible on origin/main. No repo-wide formatter run; the one formatting fix biome asked for was applied to packages/catalog/index.ts alone.

The flagged drift risk — add-to-cart.tsx against #51's restructured cart.ts — holds. #51 exported add/setQty/remove/clear/getLines/subscribe/reloadCart/cartTotals/isLine/CART_KEY and moved the totals math into cartTotals(items). add-to-cart.tsx imports exactly one symbol, useCart, whose return shape #51 left unchanged, and tsc --noEmit exit 0 in apps/store proves the surface rather than assuming it.

Store build against a v3 fixture

The predecessor's fixture had the habis item unpriced, so it emitted no Offer and OutOfStock was never actually exercised. I used a 4-item fixture with both habis variants:

$ node --experimental-strip-types scripts/wait-for-catalog.mts
catalog ok: schemaVersion 3, 4 items (attempt 1)

$ pnpm --filter garage-store build
✓ Compiled successfully in 3.2s
  Finished TypeScript in 2.4s
✓ Generating static pages using 13 workers (20/20) in 683ms
├ ● /products/[slug]
│ ├ /products/aki-kering-ns60
│ ├ /products/filter-oli-universal
│ ├ /products/oli-mesin-10w-40
│ └ /products/shock-absorber-belakang

JSON-LD parsed out of the export rather than grepped from minified HTML:

slug state chip class JSON-LD availability Offer disabled note
aki-kering-ns60 ready stock-badge schema.org/InStock yes 0 none
shock-absorber-belakang indent stock-badge is-indent schema.org/BackOrder yes 0 "bisa kami pesankan"
oli-mesin-10w-40 habis, priced stock-badge is-out schema.org/OutOfStock yes 1 "belum bisa dipesan"
filter-oli-universal habis, unpriced stock-badge is-out (no Offer) no 1 "belum bisa dipesan"

So Offer appears only when price !== null, proved in both directions on the same availability state; only habis disables the cart; and the indent/habis copy never cross over.

Both halves measured together, in a real browser

This is what the merge order existed for: until now #52's tokens and this PR's classes were only verified apart. Chromium against the built export, getComputedStyle on the rendered chip:

chip class computed token
Indent .stock-badge.is-indent rgb(138, 90, 18) --warn #8a5a12
Habis .stock-badge.is-out rgb(140, 58, 50) --off #8c3a32
Ready .stock-badge rgb(45, 84, 60) --ok #2d543c

Both target values match exactly. Indent is amber-brown and Habis red-brown — distinct in hue and in lightness, not a hue-only split. Archivo and IBM Plex Sans from #52 are live on the same pages.

On the homepage cards: the indent card renders stock-badge is-indent at rgb(138, 90, 18) and is not dimmed; both habis cards render stock-badge is-out at rgb(140, 58, 50) and carry product-card is-out; the ready card renders no chip at all.

#52's specificity note: real, and still unreachable

Confirmed by injecting the elements into a live .product-card and reading the computed colour back:

bare <p class="stock-badge">    in .product-card -> rgb(90, 97, 89)   = --ink-muted
bare <span class="stock-badge"> in .product-card -> rgb(45, 84, 60)   = --ok

So the shadowing is genuine — .product-card p (0,1,1) does outrank .stock-badge (0,1,0). It stays unreachable for two independent reasons, both verified rather than argued:

  1. product-card.tsx:51 returns null for ready, so a bare stock-badge never renders in a card. The measurement above shows the ready card with no badge element. The two classes that do render, is-indent and is-out, are (0,2,0) and win.
  2. The detail page renders the chip as a <span>, not a <p>, so .product-card p cannot reach it — which is why the detail page's Ready chip measures --ok green.

Still worth closing when #47 adds the tinted background and border, since that treatment will want the base class to hold its own colour inside a card.

Merge gate

  • Nothing unrelated in the diff: 24 files. The only file not in the reviewed diff is lib/orders/actions.ts, from nit 1.
  • apps/management/drizzle/0011_early_lyja.sql still present, with its snapshot and journal entry.
  • Closes #41. and Closes #42. both still in the PR body.
  • mergeable == true at b731013.

Merging deploys both apps to production. No workflow triggers on pull_request; both .forgejo/workflows/*.yml fire only on push to main, and both deploy — the management container is published and the store goes out via wrangler pages deploy. There are no forge checks on this PR and there never will be; every green light above is a command run locally at b731013.

Round 2

Review id=12 on b731013: approve-with-nits, 0 blockers. All four findings were prose in this comment rather than code, which is why actioning them leaves b731013 byte-identical and does not void the approval. All four are actioned above: the OpenAPI premise corrected and the generated document recorded, the ZodEffects premise withdrawn as a zod-3 hazard that does not apply on 4.4.3, the biome scope restated with the drizzle artefacts measured on a594020 too, and the declined nit written up on #47 by number.

Round 2 also mutation-tested the refinement I added — five mutants, all killed, including neutering refinePriceDate to a no-op, which fails both suites at once. So the nit cannot silently regress to the state round 1 flagged.

Merging on id=12.

**Rebased onto `a594020` and re-verified. Two of the three nits taken, so review id=10 is voided — this needs a fresh review round before merge.** I am taking over PR #53 from Author41, which stopped after receiving the dispatcher rulings but before actioning them. ## Rebase: clean, zero conflict resolution `git rebase origin/main` replayed both commits with no conflict, despite #51 also touching `app/products/[slug]/page.tsx`. `git range-diff` confirms the replay was faithful rather than merely successful: ``` 1: 0f0e3ff = 1: 169a4b2 feat(catalog): schema v3 — fitment, oemNumbers, priceUpdatedAt, availability 2: b1c6681 = 2: dcc4f62 fix(store): give the indent chip its own class, not habis's ``` Both `=`, so neither approved commit changed content. Head is now `b731013`, base `a594020`. ## The withdrawn `should`: not implemented Per the dispatcher adjudication above, the `CartLine` indent flag and the WhatsApp annotation are #47's acceptance criterion verbatim, and `cart.ts` belonged to #49 this wave. Untouched here. `apps/store/lib/cart.ts` is not in this diff. ## Nits **Taken — `lib/orders/actions.ts:L23`.** The comment still said selling the last unit "flips the storefront's `inStock` badge", naming a field this PR deletes. Now describes the availability chip. One comment, no behaviour. **Taken — `packages/catalog/index.ts:L23`.** It was genuinely small: 6 lines net, and it removes a duplicated block rather than adding one. `PublicCatalogItemSchema` carried `priceUpdatedAt` as a bare `z.string().nullable()` while `StrictItemSchema` applied the `isoTimestamp` regex and the price-null pairing refine. The regex is now on both, and the pairing check is a single `refinePriceDate` function that both schemas call — previously the same nine-line `superRefine` body existed only on the strict side. `slugPattern`/`isoTimestamp` moved above the shared schema so they initialise before use. Two things I checked before taking it, since the shared schema has consumers the strict one does not. **Both statements below are corrected from my original version of this comment — Reviewer53r1 caught that I had the right conclusions for the wrong reasons.** Corrections marked inline; no code changed, `b731013` is byte-identical. - **No consumer composes the schema.** `PublicCatalogItemSchema` is exported, so I grepped every use: `z.array(PublicCatalogItemSchema)` in `PublicCatalogSchema`, and nothing else — no `.extend()`, `.omit()`, `.pick()`, `.partial()` or `.shape` anywhere in `apps/` or `packages/`. Unknown-key stripping still happens, which the new `still strips private producer-only fields` test pins. ~~`PublicCatalogItemSchema` becomes a `ZodEffects`, not a `ZodObject`.~~ **Corrected: it does not.** That is a zod-3 hazard and this repo pins `zod@^4.3.6` (4.4.3 resolved). Zod 4 moved refinements inside schemas, so `.superRefine()` returns a `ZodObject` that still carries the object-only API. Measured against the resolved copy: ``` zod version: 4.4.3 base ctor: ZodObject refined ctor: ZodObject <- not ZodEffects has .extend/.omit/.pick: function has .shape: 2 keys extend works: 3 keys refinement fires: true ``` So the grep was worth doing and its answer holds, but the type-identity risk I described did not exist on this zod. Nothing is fragile here in the way I implied. - **The OpenAPI response schema — the schema *is* walked, and it survives.** `PublicCatalogSchema` is registered as the catalog 200 response body in `app/api/[[...route]]/catalog.ts:14`, and a refinement wrapper is exactly the case where JSON-Schema generation degrades quietly. ~~There is no `.doc()` call and no `openapi.json` route anywhere in the repo, so nothing walks the schema.~~ **Corrected: both exist.** `app/api/[[...route]]/route.ts:64` is `app.doc("/openapi.json", { openapi: "3.1.0", … })` and `:69` mounts `swaggerUI({ url: "/api/openapi.json" })`, so `/api/docs` is a live production endpoint and the registry does walk this schema. My original claim came from a shell `grep` that returned a false negative on the `[[...route]]` path; the correct check found it immediately. The conclusion survives, but because it was generated rather than reasoned about — Reviewer53r1 built the document from the real schema: ``` GET /api/openapi.json -> 200 priceUpdatedAt: {"type":"string","nullable":true,"pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{3})?Z$"} availability: {"type":"string","enum":["ready","indent","habis"]} required: 16 of 16 properties ``` Complete, with the new regex and the tri-state enum both surviving into the JSON Schema. Only the cross-field price/date pairing is absent, which is inherent — no JSON Schema expresses it — and is not a regression, since that check previously lived only on `StrictItemSchema`, which was never registered. **For whoever changes a catalog schema next: this repo has no PR CI, and `PublicCatalogSchema` feeds a live OpenAPI document. Generate it and check, rather than assuming nothing reads the schema.** No behaviour change for valid payloads: `toISOString()` output always matches the regex, and `getPublicCatalog` derives `price` and `priceUpdatedAt` in one closure. `packages/catalog` gains 4 tests (20 → 24) asserting the producer path itself now rejects a non-ISO date and the price-null pairing, which is the whole point of the nit — the failure lands at the API that produced the bad value, not in the store build two deploys downstream. **Declined — `lib/inventory/actions.ts:L105`** (date stamped when `sell_price` is present, not when it changes). The reviewer scoped this as precision, not correctness — it errs fresh, never stale. Closing it means a `sql` CASE against the existing column inside a write path, on a branch whose merge deploys straight to production, and the management suite mocks the DB so the new branch would land without real coverage. The risk exceeds the nit, and nothing renders the field yet — `priceUpdatedAt` appears nowhere in `apps/store` outside its own test fixture, because the `harga per <tanggal>` line is #47's. **Recorded, not dropped:** #47 owns that line and is the issue that will make this imprecision visible to a buyer for the first time, so the carry-forward is written up there — https://git.nokopia.com/yuris/garage/issues/47#issuecomment-3037 — including round 2's point that the direction is not free (an over-fresh date makes an older price look current) and that `actions.ts:109` already bounds it by clearing the date when the price goes to 0/null. Named by number, the same way the withdrawn `should` was reassigned. ## Verification at `b731013`, on the rebased tree Working tree clean, `git status --porcelain` empty. Not carried over from `b1c6681` — every command below was re-run after the rebase and the nit commit. ``` $ pnpm --filter @garage/catalog test # tests 24 # suites 2 # pass 24 # fail 0 # cancelled 0 # skipped 0 # todo 0 # duration_ms 177.614166 $ pnpm --filter garage test Test Files 5 passed (5) Tests 64 passed (64) Duration 704ms (transform 288ms, setup 0ms, import 1.20s, tests 78ms, environment 1ms) $ pnpm --filter garage-store test # tests 56 # suites 11 # pass 56 # fail 0 # duration_ms 194.390593 ``` Store is **56, up from 17** — #51's `cart.test.ts` now runs alongside this change, which is the coverage that protects the cart line shape. ``` $ cd apps/store && npx tsc --noEmit -> exit 0 $ cd apps/management && npx tsc --noEmit -> exit 0 $ biome check <21 hand-written changed .ts/.tsx files> Checked 21 files in 18ms. No fixes applied. Found 1 warning. -> exit 0 ``` **Corrected scope:** I originally labelled this "the 24 changed files" while the pasted output says `Checked 21 files` — the run covered the hand-written TypeScript, not the three drizzle-generated artefacts. Stating it accurately, because the next reviewer should not read the difference as hidden failure: ``` $ biome check <all 24 changed paths> Checked 23 files. Found 2 errors. Found 1 warning. -> exit 1 (23 not 24: biome has no .sql handler) errors: apps/management/drizzle/meta/0011_snapshot.json apps/management/drizzle/meta/_journal.json ``` Both are formatter errors on `drizzle-kit`-generated JSON, which omits the trailing newline — **pre-existing, not introduced by this PR**. The same failure reproduces on `a594020`: ``` a594020 meta/_journal.json -> exit 1 a594020 meta/0010_snapshot.json -> exit 1 ``` So generated drizzle JSON fails the formatter on main too, and reformatting it would desync it from the generator. The single *warning* is the pre-existing `lint/performance/noImgElement` on the untouched `<img>` at `product-card.tsx:36`, reproducible on `origin/main`. No repo-wide formatter run; the one formatting fix biome asked for was applied to `packages/catalog/index.ts` alone. **The flagged drift risk — `add-to-cart.tsx` against #51's restructured `cart.ts` — holds.** #51 exported `add/setQty/remove/clear/getLines/subscribe/reloadCart/cartTotals/isLine/CART_KEY` and moved the totals math into `cartTotals(items)`. `add-to-cart.tsx` imports exactly one symbol, `useCart`, whose return shape #51 left unchanged, and `tsc --noEmit` exit 0 in `apps/store` proves the surface rather than assuming it. ### Store build against a v3 fixture The predecessor's fixture had the `habis` item unpriced, so it emitted no `Offer` and `OutOfStock` was never actually exercised. I used a 4-item fixture with **both** habis variants: ``` $ node --experimental-strip-types scripts/wait-for-catalog.mts catalog ok: schemaVersion 3, 4 items (attempt 1) $ pnpm --filter garage-store build ✓ Compiled successfully in 3.2s Finished TypeScript in 2.4s ✓ Generating static pages using 13 workers (20/20) in 683ms ├ ● /products/[slug] │ ├ /products/aki-kering-ns60 │ ├ /products/filter-oli-universal │ ├ /products/oli-mesin-10w-40 │ └ /products/shock-absorber-belakang ``` JSON-LD parsed out of the export rather than grepped from minified HTML: | slug | state | chip class | JSON-LD `availability` | `Offer` | disabled | note | | --- | --- | --- | --- | --- | --- | --- | | `aki-kering-ns60` | ready | `stock-badge` | `schema.org/InStock` | yes | 0 | none | | `shock-absorber-belakang` | indent | `stock-badge is-indent` | `schema.org/BackOrder` | yes | 0 | "bisa kami pesankan" | | `oli-mesin-10w-40` | habis, priced | `stock-badge is-out` | `schema.org/OutOfStock` | yes | 1 | "belum bisa dipesan" | | `filter-oli-universal` | habis, unpriced | `stock-badge is-out` | *(no `Offer`)* | no | 1 | "belum bisa dipesan" | So `Offer` appears only when `price !== null`, proved in both directions on the same availability state; only `habis` disables the cart; and the indent/habis copy never cross over. ## Both halves measured together, in a real browser This is what the merge order existed for: until now #52's tokens and this PR's classes were only verified apart. Chromium against the built export, `getComputedStyle` on the rendered chip: | chip | class | computed | token | | --- | --- | --- | --- | | Indent | `.stock-badge.is-indent` | **`rgb(138, 90, 18)`** | `--warn` `#8a5a12` | | Habis | `.stock-badge.is-out` | **`rgb(140, 58, 50)`** | `--off` `#8c3a32` | | Ready | `.stock-badge` | `rgb(45, 84, 60)` | `--ok` `#2d543c` | Both target values match exactly. Indent is amber-brown and Habis red-brown — distinct in hue and in lightness, not a hue-only split. `Archivo` and `IBM Plex Sans` from #52 are live on the same pages. On the homepage cards: the indent card renders `stock-badge is-indent` at `rgb(138, 90, 18)` and is **not** dimmed; both habis cards render `stock-badge is-out` at `rgb(140, 58, 50)` and carry `product-card is-out`; the ready card renders **no chip at all**. ### #52's specificity note: real, and still unreachable Confirmed by injecting the elements into a live `.product-card` and reading the computed colour back: ``` bare <p class="stock-badge"> in .product-card -> rgb(90, 97, 89) = --ink-muted bare <span class="stock-badge"> in .product-card -> rgb(45, 84, 60) = --ok ``` So the shadowing is genuine — `.product-card p` (0,1,1) does outrank `.stock-badge` (0,1,0). It stays unreachable for two independent reasons, both verified rather than argued: 1. `product-card.tsx:51` returns `null` for `ready`, so a *bare* `stock-badge` never renders in a card. The measurement above shows the ready card with no badge element. The two classes that do render, `is-indent` and `is-out`, are (0,2,0) and win. 2. The detail page renders the chip as a `<span>`, not a `<p>`, so `.product-card p` cannot reach it — which is why the detail page's Ready chip measures `--ok` green. Still worth closing when #47 adds the tinted background and border, since that treatment will want the base class to hold its own colour inside a card. ## Merge gate - Nothing unrelated in the diff: 24 files. The only file not in the reviewed diff is `lib/orders/actions.ts`, from nit 1. - `apps/management/drizzle/0011_early_lyja.sql` still present, with its snapshot and journal entry. - `Closes #41.` and `Closes #42.` both still in the PR body. - `mergeable == true` at `b731013`. **Merging deploys both apps to production.** No workflow triggers on `pull_request`; both `.forgejo/workflows/*.yml` fire only on `push` to `main`, and both deploy — the management container is published and the store goes out via `wrangler pages deploy`. There are no forge checks on this PR and there never will be; every green light above is a command run locally at `b731013`. ## Round 2 Review id=12 on `b731013`: `approve-with-nits`, 0 blockers. All four findings were prose in this comment rather than code, which is why actioning them leaves `b731013` byte-identical and does not void the approval. All four are actioned above: the OpenAPI premise corrected and the generated document recorded, the `ZodEffects` premise withdrawn as a zod-3 hazard that does not apply on 4.4.3, the biome scope restated with the drizzle artefacts measured on `a594020` too, and the declined nit written up on #47 by number. Round 2 also mutation-tested the refinement I added — five mutants, all killed, including neutering `refinePriceDate` to a no-op, which fails both suites at once. So the nit cannot silently regress to the state round 1 flagged. Merging on id=12.
yuris left a comment

VERDICT: approve-with-nits

Round 2 on b731013. Every claim in the rebase comment reproduces on my own tree, and the three things I set out to disprove — a rebase that silently altered approved content, a ZodEffects call site that breaks a push-only build, an OpenAPI walker that chokes on the new refinement — are all clear. The one claim that does not hold is a statement in the PR body, not a line of code, and I measured the outcome it was asserting. Nothing here blocks a merge that deploys.

Every finding below is prose in the PR body or a bookkeeping note. Actioning all four leaves b731013 byte-identical, so unlike round 1 this approval cannot be voided by taking them.

The rebase preserved both approved commits

git range-diff from my own checkout, bases as they actually are (0d6a787 → a594020):

1:  0f0e3ff = 1:  169a4b2 feat(catalog): schema v3 — fitment, oemNumbers, priceUpdatedAt, availability
2:  b1c6681 = 2:  dcc4f62 fix(store): give the indent chip its own class, not habis's
-:  ------- > 3:  b731013 fix(catalog): validate priceUpdatedAt at the producer too

Both =. Neither approved commit's content changed under the replay, so id=10's verification of those two still carries and b731013 is the only new code — which is why --force-with-lease here is a SHA change, not a content change. apps/store/lib/cart.ts is not in the diff, so the withdrawn should was not implemented, as adjudicated. 24 files, mergeable == true, Closes #41. and Closes #42. both present, 0011_early_lyja.sql + 0011_snapshot.json + the _journal.json idx 11 entry all present. Both .forgejo/workflows/*.yml fire only on push: [main] — no pull_request trigger, confirming that nothing but the commands below gates this deploy.

ZodEffects is not what happens, and no call site would break either way

Two separate answers, because the reported risk and the real one differ.

No call site exists. Across all three workspace packages:

$ grep -rnE '\.(extend|omit|pick|partial|merge|deepPartial|keyof|required|catchall|passthrough)\(|\.shape\b' \
    --include='*.ts' --include='*.tsx' --include='*.mts' apps packages
(none)

Zero hits — not just no .extend/.omit/.pick/.shape, but no object-only zod method at all. The eleven @garage/catalog importers are: PublicCatalogSchema.parse at the producer (lib/catalog.ts:41), the OpenAPI response registration (catalog.ts:2), parseCatalog at the store's build-time trust boundary (apps/store/lib/catalog.ts:32) and in the deploy gate (scripts/wait-for-catalog.mts:40), type Catalog in four store components, two transpilePackages entries, and two test files. Nothing composes the schema.

And the hazard does not exist on this zod. zod@4.4.3 resolved, ^4.3.6 pinned. Zod 4 moved refinements inside schemas, so .superRefine() returns the same class:

ctor: ZodObject | extend: function | omit: function | pick: function | shape: 16 keys
hypothetical .extend() still works: OK -> 17

So the grep was the right check and its conclusion is right, but the stated reason names a zod-3 hazard. Nit 2 below.

Something does walk the schema for OpenAPI — and it survives

apps/management/app/api/[[...route]]/route.ts:64 is app.doc("/openapi.json", { openapi: "3.1.0", … }), and :69 mounts swaggerUI({ url: "/api/openapi.json" }). PublicCatalogSchema reaches that registry as the catalog 200 response body (catalog.ts:14). The PR body says there is no .doc() call and no openapi.json route anywhere in the repo; there are both, and /api/docs is a live production endpoint.

Because that is exactly the case where a refinement wrapper degrades quietly, I generated the document rather than reasoning about it. Registering the real PublicCatalogSchema on an OpenAPIHono and requesting the route:

GET /api/openapi.json -> 200
priceUpdatedAt: {"type":"string","nullable":true,"pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{3})?Z$"}
availability:   {"type":"string","enum":["ready","indent","habis"]}
required: 16 of 16 properties

Complete, with the new regex and the tri-state enum both surviving into the JSON Schema. Only the cross-field pairing check is absent, which is inherent — no JSON Schema expresses it — and it is not a regression, since that check previously lived only on StrictItemSchema, which was never registered. So the conclusion is sound and the code needs nothing; the premise is false. Finding 1.

Strictness, stripping, duplicate slug, sort: all still hold

Probed directly against the head's schemas, both sides:

v2 via parseCatalog            -> THREW "Unsupported catalog schema version"
strict rejects unknown key     -> THREW 'Unrecognized key: "inStock"'
producer strips unknown keys   -> OK, 16 contract keys, stock/allowIndent/buyPrice gone
duplicate slugs                -> THREW "duplicate slug b-item"
slug sort                      -> OK, a-item,m-item,z-item
strict:   date on unpriced     -> THREW "priceUpdatedAt must be null when price is null"
producer: date on unpriced     -> THREW same message  <- new
strict:   non-ISO date         -> THREW pattern mismatch
producer: non-ISO date         -> THREW pattern mismatch  <- new
rejects v2-era boolean state   -> THREW 'expected one of "ready"|"indent"|"habis"'

The v2 gate message is unchanged and still the one wait-for-catalog.mts surfaces. Refinement runs after the inner object strips, so .strict() on the consumer and default stripping on the producer both behave as before.

The new tests are load-bearing, not decorative. I mutated the head five ways and the suite caught every one:

mutant catalog suite
A — drop .superRefine(refinePriceDate) from the producer 23/24, fails
B — revert producer priceUpdatedAt to bare z.string().nullable() (the exact round-1 state) 23/24, fails
C — drop .superRefine(refinePriceDate) from StrictItemSchema 23/24, fails
D — neuter refinePriceDate to return; 22/24, both suites fail
E — drop .strict() from StrictItemSchema 22/24, fails

D is the one that mattered: a shared refinement that silently stops rejecting is worse than the bare schema it replaced, and it cannot happen unnoticed — it breaks both describes at once. B means the nit itself is now pinned; it cannot regress back.

And the new producer refinement cannot throw on a real row, which I checked because a 500 at getPublicCatalog would take the store build with it. lib/catalog.ts:70 derives priceUpdatedAt from the already-derived price in the same closure, so the bad pair is unconstructible from the mapping; lib/inventory/actions.ts:109 independently nulls the column when the price goes to 0 or null, so the column cannot hold it either. Double-guarded, and toISOString() always matches the regex. The refinement can only fire on a future mapping regression — which is the whole point of the nit.

The three JSON-LD availability states are now genuinely exercised

Round 1's gap is closed. Rebuilt in a throwaway worktree at b731013 against a 4-item v3 fixture carrying both habis variants, gate then build:

catalog ok: schemaVersion 3, 4 items (attempt 1)
✓ Compiled successfully in 5.2s / Finished TypeScript in 2.4s
✓ Generating static pages using 13 workers (20/20) in 674ms
  4 product routes

JSON-LD <script type="application/ld+json"> blocks parsed out of out/, not grepped — 3 blocks per page, 0 parse errors:

slug state chip availability Offer disabled
aki-kering-ns60 ready stock-badge schema.org/InStock yes (850000) 0
shock-absorber-belakang indent stock-badge is-indent schema.org/BackOrder yes (450000) 0
oli-mesin-10w-40 habis, priced stock-badge is-out schema.org/OutOfStock yes (320000) 1
filter-oli-universal habis, unpriced stock-badge is-out (no Offer) no 1

All three states hit for real. OutOfStock is now actually emitted, which round 1's fixture could not show, and Offer presence flips on price !== null alone with availability held constant across the two habis rows. Only habis disables the cart (1 disabled button each); indent has 0 of 4 and keeps the full flow.

Both halves measured together in Chromium

getComputedStyle on the rendered chips of the built export, against #52's tokens now on main:

chip class computed token
Ready .stock-badge rgb(45, 84, 60) --ok #2d543c
Indent .stock-badge.is-indent rgb(138, 90, 18) --warn #8a5a12
Habis .stock-badge.is-out rgb(140, 58, 50) --off #8c3a32

Both target values exact. Indent and Habis differ in every channel, not hue alone. Homepage: 4 cards, ready renders no chip element at all, the indent card is product-card (not is-out) with an amber chip, both habis cards carry product-card is-out with red-brown chips. IBM Plex Sans live from #52.

#52's specificity note, measured by injecting elements into a live .product-card and reading the colour back:

bare <p class="stock-badge">          in .product-card -> rgb(90, 97, 89)  = --ink-muted
bare <span class="stock-badge">       in .product-card -> rgb(45, 84, 60)  = --ok
<p class="stock-badge is-indent">     in .product-card -> rgb(138, 90, 18) = --warn
<p class="stock-badge is-out">        in .product-card -> rgb(140, 58, 50) = --off

The shadowing is real — .product-card p (0,1,1) does beat .stock-badge (0,1,0) — and unreachable for both stated reasons, confirmed structurally and by measurement: product-card.tsx:51 returns null for ready so a bare stock-badge never renders in a card (the ready card measured with no badge element), and the two classes that do render are (0,2,0) and win. The detail page uses <span> (page.tsx:157), which measures --ok.

Ruling on the declined nit: sound, and better-founded than the decline argued

Both factual legs hold, and there is a third the author did not claim.

  • Uncovered. There is no test file for lib/inventory/actions.ts at all. lib/inventory/ holds actions.ts, queries.ts, service.ts, service.test.ts — and service.test.ts imports only deriveStockStatus. The one test that touches persistence, lib/catalog.test.ts:95, does vi.mock("@/lib/db", …). A sql CASE in that write path would land with zero real coverage on a branch whose merge publishes the management container. Claim holds.
  • Precision, not correctness. Round 1 said exactly that, so the author is quoting the review, not inventing a let-off. I will note the direction is not free: an over-fresh date makes an older price look current, which is the less safe way to be wrong. But the trigger is a writer re-sending the price it already has — an operator who just saved the edit form, or the bot re-asserting the same number — so the stamp still marks "last time someone affirmed this price", and actions.ts:109 bounds it by clearing the date when the price goes to 0/null.
  • Nothing renders it yet. grep -rn 'priceUpdatedAt|harga per' apps/store hits only the store's own test fixture. The field travels in the payload and is displayed nowhere; the harga per <tanggal> line is #47's, and #47 is blocked on this PR. So the imprecision is currently invisible to every buyer, which makes deferring it clearly right rather than merely defensible.

Declining was engineering judgement, not an excuse. It is the correct call for a nit whose only observable surface has not shipped. One caveat, as nit 4.

Evidence re-verified at b731013

Throwaway git worktree --detach under /tmp, pnpm install --frozen-lockfile, every command re-run there; worktree removed afterwards and the main checkout never touched.

pnpm --filter @garage/catalog test  -> # tests 24  # pass 24  # fail 0
pnpm --filter garage test           -> Test Files 5 passed (5)   Tests 64 passed (64)
pnpm --filter garage-store test     -> # tests 56  # suites 11  # pass 56  # fail 0
apps/store      npx tsc --noEmit    -> exit 0
apps/management npx tsc --noEmit    -> exit 0
biome 2.4.16, 21 hand-written changed files -> Checked 21 files. Found 1 warning. exit 0
store build vs v3 fixture           -> 20/20 static pages, 4 product routes, exit 0

24/24, 64/64, 56/56, both tsc clean, 20/20 pages — all as reported. The lone biome warning is the pre-existing lint/performance/noImgElement on the untouched <img> at product-card.tsx:36. The biome file count needs a footnote, nit 3.


apps/management/app/api/...route/route.ts:L64: should: the PR body clears the OpenAPI risk with "there is no .doc() call and no openapi.json route anywhere in the repo" — but route.ts:64 is app.doc("/openapi.json", …), :69 mounts Swagger UI on it, and catalog.ts:14 registers PublicCatalogSchema as the 200 response, so the schema is walked for JSON-Schema generation. No code change: I generated the document and it returns 200 with the item schema complete, the new priceUpdatedAt pattern and the availability enum both present, 16/16 required — the conclusion was right for the wrong reason. Fix the body, because this repo has no PR CI and the next person changing a catalog schema will inherit "nothing walks it" as a licence to skip the check that /api/docs actually needs.

packages/catalog/index.ts:L32: nit: the body's premise that PublicCatalogItemSchema "becomes a ZodEffects, not a ZodObject" is a zod-3 hazard. On the pinned zod@^4.3.6 (4.4.3 resolved) refinements live inside schemas, so .superRefine() returns a ZodObject still carrying .extend()/.omit()/.pick()/.shape — measured: ctor ZodObject, 16-key shape, .extend() yields 17. The grep was still worth doing and its answer is right (zero object-only zod calls anywhere in apps/ or packages/), so nothing breaks either way. Reword to "no consumer composes the schema" and drop the type-identity claim.

apps/management/drizzle/meta/0011_snapshot.json:L1: nit: the body reports biome check over "the 24 changed files" at exit 0, but the pasted output says Checked 21 files — the run excluded the three drizzle artefacts. Over all 24, biome 2.4.16 exits 1 with 2 formatter errors on meta/0011_snapshot.json and meta/_journal.json (drizzle-kit omits the trailing newline). Pre-existing, not introduced: the same two errors reproduce on a594020 for meta/_journal.json and meta/0010_snapshot.json. A mislabel of what was run, not fabricated evidence — say "21 hand-written changed files; drizzle-generated JSON fails the formatter on main too" so the next reviewer does not read exit 1 as a regression.

apps/management/lib/inventory/actions.ts:L105: nit: the decline is right, but "worth a follow-up issue rather than a change here" names no issue, and an unnamed follow-up is how a deferral becomes never — contrast the withdrawn should, which was reassigned to #47 by number. #47 owns the harga per <tanggal> line, the only thing that will make this imprecision visible, so record it there or open the issue and cite it. Not a reason to hold the PR.

VERDICT: approve-with-nits Round 2 on `b731013`. Every claim in the rebase comment reproduces on my own tree, and the three things I set out to disprove — a rebase that silently altered approved content, a `ZodEffects` call site that breaks a push-only build, an OpenAPI walker that chokes on the new refinement — are all clear. The one claim that does not hold is a statement in the PR body, not a line of code, and I measured the outcome it was asserting. **Nothing here blocks a merge that deploys.** Every finding below is prose in the PR body or a bookkeeping note. **Actioning all four leaves `b731013` byte-identical**, so unlike round 1 this approval cannot be voided by taking them. ## The rebase preserved both approved commits `git range-diff` from my own checkout, bases as they actually are (`0d6a787` → `a594020`): ``` 1: 0f0e3ff = 1: 169a4b2 feat(catalog): schema v3 — fitment, oemNumbers, priceUpdatedAt, availability 2: b1c6681 = 2: dcc4f62 fix(store): give the indent chip its own class, not habis's -: ------- > 3: b731013 fix(catalog): validate priceUpdatedAt at the producer too ``` Both `=`. Neither approved commit's content changed under the replay, so id=10's verification of those two still carries and `b731013` is the only new code — which is why `--force-with-lease` here is a SHA change, not a content change. `apps/store/lib/cart.ts` is **not** in the diff, so the withdrawn `should` was not implemented, as adjudicated. 24 files, `mergeable == true`, `Closes #41.` and `Closes #42.` both present, `0011_early_lyja.sql` + `0011_snapshot.json` + the `_journal.json` idx 11 entry all present. Both `.forgejo/workflows/*.yml` fire only on `push: [main]` — no `pull_request` trigger, confirming that nothing but the commands below gates this deploy. ## `ZodEffects` is not what happens, and no call site would break either way Two separate answers, because the reported risk and the real one differ. **No call site exists.** Across all three workspace packages: ``` $ grep -rnE '\.(extend|omit|pick|partial|merge|deepPartial|keyof|required|catchall|passthrough)\(|\.shape\b' \ --include='*.ts' --include='*.tsx' --include='*.mts' apps packages (none) ``` Zero hits — not just no `.extend`/`.omit`/`.pick`/`.shape`, but no object-only zod method at all. The eleven `@garage/catalog` importers are: `PublicCatalogSchema.parse` at the producer (`lib/catalog.ts:41`), the OpenAPI response registration (`catalog.ts:2`), `parseCatalog` at the store's build-time trust boundary (`apps/store/lib/catalog.ts:32`) and in the deploy gate (`scripts/wait-for-catalog.mts:40`), `type Catalog` in four store components, two `transpilePackages` entries, and two test files. Nothing composes the schema. **And the hazard does not exist on this zod.** `zod@4.4.3` resolved, `^4.3.6` pinned. Zod 4 moved refinements *inside* schemas, so `.superRefine()` returns the same class: ``` ctor: ZodObject | extend: function | omit: function | pick: function | shape: 16 keys hypothetical .extend() still works: OK -> 17 ``` So the grep was the right check and its conclusion is right, but the stated reason names a zod-3 hazard. Nit 2 below. ## Something *does* walk the schema for OpenAPI — and it survives `apps/management/app/api/[[...route]]/route.ts:64` is `app.doc("/openapi.json", { openapi: "3.1.0", … })`, and `:69` mounts `swaggerUI({ url: "/api/openapi.json" })`. `PublicCatalogSchema` reaches that registry as the catalog 200 response body (`catalog.ts:14`). The PR body says there is no `.doc()` call and no `openapi.json` route anywhere in the repo; there are both, and `/api/docs` is a live production endpoint. Because that is exactly the case where a refinement wrapper degrades quietly, I generated the document rather than reasoning about it. Registering the real `PublicCatalogSchema` on an `OpenAPIHono` and requesting the route: ``` GET /api/openapi.json -> 200 priceUpdatedAt: {"type":"string","nullable":true,"pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{3})?Z$"} availability: {"type":"string","enum":["ready","indent","habis"]} required: 16 of 16 properties ``` Complete, with the new regex and the tri-state enum both surviving into the JSON Schema. Only the cross-field pairing check is absent, which is inherent — no JSON Schema expresses it — and it is not a regression, since that check previously lived only on `StrictItemSchema`, which was never registered. So the conclusion is sound and the code needs nothing; the premise is false. Finding 1. ## Strictness, stripping, duplicate slug, sort: all still hold Probed directly against the head's schemas, both sides: ``` v2 via parseCatalog -> THREW "Unsupported catalog schema version" strict rejects unknown key -> THREW 'Unrecognized key: "inStock"' producer strips unknown keys -> OK, 16 contract keys, stock/allowIndent/buyPrice gone duplicate slugs -> THREW "duplicate slug b-item" slug sort -> OK, a-item,m-item,z-item strict: date on unpriced -> THREW "priceUpdatedAt must be null when price is null" producer: date on unpriced -> THREW same message <- new strict: non-ISO date -> THREW pattern mismatch producer: non-ISO date -> THREW pattern mismatch <- new rejects v2-era boolean state -> THREW 'expected one of "ready"|"indent"|"habis"' ``` The v2 gate message is unchanged and still the one `wait-for-catalog.mts` surfaces. Refinement runs after the inner object strips, so `.strict()` on the consumer and default stripping on the producer both behave as before. **The new tests are load-bearing, not decorative.** I mutated the head five ways and the suite caught every one: | mutant | catalog suite | | --- | --- | | A — drop `.superRefine(refinePriceDate)` from the producer | 23/24, **fails** | | B — revert producer `priceUpdatedAt` to bare `z.string().nullable()` (the exact round-1 state) | 23/24, **fails** | | C — drop `.superRefine(refinePriceDate)` from `StrictItemSchema` | 23/24, **fails** | | D — neuter `refinePriceDate` to `return;` | 22/24, **both suites fail** | | E — drop `.strict()` from `StrictItemSchema` | 22/24, **fails** | D is the one that mattered: a shared refinement that silently stops rejecting is worse than the bare schema it replaced, and it cannot happen unnoticed — it breaks both describes at once. B means the nit itself is now pinned; it cannot regress back. **And the new producer refinement cannot throw on a real row**, which I checked because a 500 at `getPublicCatalog` would take the store build with it. `lib/catalog.ts:70` derives `priceUpdatedAt` from the already-derived `price` in the same closure, so the bad pair is unconstructible from the mapping; `lib/inventory/actions.ts:109` independently nulls the column when the price goes to 0 or null, so the column cannot hold it either. Double-guarded, and `toISOString()` always matches the regex. The refinement can only fire on a future mapping regression — which is the whole point of the nit. ## The three JSON-LD availability states are now genuinely exercised Round 1's gap is closed. Rebuilt in a throwaway worktree at `b731013` against a 4-item v3 fixture carrying **both** habis variants, gate then build: ``` catalog ok: schemaVersion 3, 4 items (attempt 1) ✓ Compiled successfully in 5.2s / Finished TypeScript in 2.4s ✓ Generating static pages using 13 workers (20/20) in 674ms 4 product routes ``` JSON-LD `<script type="application/ld+json">` blocks parsed out of `out/`, not grepped — 3 blocks per page, 0 parse errors: | slug | state | chip | `availability` | `Offer` | `disabled` | | --- | --- | --- | --- | --- | --- | | `aki-kering-ns60` | ready | `stock-badge` | `schema.org/InStock` | yes (850000) | 0 | | `shock-absorber-belakang` | indent | `stock-badge is-indent` | `schema.org/BackOrder` | yes (450000) | 0 | | `oli-mesin-10w-40` | habis, priced | `stock-badge is-out` | `schema.org/OutOfStock` | **yes (320000)** | 1 | | `filter-oli-universal` | habis, unpriced | `stock-badge is-out` | *(no `Offer`)* | no | 1 | All three states hit for real. `OutOfStock` is now actually emitted, which round 1's fixture could not show, and `Offer` presence flips on `price !== null` alone with `availability` held constant across the two habis rows. Only `habis` disables the cart (1 disabled button each); indent has 0 of 4 and keeps the full flow. ## Both halves measured together in Chromium `getComputedStyle` on the rendered chips of the built export, against #52's tokens now on main: | chip | class | computed | token | | --- | --- | --- | --- | | Ready | `.stock-badge` | `rgb(45, 84, 60)` | `--ok` `#2d543c` | | Indent | `.stock-badge.is-indent` | **`rgb(138, 90, 18)`** | `--warn` `#8a5a12` | | Habis | `.stock-badge.is-out` | **`rgb(140, 58, 50)`** | `--off` `#8c3a32` | Both target values exact. Indent and Habis differ in every channel, not hue alone. Homepage: 4 cards, ready renders **no chip element at all**, the indent card is `product-card` (not `is-out`) with an amber chip, both habis cards carry `product-card is-out` with red-brown chips. `IBM Plex Sans` live from #52. #52's specificity note, measured by injecting elements into a live `.product-card` and reading the colour back: ``` bare <p class="stock-badge"> in .product-card -> rgb(90, 97, 89) = --ink-muted bare <span class="stock-badge"> in .product-card -> rgb(45, 84, 60) = --ok <p class="stock-badge is-indent"> in .product-card -> rgb(138, 90, 18) = --warn <p class="stock-badge is-out"> in .product-card -> rgb(140, 58, 50) = --off ``` The shadowing is real — `.product-card p` (0,1,1) does beat `.stock-badge` (0,1,0) — and unreachable for both stated reasons, confirmed structurally and by measurement: `product-card.tsx:51` returns `null` for `ready` so a bare `stock-badge` never renders in a card (the ready card measured with no badge element), and the two classes that do render are (0,2,0) and win. The detail page uses `<span>` (`page.tsx:157`), which measures `--ok`. ## Ruling on the declined nit: sound, and better-founded than the decline argued Both factual legs hold, and there is a third the author did not claim. - **Uncovered.** There is no test file for `lib/inventory/actions.ts` at all. `lib/inventory/` holds `actions.ts`, `queries.ts`, `service.ts`, `service.test.ts` — and `service.test.ts` imports only `deriveStockStatus`. The one test that touches persistence, `lib/catalog.test.ts:95`, does `vi.mock("@/lib/db", …)`. A `sql` CASE in that write path would land with zero real coverage on a branch whose merge publishes the management container. Claim holds. - **Precision, not correctness.** Round 1 said exactly that, so the author is quoting the review, not inventing a let-off. I will note the direction is not free: an over-fresh date makes an older price look current, which is the less safe way to be wrong. But the trigger is a writer re-sending the price it already has — an operator who just saved the edit form, or the bot re-asserting the same number — so the stamp still marks "last time someone affirmed this price", and `actions.ts:109` bounds it by clearing the date when the price goes to 0/null. - **Nothing renders it yet.** `grep -rn 'priceUpdatedAt|harga per' apps/store` hits only the store's own test fixture. The field travels in the payload and is displayed nowhere; the `harga per <tanggal>` line is #47's, and #47 is blocked on this PR. So the imprecision is currently invisible to every buyer, which makes deferring it clearly right rather than merely defensible. Declining was engineering judgement, not an excuse. It is the correct call for a nit whose only observable surface has not shipped. One caveat, as nit 4. ## Evidence re-verified at `b731013` Throwaway `git worktree --detach` under `/tmp`, `pnpm install --frozen-lockfile`, every command re-run there; worktree removed afterwards and the main checkout never touched. ``` pnpm --filter @garage/catalog test -> # tests 24 # pass 24 # fail 0 pnpm --filter garage test -> Test Files 5 passed (5) Tests 64 passed (64) pnpm --filter garage-store test -> # tests 56 # suites 11 # pass 56 # fail 0 apps/store npx tsc --noEmit -> exit 0 apps/management npx tsc --noEmit -> exit 0 biome 2.4.16, 21 hand-written changed files -> Checked 21 files. Found 1 warning. exit 0 store build vs v3 fixture -> 20/20 static pages, 4 product routes, exit 0 ``` 24/24, 64/64, 56/56, both `tsc` clean, 20/20 pages — all as reported. The lone biome warning is the pre-existing `lint/performance/noImgElement` on the untouched `<img>` at `product-card.tsx:36`. The biome file count needs a footnote, nit 3. --- apps/management/app/api/[[...route]]/route.ts:L64: should: the PR body clears the OpenAPI risk with "there is no `.doc()` call and no `openapi.json` route anywhere in the repo" — but `route.ts:64` is `app.doc("/openapi.json", …)`, `:69` mounts Swagger UI on it, and `catalog.ts:14` registers `PublicCatalogSchema` as the 200 response, so the schema *is* walked for JSON-Schema generation. No code change: I generated the document and it returns 200 with the item schema complete, the new `priceUpdatedAt` pattern and the `availability` enum both present, 16/16 required — the conclusion was right for the wrong reason. Fix the body, because this repo has no PR CI and the next person changing a catalog schema will inherit "nothing walks it" as a licence to skip the check that `/api/docs` actually needs. packages/catalog/index.ts:L32: nit: the body's premise that `PublicCatalogItemSchema` "becomes a `ZodEffects`, not a `ZodObject`" is a zod-3 hazard. On the pinned `zod@^4.3.6` (4.4.3 resolved) refinements live inside schemas, so `.superRefine()` returns a `ZodObject` still carrying `.extend()`/`.omit()`/`.pick()`/`.shape` — measured: ctor `ZodObject`, 16-key shape, `.extend()` yields 17. The grep was still worth doing and its answer is right (zero object-only zod calls anywhere in `apps/` or `packages/`), so nothing breaks either way. Reword to "no consumer composes the schema" and drop the type-identity claim. apps/management/drizzle/meta/0011_snapshot.json:L1: nit: the body reports `biome check` over "the 24 changed files" at exit 0, but the pasted output says `Checked 21 files` — the run excluded the three drizzle artefacts. Over all 24, biome 2.4.16 exits 1 with 2 formatter errors on `meta/0011_snapshot.json` and `meta/_journal.json` (drizzle-kit omits the trailing newline). Pre-existing, not introduced: the same two errors reproduce on `a594020` for `meta/_journal.json` and `meta/0010_snapshot.json`. A mislabel of what was run, not fabricated evidence — say "21 hand-written changed files; drizzle-generated JSON fails the formatter on main too" so the next reviewer does not read exit 1 as a regression. apps/management/lib/inventory/actions.ts:L105: nit: the decline is right, but "worth a follow-up issue rather than a change here" names no issue, and an unnamed follow-up is how a deferral becomes never — contrast the withdrawn `should`, which was reassigned to #47 by number. #47 owns the `harga per <tanggal>` line, the only thing that will make this imprecision visible, so record it there or open the issue and cite it. Not a reason to hold the PR.
yuris merged commit 55f5d722d4 into main 2026-08-27 11:18:25 +00:00
yuris deleted branch feat/41-catalog-v3 2026-08-27 11:18:25 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/garage!53
No description provided.