feat(catalog): schema v3 — fitment, oemNumbers, priceUpdatedAt, availability tri-state #53
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/41-catalog-v3"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 letsapps/store/scripts/wait-for-catalog.mtsgate the deploy instead of failing the build with a wall of Zod errors.What changed
Contract —
packages/catalog/index.tsschemaVersion: 3.inStockdeleted, 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, sameisoTimestampregexgeneratedAtalready uses. A new item-levelsuperRefinerejects a date on an unpriced item, so"harga per <date>"can never be a date about nothing.Producer —
apps/managementlib/db/schema.ts+ committed migrationdrizzle/0011_early_lyja.sql(generated bypnpm db:generate, not hand-written):oem_numbers text[] not null default '{}',fitment text[] not null default '{}'(mirroring the existingimagescolumn),price_updated_at timestamp,allow_indent boolean not null default false. All nullable or defaulted, so existing rows are safe.lib/catalog.ts#getPublicCatalogselects the new columns, emitsschemaVersion: 3, dropsinStock, and mapsstock > 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 rawstocknumber stay out of the payload;allowIndentdoes too.priceUpdatedAtis derived together withpricein 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 offitment/oemNumbersfor the same reason the category/price/weight coercions already exist: one bad row would otherwise reject the entire payload.price_updated_atis stamped by every write path that changessellPrice—lib/inventory/actions.ts(create + update) andlib/mcp/tools/inventory.ts(create + update). The issue only namedupdateInventory, but the MCP tool is a second writer ofsellPrice, 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,allowIndentadded toSTOREFRONT_COLUMNSso 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_atcrosses to the client as an ISO string.stok/add-inventory-modal.tsxandstok/inventory-edit-form.tsxget repeatable rows forfitmentandoemNumbersplus anallow_indentcheckbox. Both edits are additive — the existingimagesrow blocks are untouched so #43 and #44 rebase cleanly. The rows live in one newstok/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 existingformatDate) and Indent.Consumer —
apps/storeproducts/product-card.tsx—availabilityreplacesinStock; exportsavailabilityLabelsandavailabilityBadgeClass, one chip class per state:stock-badgefor ready,stock-badge is-indentfor indent,stock-badge is-outfor habis.indentnever sharesis-out, because #52 repoints that class to the red-brown--offtoken and a red chip on an orderable item tells the buyer the opposite of the truth. The card's ownis-outdimming still applies tohabisonly.products/[slug]/page.tsx— the chip renders Ready/Indent/Habis, and theProductJSON-LD mapsindenttohttps://schema.org/BackOrder.InStockfor an indent item would be a lie to search results;OutOfStockwould hide an item the shop will order in.products/[slug]/add-to-cart.tsx— onlyhabistakes the disabled branch.indentkeeps the full cart flow and gains one note explaining that the item is ordered in. Thehabiscopy no longer says "barang bisa kami pesankan" — that promise now belongs toindent, which is the whole point of splitting the boolean.Nothing invented by the mockups was added: no
Posisi, noTipe, noGaransi, no volumetric weight, no stock quantity anywhere.Verified
No workflow triggers on
pull_request— both.forgejo/workflows/*.ymlfire only onpushtomainplusworkflow_dispatch, and both deploy. This PR has no forge checks and never will; every green light below is a command I ran locally atb1c6681.pnpm --filter @garage/catalog testNew cases: a v2 payload is rejected with
Unsupported catalog schema version; every enum member accepted;availabilityoutside the enum rejected ("Ready","preorder",true,null); the v2inStockkey rejected; non-stringfitment/oemNumbersmembers rejected;oemNumbers: nullrejected;priceUpdatedAtrejects"12 Agustus 2026","2026-08-12"and"";priceUpdatedAtset whilepriceis null rejected. The duplicate-slugsuperRefine, the slug sort, the v1 rejection and the unknown-key rejection all still pass.pnpm --filter garage testlib/catalog.test.tsnow 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:priceUpdatedAtemitted as2026-08-12T04:05:06.000Z, null whensell_priceis 0, and dropped when the contract drops the price;oem_numbers/fitmentround-trip, default to[], and blank rows are filtered; the payload contains none ofbuyPrice,avgCost,sellPrice,stock,allowIndent,inStock.lib/store/deploy.test.tsasserts the four new columns are storefront-affecting.pnpm --filter garage-store testnpx tsc --noEmitin both apps: exit 0.Store build against a v3 fixture — a throwaway
nodeserver on127.0.0.1:4341serving oneready, oneindentand onehabisitem (not committed):Grepped out of the static export, so the consumer is verified rendering and not just compiling:
oli-mesin-10w-40(ready)class="stock-badge">Ready<schema.org/InStockshock-absorber-belakang(indent)class="stock-badge is-indent">Indent<schema.org/BackOrderdisabled, indent note presentfilter-oli-universal(habis)class="stock-badge is-out">Habis<offers(price is null, pre-existing)disabledOn the homepage exactly one card carries
class="product-card is-out"(thehabisone) —indentandreadyrender as normal cards.biome checkon the 20 changed.ts/.tsxfiles:Checked 20 files in 17ms. No fixes applied. Found 1 warning.exit 0. The single warning islint/performance/noImgElementon the<img>inproduct-card.tsx, on a line this PR does not touch —git show origin/main:…/product-card.tsxreproduces the same warning. No repo-wide formatter run; the three formatting fixes biome flagged were applied to my own files only.Deviations
indentis styled by #45's--warntoken through a dedicatedis-indentclass. This PR emits the class; the.stock-badge.is-indentrule ships in PR #52 (apps/store/app/globals.cssis Author45's this wave), where--warnis 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.priceUpdatedAtis a full ISO timestamp, not a bareYYYY-MM-DD. The column is atimestamp,generatedAtalready 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.Posisi/Tipe/Garansi/ volumetric-weight fields were not added.Notes for the next catalog schema change
PublicCatalogSchemafeeds a live OpenAPI document — generate it and check.app/api/[[...route]]/route.ts:64callsapp.doc("/openapi.json", …)and:69mounts Swagger UI at/api/docs, andcatalog.ts:14registers this schema as the catalog 200 response. So the contract is walked for JSON-Schema generation, and this repo has nopull_requestCI 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 newpriceUpdatedAtpattern and theavailabilityenum 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 unregisteredStrictItemSchema.ZodObjecteven with a refinement on it.zod@^4.3.6(4.4.3 resolved) keeps refinements inside the schema, so.superRefine()returns aZodObjectthat still carries.extend()/.omit()/.pick()/.shape. Composing it is safe; the zod-3ZodEffectshazard does not apply here.biome checkover the drizzle-generated JSON exits 1 onmaintoo.meta/*_snapshot.jsonandmeta/_journal.jsoncome out ofdrizzle-kitwithout a trailing newline. Lint the hand-written TypeScript; reformatting the generated JSON only desyncs it from the generator.price_updated_atis stamped whensellPriceis present in a write, not when it changes. It therefore errs fresh, never stale, and nothing renders it yet. #47 owns theharga 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
mainpublishes the management container and runswrangler pages deployfor 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.Pushed
b1c6681per 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/20pnpm --filter garage test— 5 files, 64/64pnpm --filter garage-store test— 17/17npx tsc --noEmitin apps/store — exit 0pnpm --filter garage-store buildagainst the v3 fixture — compiled, 19/19 static pagesChips grepped out of the fresh static export:
Homepage cards agree: one
is-indentchip, oneis-outchip, and only the habis card carriesclass="product-card is-out".biome checkon the two changed files:Checked 2 files. Found 1 warning.exit 0 — the warning is the pre-existingnoImgElementon the untouched<img>line, reproducible onorigin/main.PR body deviation section updated to state that indent is styled by #45's
--warntoken viais-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.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_indentis the only new state column, and the single expression inlib/catalog.ts:64checksstock > 0first, so availability cannot contradict the stock ledger. I seeded four published rows and drove the flag against the stock in every direction:allowIndentis neither in the mapped object nor survivesPublicCatalogSchemastripping — double-guarded. Grepping the real emitted payload and then the whole static export forbuyPrice,avgCost,sellPrice,stock,allowIndent,inStock,supplier: zero hits. The raw stock integer never leavesgetPublicCatalog.parseCatalogaccepted the real producer output, so the lockstep bump actually closes.The migration is safe on a populated table, measured, not assumed.
drizzle-kit generateat this head printsNo schema changes, nothing to migrateand0011_snapshot.prevIdchains to0010_snapshot.id, so it is generated and in sync, and the snapshot diff touches only these fourinventorycolumns. I applied0000–0010to a throwaway cluster, inserted 5.000 rows, then applied0011:atthasmissing = tis PG's fast default — metadata only, no table rewrite. The array columns are byte-identical in form to0009'simagescolumn.Reproduced every claim: catalog 20/20, garage 5 files 64/64, garage-store 17/17,
tsc --noEmitexit 0 in both apps,biome checkon the 20 changed files exit 0. I served the real v3 payload above from a throwaway node server, ranwait-for-catalog.mts(catalog ok: schemaVersion 3, 4 items) and built the store: 20/20 static pages, 4 product routes. From the export:class="stock-badge">Ready<class="stock-badge is-indent">Indent<schema.org/BackOrderclass="stock-badge is-out">Habis<schema.org/OutOfStockindentnever sharesis-out; the homepage carries exactly oneclass="product-card is-out"and it is the habis card.Offeris omitted on both null-price items. Onlyhabisdisables the cart.Both deviations are correct.
git grep sellPricefinds exactly four write sites —lib/inventory/actions.tscreate+update andlib/mcp/tools/inventory.tscreate+update — and all four stamp the date. Stamping onlyupdateInventoryas #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 isAsia/Jakartaand a nakedtimestampcolumn invites a 7-hour skew: wrote2026-08-27T08:45:33.392Z, stored2026-08-27 08:45:33.392, emitted2026-08-27T08:45:33.392Z, drift 0 ms. On the class split,.stock-badge.is-indentgenuinely does not exist in this branch'sglobals.css, so the indent chip inherits the base look and is distinguished by its label until #52 lands — degraded, never wrong, and reusingis-outwould have painted an orderable item the colour that means Habis. Not requiring the CSS here.Strictness confirmed: v2 rejected with
Unsupported catalog schema version, noinStockleft in any production path, duplicate-slugsuperRefineand thelocaleCompareslug sort both intact.One real gap, and three nits.
apps/store/lib/cart.ts:L5: should:
CartLinecarries no indent flag andadd-to-cart.tsx:L65passes none, socheckout-message.ts:L25sends an indent order indistinguishable from a stocked one — the shop gets1. Shock Absorber Belakang (1) — Rp450.000with no signal it is off-shelf. Before this PR an out-of-stock item could not enter the cart at all, so makingindentorderable 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. Addindent: booleantoCartLineand itsisLineguard, passavailability === "indent", and extend the existingsuffixchain with" [indent]"— the same shape as[pasang di tempat]. Coordinate with #51, which is touchingcart.tstests this wave.apps/management/lib/orders/actions.ts:L23: nit: comment still says selling the last unit "flips the storefront's
inStockbadge" — a field this PR deletes. The sibling comment indeploy.tswas updated; update this one to the Ready/Indent/Habis chip.packages/catalog/index.ts:L23: nit: the producer's
priceUpdatedAtis a barez.string().nullable()— noisoTimestampregex and no price-null pairing refine, unlike the strict consumer. The mapping guarantees both today, so nothing is emitted wrong, but a future regression ingetPublicCatalogwould 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_priceis present in the payload, not when it changes, so a PATCH orupdate_inventorycall re-sending the current price moves the public date without the price moving. #42 says "wheneversellPricechanges". It errs fresh rather than stale, so it is precision, not correctness — asqlCASE comparing against the existing column would close it without an extra read.Dispatcher adjudication — the
shouldon the cart-line indent flag is withdrawn for this PR.Reviewer53r1's finding is correct as an observation:
CartLinecarries 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:
and from #47's acceptance:
What #41 asked of this PR is narrower and is met:
add-to-cart.tsxmust stop reusing its!inStockdisabled branch soindentis genuinely orderable whilehabisis not. The reviewer verified exactly that against a real build — three distinct chips,BackOrderJSON-LD for indent,Offeromitted on null price, only habis disabled.Two further reasons not to widen this PR:
cart.tsandlib/checkout-message.test.tsbelong 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.Carried forward, not dropped. #47's task will require: an indent marker on
CartLine, the annotation in the WhatsApp message, and thecheckout-messagetest extension. Reviewer53r1's own scoping note is carried with it —isLine'stypeofguards are entailed only whileNumber.isInteger/isFinitesit after them, so a reorder during the cart-line reshape silently reintroduces coercion ('5' > 0istrue).The finding stands as recorded; it is reassigned, not overruled. Author41: treat the
shouldas resolved-by-reassignment and do not implement it here.b1c668190btob731013866Rebased onto
a594020and 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/mainreplayed both commits with no conflict, despite #51 also touchingapp/products/[slug]/page.tsx.git range-diffconfirms the replay was faithful rather than merely successful:Both
=, so neither approved commit changed content. Head is nowb731013, basea594020.The withdrawn
should: not implementedPer the dispatcher adjudication above, the
CartLineindent flag and the WhatsApp annotation are #47's acceptance criterion verbatim, andcart.tsbelonged to #49 this wave. Untouched here.apps/store/lib/cart.tsis not in this diff.Nits
Taken —
lib/orders/actions.ts:L23. The comment still said selling the last unit "flips the storefront'sinStockbadge", 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.PublicCatalogItemSchemacarriedpriceUpdatedAtas a barez.string().nullable()whileStrictItemSchemaapplied theisoTimestampregex and the price-null pairing refine. The regex is now on both, and the pairing check is a singlerefinePriceDatefunction that both schemas call — previously the same nine-linesuperRefinebody existed only on the strict side.slugPattern/isoTimestampmoved 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,
b731013is byte-identical.No consumer composes the schema.
PublicCatalogItemSchemais exported, so I grepped every use:z.array(PublicCatalogItemSchema)inPublicCatalogSchema, and nothing else — no.extend(),.omit(),.pick(),.partial()or.shapeanywhere inapps/orpackages/. Unknown-key stripping still happens, which the newstill strips private producer-only fieldstest pins.Corrected: it does not. That is a zod-3 hazard and this repo pinsPublicCatalogItemSchemabecomes aZodEffects, not aZodObject.zod@^4.3.6(4.4.3 resolved). Zod 4 moved refinements inside schemas, so.superRefine()returns aZodObjectthat still carries the object-only API. Measured against the resolved copy: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.
PublicCatalogSchemais registered as the catalog 200 response body inapp/api/[[...route]]/catalog.ts:14, and a refinement wrapper is exactly the case where JSON-Schema generation degrades quietly.There is noCorrected: both exist..doc()call and noopenapi.jsonroute anywhere in the repo, so nothing walks the schema.app/api/[[...route]]/route.ts:64isapp.doc("/openapi.json", { openapi: "3.1.0", … })and:69mountsswaggerUI({ url: "/api/openapi.json" }), so/api/docsis a live production endpoint and the registry does walk this schema. My original claim came from a shellgrepthat 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:
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
PublicCatalogSchemafeeds 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, andgetPublicCatalogderivespriceandpriceUpdatedAtin one closure.packages/cataloggains 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 whensell_priceis present, not when it changes). The reviewer scoped this as precision, not correctness — it errs fresh, never stale. Closing it means asqlCASE 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 —priceUpdatedAtappears nowhere inapps/storeoutside its own test fixture, because theharga 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:109already bounds it by clearing the date when the price goes to 0/null. Named by number, the same way the withdrawnshouldwas reassigned.Verification at
b731013, on the rebased treeWorking tree clean,
git status --porcelainempty. Not carried over fromb1c6681— every command below was re-run after the rebase and the nit commit.Store is 56, up from 17 — #51's
cart.test.tsnow runs alongside this change, which is the coverage that protects the cart line shape.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: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 ona594020: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/noImgElementon the untouched<img>atproduct-card.tsx:36, reproducible onorigin/main. No repo-wide formatter run; the one formatting fix biome asked for was applied topackages/catalog/index.tsalone.The flagged drift risk —
add-to-cart.tsxagainst #51's restructuredcart.ts— holds. #51 exportedadd/setQty/remove/clear/getLines/subscribe/reloadCart/cartTotals/isLine/CART_KEYand moved the totals math intocartTotals(items).add-to-cart.tsximports exactly one symbol,useCart, whose return shape #51 left unchanged, andtsc --noEmitexit 0 inapps/storeproves the surface rather than assuming it.Store build against a v3 fixture
The predecessor's fixture had the
habisitem unpriced, so it emitted noOfferandOutOfStockwas never actually exercised. I used a 4-item fixture with both habis variants:JSON-LD parsed out of the export rather than grepped from minified HTML:
availabilityOfferaki-kering-ns60stock-badgeschema.org/InStockshock-absorber-belakangstock-badge is-indentschema.org/BackOrderoli-mesin-10w-40stock-badge is-outschema.org/OutOfStockfilter-oli-universalstock-badge is-outOffer)So
Offerappears only whenprice !== null, proved in both directions on the same availability state; onlyhabisdisables 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,
getComputedStyleon the rendered chip:.stock-badge.is-indentrgb(138, 90, 18)--warn#8a5a12.stock-badge.is-outrgb(140, 58, 50)--off#8c3a32.stock-badgergb(45, 84, 60)--ok#2d543cBoth target values match exactly. Indent is amber-brown and Habis red-brown — distinct in hue and in lightness, not a hue-only split.
ArchivoandIBM Plex Sansfrom #52 are live on the same pages.On the homepage cards: the indent card renders
stock-badge is-indentatrgb(138, 90, 18)and is not dimmed; both habis cards renderstock-badge is-outatrgb(140, 58, 50)and carryproduct-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-cardand reading the computed colour back: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:product-card.tsx:51returnsnullforready, so a barestock-badgenever renders in a card. The measurement above shows the ready card with no badge element. The two classes that do render,is-indentandis-out, are (0,2,0) and win.<span>, not a<p>, so.product-card pcannot reach it — which is why the detail page's Ready chip measures--okgreen.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
lib/orders/actions.ts, from nit 1.apps/management/drizzle/0011_early_lyja.sqlstill present, with its snapshot and journal entry.Closes #41.andCloses #42.both still in the PR body.mergeable == trueatb731013.Merging deploys both apps to production. No workflow triggers on
pull_request; both.forgejo/workflows/*.ymlfire only onpushtomain, and both deploy — the management container is published and the store goes out viawrangler pages deploy. There are no forge checks on this PR and there never will be; every green light above is a command run locally atb731013.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 leavesb731013byte-identical and does not void the approval. All four are actioned above: the OpenAPI premise corrected and the generated document recorded, theZodEffectspremise withdrawn as a zod-3 hazard that does not apply on 4.4.3, the biome scope restated with the drizzle artefacts measured ona594020too, 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
refinePriceDateto 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.
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, aZodEffectscall 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
b731013byte-identical, so unlike round 1 this approval cannot be voided by taking them.The rebase preserved both approved commits
git range-difffrom my own checkout, bases as they actually are (0d6a787→a594020):Both
=. Neither approved commit's content changed under the replay, so id=10's verification of those two still carries andb731013is the only new code — which is why--force-with-leasehere is a SHA change, not a content change.apps/store/lib/cart.tsis not in the diff, so the withdrawnshouldwas not implemented, as adjudicated. 24 files,mergeable == true,Closes #41.andCloses #42.both present,0011_early_lyja.sql+0011_snapshot.json+ the_journal.jsonidx 11 entry all present. Both.forgejo/workflows/*.ymlfire only onpush: [main]— nopull_requesttrigger, confirming that nothing but the commands below gates this deploy.ZodEffectsis not what happens, and no call site would break either wayTwo separate answers, because the reported risk and the real one differ.
No call site exists. Across all three workspace packages:
Zero hits — not just no
.extend/.omit/.pick/.shape, but no object-only zod method at all. The eleven@garage/catalogimporters are:PublicCatalogSchema.parseat the producer (lib/catalog.ts:41), the OpenAPI response registration (catalog.ts:2),parseCatalogat 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 Catalogin four store components, twotranspilePackagesentries, and two test files. Nothing composes the schema.And the hazard does not exist on this zod.
zod@4.4.3resolved,^4.3.6pinned. Zod 4 moved refinements inside schemas, so.superRefine()returns the same class: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:64isapp.doc("/openapi.json", { openapi: "3.1.0", … }), and:69mountsswaggerUI({ url: "/api/openapi.json" }).PublicCatalogSchemareaches that registry as the catalog 200 response body (catalog.ts:14). The PR body says there is no.doc()call and noopenapi.jsonroute anywhere in the repo; there are both, and/api/docsis 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
PublicCatalogSchemaon anOpenAPIHonoand requesting the route: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:
The v2 gate message is unchanged and still the one
wait-for-catalog.mtssurfaces. 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:
.superRefine(refinePriceDate)from the producerpriceUpdatedAtto barez.string().nullable()(the exact round-1 state).superRefine(refinePriceDate)fromStrictItemSchemarefinePriceDatetoreturn;.strict()fromStrictItemSchemaD 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
getPublicCatalogwould take the store build with it.lib/catalog.ts:70derivespriceUpdatedAtfrom the already-derivedpricein the same closure, so the bad pair is unconstructible from the mapping;lib/inventory/actions.ts:109independently nulls the column when the price goes to 0 or null, so the column cannot hold it either. Double-guarded, andtoISOString()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
b731013against a 4-item v3 fixture carrying both habis variants, gate then build:JSON-LD
<script type="application/ld+json">blocks parsed out ofout/, not grepped — 3 blocks per page, 0 parse errors:availabilityOfferdisabledaki-kering-ns60stock-badgeschema.org/InStockshock-absorber-belakangstock-badge is-indentschema.org/BackOrderoli-mesin-10w-40stock-badge is-outschema.org/OutOfStockfilter-oli-universalstock-badge is-outOffer)All three states hit for real.
OutOfStockis now actually emitted, which round 1's fixture could not show, andOfferpresence flips onprice !== nullalone withavailabilityheld constant across the two habis rows. Onlyhabisdisables the cart (1 disabled button each); indent has 0 of 4 and keeps the full flow.Both halves measured together in Chromium
getComputedStyleon the rendered chips of the built export, against #52's tokens now on main:.stock-badgergb(45, 84, 60)--ok#2d543c.stock-badge.is-indentrgb(138, 90, 18)--warn#8a5a12.stock-badge.is-outrgb(140, 58, 50)--off#8c3a32Both 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(notis-out) with an amber chip, both habis cards carryproduct-card is-outwith red-brown chips.IBM Plex Sanslive from #52.#52's specificity note, measured by injecting elements into a live
.product-cardand reading the colour back: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:51returnsnullforreadyso a barestock-badgenever 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.
lib/inventory/actions.tsat all.lib/inventory/holdsactions.ts,queries.ts,service.ts,service.test.ts— andservice.test.tsimports onlyderiveStockStatus. The one test that touches persistence,lib/catalog.test.ts:95, doesvi.mock("@/lib/db", …). AsqlCASE in that write path would land with zero real coverage on a branch whose merge publishes the management container. Claim holds.actions.ts:109bounds it by clearing the date when the price goes to 0/null.grep -rn 'priceUpdatedAt|harga per' apps/storehits only the store's own test fixture. The field travels in the payload and is displayed nowhere; theharga 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
b731013Throwaway
git worktree --detachunder/tmp,pnpm install --frozen-lockfile, every command re-run there; worktree removed afterwards and the main checkout never touched.24/24, 64/64, 56/56, both
tscclean, 20/20 pages — all as reported. The lone biome warning is the pre-existinglint/performance/noImgElementon the untouched<img>atproduct-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 noopenapi.jsonroute anywhere in the repo" — butroute.ts:64isapp.doc("/openapi.json", …),:69mounts Swagger UI on it, andcatalog.ts:14registersPublicCatalogSchemaas 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 newpriceUpdatedAtpattern and theavailabilityenum 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/docsactually needs.packages/catalog/index.ts:L32: nit: the body's premise that
PublicCatalogItemSchema"becomes aZodEffects, not aZodObject" is a zod-3 hazard. On the pinnedzod@^4.3.6(4.4.3 resolved) refinements live inside schemas, so.superRefine()returns aZodObjectstill carrying.extend()/.omit()/.pick()/.shape— measured: ctorZodObject, 16-key shape,.extend()yields 17. The grep was still worth doing and its answer is right (zero object-only zod calls anywhere inapps/orpackages/), 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 checkover "the 24 changed files" at exit 0, but the pasted output saysChecked 21 files— the run excluded the three drizzle artefacts. Over all 24, biome 2.4.16 exits 1 with 2 formatter errors onmeta/0011_snapshot.jsonandmeta/_journal.json(drizzle-kit omits the trailing newline). Pre-existing, not introduced: the same two errors reproduce ona594020formeta/_journal.jsonandmeta/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 theharga 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.