feat(management): edit item name and sell price from the dashboard #56

Merged
yuris merged 4 commits from feat/43-name-price-edit into main 2026-08-27 16:34:03 +00:00
Owner

Closes #43.

Merging this PR deploys to production. No workflow runs on pull_request — both .forgejo/workflows/*.yml fire only on push: main, and both deploy (management publishes a container, the store runs wrangler pages deploy). The forge never checked this branch; every green light below is a command I ran locally against a real Postgres and a real dev server.

Superseded in part by round 1. Review id=19 found a blocker — <input type="number"> reads 95.000 as the decimal 95 — plus a miscount below. Both fixed at b63316e; see the "Round 1 addressed" comment. This body describes the original 7073423 except where corrected inline.

What changed

  • apps/management/app/(dashboard)/stok/inventory-edit-form.tsx — adds Nama Barang and Harga Jual (Rp) as the first two fields of the Toko tab, wired into the existing updateInventory call. Harga Jual is type=number min=0 hinted 0 = belum ditentukan; an emptied field maps to 0 via Number(sellPrice) || 0. Both fields follow the same diff-on-change rule as every other field in this form, so an untouched field is never re-sent. Submit is disabled while the name is blank (disabled={saving || nameEmpty}), matching the wajib diisi hint.
  • apps/management/lib/inventory/actions.ts — updateInventory now runs createInventory's duplicate-name pre-check before writing itemName, skipping the row being edited (existing.id !== Number(data.id)). See Deviations for why this server change is in a "form and UI work" PR.
  • apps/management/lib/inventory/queries.ts — findInventoryByName accepts DbTransaction | typeof db. It was transaction-only; updateInventory is a single-row update with no surrounding transaction. Signature widening only, no behaviour change for existing callers. Renamed to conn in round 1, since it may now be the root db.
  • apps/management/lib/inventory/actions.test.ts — new. lib/inventory/actions.ts had no test file; these 6 cases cover the zero/null price path the issue asked about, plus the rename guard. @/lib/db and @/lib/auth/guard are stubbed but affectsStorefront is left real, so the rebuild assertions run against the actual STOREFRONT_COLUMNS list rather than a copy of it.

No new server route, no new dependency, no change to UpdateInventorySchema, no image-upload work (#44 owns the URL rows).

Verified

Real UI against real Postgres

Postgres 16.14 from the Nix store on 127.0.0.1:55432, pnpm db:migrate through 0011, next dev -p 3210, driven with the browser tool as a logged-in operator. STORE_DEPLOY_WORKFLOW_URL pointed at a local HTTP sink so requestStoreDeploy is observable — unset, it is a deliberate silent no-op (lib/store/deploy.ts:59), which would have proven nothing.

Two seeded rows: Oli Mesin Shell Helix 1L (sell_price 95000, price_updated_at 2026-05-29) and Filter Oli Toyota Avanza (45000).

1. Name edit — typed Oli Mesin Shell Helix HX7 1L, saved. Toast Pengaturan toko disimpan.

 id |          item_name           | sell_price |      price_updated_at
  1 | Oli Mesin Shell Helix HX7 1L |      95000 | 2026-05-29 19:36:49.580506
deploy sink: 2026-08-27T12:39:23.354Z POST /dispatch auth=token *** body={"ref":"main"}

Name persisted, one rebuild queued, price_updated_at untouched.

2. Price edit — 95000 → 112500, saved.

  1 | Oli Mesin Shell Helix HX7 1L |     112500 | 2026-08-27 12:39:49.406
deploy sink: 2026-08-27T12:39:49.410Z POST /dispatch auth=token *** body={"ref":"main"}

price_updated_at stamped, matching the deploy timestamp. #42's stamping is reached through this form.

3. Clearing the price to zero — emptied the field ("", not "0"). Before submitting: price field "", Tampilkan di toko still checked, submit button not disabled — nothing validates a price as required.

  1 | Oli Mesin Shell Helix HX7 1L |  0 |  <null>  | is_published = t
deploy sink: 2026-08-27T12:53:55.987Z POST /dispatch auth=token *** body={"ref":"main"}

sell_price 0, price_updated_at NULL (the date is cleared with the price, so no date survives a number the storefront never shows), row still published. GET /api/public/catalog:

{
  "slug": "oli-mesin-shell-helix-1l",
  "name": "Oli Mesin Shell Helix HX7 1L",
  "price": null,
  "priceUpdatedAt": null,
  "availability": "ready"
}

price: null with the item still in the export — the "belum ditentukan" / "Hubungi kami" state, reached entirely from the dashboard. The export also carries the new name, so a rename reaches the storefront. schemaVersion: 3, no stock or inStock field.

4. Duplicate name — renamed item 1 to FILTER OLI TOYOTA AVANZA (different case, padded, to exercise the lower(btrim(...)) index mirror). Rendered as a red error toast:

Barang dengan nama serupa sudah ada: "Filter Oli Toyota Avanza".

Byte-identical to createInventory's message — one string, not a second wording. Both rows unchanged afterwards and zero deploy hits: the guard rejects before the write. For contrast, the same rename straight through SQL:

ERROR:  duplicate key value violates unique constraint "inventory_item_name_uniq"
DETAIL:  Key (lower(btrim(item_name::text)))=(filter oli toyota avanza) already exists.

That dump is what an operator saw without the guard.

5. Raising the price back out of zero — renamed and repriced 0 → 89000 in one save.

  1 | Oli Mesin Shell Helix HX7 10W-40 1L |  89000 | 2026-08-27 12:55:45.834
deploy sink: 2026-08-27T12:55:45.839Z POST /dispatch auth=token *** body={"ref":"main"}
catalog: "price": 89000, "priceUpdatedAt": "2026-08-27T12:55:45.834Z"

price_updated_at re-stamps from NULL; both fields land in one round trip with one rebuild.

6. Blank name — name set to " " → submit button disabled: true. Restored to a real name → enabled, saved, and price_updated_at stayed 12:55:45.834 while the save itself ran at 12:56:17 — a save that does not touch the price does not refresh the date.

Commands

$ pnpm --filter garage test
 Test Files  6 passed (6)
      Tests  70 passed (70)
   Duration  804ms

70, up from 64 on main; the 6 new cases are all in lib/inventory/actions.test.ts:

 ✓ updateInventory price > stamps the price date when a real price is saved
 ✓ updateInventory price > clears the price date when the price is cleared to zero
 ✓ updateInventory price > queues a store rebuild when sell_price changes
 ✓ updateInventory price > queues a store rebuild when item_name changes
 ✓ updateInventory name  > rejects a rename onto another item with the create modal's message
 ✓ updateInventory name  > lets an item keep its own name
$ pnpm exec biome check "app/(dashboard)/stok/inventory-edit-form.tsx" lib/inventory/actions.ts lib/inventory/queries.ts lib/inventory/actions.test.ts
Checked 4 files in 14ms. No fixes applied.

$ pnpm exec tsc --noEmit
(clean, exit 0)

Changed files only — no repo-wide format or lint run. All output above is from 7073423.

Deviations

The server-side duplicate-name guard is a server change in a PR the issue framed as "form and UI work". I kept it, because without it the acceptance criterion cannot be met. Corrected in round 1: there are four itemName write paths, not three. createInventory (actions.ts) and purchases/service.ts already called findInventoryByName and threw the same sentence, but lib/mcp/tools/inventory.ts writes itemName directly at both create and update and called neither, so an MCP rename still dumped raw Postgres. The check is now a shared inventoryNameConflict helper invoked from all four sites. A rename with no guard reaches the inventory_item_name_uniq index and surfaces the raw Postgres dump quoted in case 4 — precisely what "not a raw Postgres constraint dump" rules out. The issue also says to reuse the create modal's handling; that handling is this pre-check plus the toast, so reusing it means calling the same function with the same message. The message is not duplicated — it is the same literal in both call sites. The DB index remains the real arbiter for a concurrent race; the guard only buys the readable message on the common path.

price_updated_at on re-save. The declined imprecision recorded on #47 (stamped whenever sell_price is present, not only when it changes) is untouched — actions.ts:114-120 is unchanged. It does not surface through this form, because the form sends sell_price only when the value differs from item.sellPrice, the same diff rule the other 14 fields already use. Case 6 shows it: a name-only save left the timestamp at 12:55:45.834. So re-saving the form does not refresh the date, and no server behaviour was changed to get that.

buy_price and stock keep Number(x) || 0 in the create modal — deferred, NOT safe. An earlier comment of mine justified this as "neither is published, both bounded server-side". Both halves of that are wrong, and round 3 proved it rather than argued it:

  • Neither is bounded. Both are z.number().optional() with no .int(), .nonnegative() or .max(), so PATCH {"buy_price":9999999999} is still a bare 500 — the same raw-dump class this PR's own title is about, one field to the left.
  • stock does reach the storefront. PATCH {"stock":-5} returns 200, persists, and flips the item's public availability to "habis" — #42's derived stock > 0 ? "ready" : … rule working correctly on a value nothing rejects. So a negative stock silently marks a real product unavailable to buyers.

The comparison I drew to Berat (gram) does not transfer: weight_grams genuinely is .int().positive(), so that one really was server-bounded. These two are not. The deferral still looks right — widening now would void a clean verdict on the final round, and the fix is schema-only for whoever picks it up — but it is recorded here as deferred with a known hole, not as covered. Filed separately by the dispatcher; Berat (gram) is #59.

Not done, deliberately: stock stays out of this form (it belongs to the FIFO ledger via adjustment-modal.tsx). Image upload is #44's. No warranty/Posisi/Tipe fields. No stock quantity is rendered or exported.

Two round-3 nits declined, both copy-only in add-inventory-modal.tsx. The reviewer sanctioned merging without them and a push would void an approve-with-nits verdict for cosmetics, so: (1) the modal's refusal hint wording differs from the edit form's and omits "spasi" — real but purely wording, and the two screens already differ by the modal's leading-em-dash hint convention, so a shared constant needs a wording decision rather than a rename; (2) the modal shows no hint while the price is valid, so it never states that empty/0 means "belum ditentukan". Both are worth a follow-up on the same pass that fixes buy_price/stock in that file; neither changes what any input does.

Closes #43. **Merging this PR deploys to production.** No workflow runs on `pull_request` — both `.forgejo/workflows/*.yml` fire only on `push: main`, and both deploy (management publishes a container, the store runs `wrangler pages deploy`). The forge never checked this branch; every green light below is a command I ran locally against a real Postgres and a real dev server. > **Superseded in part by round 1.** Review id=19 found a blocker — `<input type="number">` reads `95.000` as the decimal 95 — plus a miscount below. Both fixed at `b63316e`; see the "Round 1 addressed" comment. This body describes the original `7073423` except where corrected inline. ## What changed - `apps/management/app/(dashboard)/stok/inventory-edit-form.tsx` — adds `Nama Barang` and `Harga Jual (Rp)` as the first two fields of the Toko tab, wired into the existing `updateInventory` call. `Harga Jual` is `type=number min=0` hinted `0 = belum ditentukan`; an emptied field maps to `0` via `Number(sellPrice) || 0`. Both fields follow the same diff-on-change rule as every other field in this form, so an untouched field is never re-sent. Submit is disabled while the name is blank (`disabled={saving || nameEmpty}`), matching the `wajib diisi` hint. - `apps/management/lib/inventory/actions.ts` — `updateInventory` now runs `createInventory`'s duplicate-name pre-check before writing `itemName`, skipping the row being edited (`existing.id !== Number(data.id)`). See Deviations for why this server change is in a "form and UI work" PR. - `apps/management/lib/inventory/queries.ts` — `findInventoryByName` accepts `DbTransaction | typeof db`. It was transaction-only; `updateInventory` is a single-row update with no surrounding transaction. Signature widening only, no behaviour change for existing callers. Renamed to `conn` in round 1, since it may now be the root `db`. - `apps/management/lib/inventory/actions.test.ts` — new. `lib/inventory/actions.ts` had no test file; these 6 cases cover the zero/null price path the issue asked about, plus the rename guard. `@/lib/db` and `@/lib/auth/guard` are stubbed but `affectsStorefront` is left real, so the rebuild assertions run against the actual `STOREFRONT_COLUMNS` list rather than a copy of it. No new server route, no new dependency, no change to `UpdateInventorySchema`, no image-upload work (#44 owns the URL rows). ## Verified ### Real UI against real Postgres Postgres 16.14 from the Nix store on `127.0.0.1:55432`, `pnpm db:migrate` through `0011`, `next dev -p 3210`, driven with the `browser` tool as a logged-in operator. `STORE_DEPLOY_WORKFLOW_URL` pointed at a local HTTP sink so `requestStoreDeploy` is observable — unset, it is a deliberate silent no-op (`lib/store/deploy.ts:59`), which would have proven nothing. Two seeded rows: `Oli Mesin Shell Helix 1L` (`sell_price` 95000, `price_updated_at` 2026-05-29) and `Filter Oli Toyota Avanza` (45000). **1. Name edit** — typed `Oli Mesin Shell Helix HX7 1L`, saved. Toast `Pengaturan toko disimpan.` ``` id | item_name | sell_price | price_updated_at 1 | Oli Mesin Shell Helix HX7 1L | 95000 | 2026-05-29 19:36:49.580506 deploy sink: 2026-08-27T12:39:23.354Z POST /dispatch auth=token *** body={"ref":"main"} ``` Name persisted, one rebuild queued, `price_updated_at` untouched. **2. Price edit** — 95000 → 112500, saved. ``` 1 | Oli Mesin Shell Helix HX7 1L | 112500 | 2026-08-27 12:39:49.406 deploy sink: 2026-08-27T12:39:49.410Z POST /dispatch auth=token *** body={"ref":"main"} ``` `price_updated_at` stamped, matching the deploy timestamp. #42's stamping is reached through this form. **3. Clearing the price to zero** — emptied the field (`""`, not `"0"`). Before submitting: price field `""`, `Tampilkan di toko` still checked, submit button **not** disabled — nothing validates a price as required. ``` 1 | Oli Mesin Shell Helix HX7 1L | 0 | <null> | is_published = t deploy sink: 2026-08-27T12:53:55.987Z POST /dispatch auth=token *** body={"ref":"main"} ``` `sell_price` 0, `price_updated_at` NULL (the date is cleared with the price, so no date survives a number the storefront never shows), row still published. `GET /api/public/catalog`: ```json { "slug": "oli-mesin-shell-helix-1l", "name": "Oli Mesin Shell Helix HX7 1L", "price": null, "priceUpdatedAt": null, "availability": "ready" } ``` `price: null` with the item still in the export — the "belum ditentukan" / "Hubungi kami" state, reached entirely from the dashboard. The export also carries the new name, so a rename reaches the storefront. `schemaVersion: 3`, no `stock` or `inStock` field. **4. Duplicate name** — renamed item 1 to ` FILTER OLI TOYOTA AVANZA ` (different case, padded, to exercise the `lower(btrim(...))` index mirror). Rendered as a red error toast: > Barang dengan nama serupa sudah ada: "Filter Oli Toyota Avanza". Byte-identical to `createInventory`'s message — one string, not a second wording. Both rows unchanged afterwards and **zero** deploy hits: the guard rejects before the write. For contrast, the same rename straight through SQL: ``` ERROR: duplicate key value violates unique constraint "inventory_item_name_uniq" DETAIL: Key (lower(btrim(item_name::text)))=(filter oli toyota avanza) already exists. ``` That dump is what an operator saw without the guard. **5. Raising the price back out of zero** — renamed and repriced 0 → 89000 in one save. ``` 1 | Oli Mesin Shell Helix HX7 10W-40 1L | 89000 | 2026-08-27 12:55:45.834 deploy sink: 2026-08-27T12:55:45.839Z POST /dispatch auth=token *** body={"ref":"main"} catalog: "price": 89000, "priceUpdatedAt": "2026-08-27T12:55:45.834Z" ``` `price_updated_at` re-stamps from NULL; both fields land in one round trip with one rebuild. **6. Blank name** — name set to `" "` → submit button `disabled: true`. Restored to a real name → enabled, saved, and `price_updated_at` stayed `12:55:45.834` while the save itself ran at `12:56:17` — a save that does not touch the price does not refresh the date. ### Commands ``` $ pnpm --filter garage test Test Files 6 passed (6) Tests 70 passed (70) Duration 804ms ``` 70, up from 64 on `main`; the 6 new cases are all in `lib/inventory/actions.test.ts`: ``` ✓ updateInventory price > stamps the price date when a real price is saved ✓ updateInventory price > clears the price date when the price is cleared to zero ✓ updateInventory price > queues a store rebuild when sell_price changes ✓ updateInventory price > queues a store rebuild when item_name changes ✓ updateInventory name > rejects a rename onto another item with the create modal's message ✓ updateInventory name > lets an item keep its own name ``` ``` $ pnpm exec biome check "app/(dashboard)/stok/inventory-edit-form.tsx" lib/inventory/actions.ts lib/inventory/queries.ts lib/inventory/actions.test.ts Checked 4 files in 14ms. No fixes applied. $ pnpm exec tsc --noEmit (clean, exit 0) ``` Changed files only — no repo-wide format or lint run. All output above is from `7073423`. ## Deviations **The server-side duplicate-name guard is a server change in a PR the issue framed as "form and UI work". I kept it, because without it the acceptance criterion cannot be met.** **Corrected in round 1:** there are **four** `itemName` write paths, not three. `createInventory` (`actions.ts`) and `purchases/service.ts` already called `findInventoryByName` and threw the same sentence, but `lib/mcp/tools/inventory.ts` writes `itemName` directly at both create and update and called neither, so an MCP rename still dumped raw Postgres. The check is now a shared `inventoryNameConflict` helper invoked from all four sites. A rename with no guard reaches the `inventory_item_name_uniq` index and surfaces the raw Postgres dump quoted in case 4 — precisely what "not a raw Postgres constraint dump" rules out. The issue also says to *reuse* the create modal's handling; that handling **is** this pre-check plus the toast, so reusing it means calling the same function with the same message. The message is not duplicated — it is the same literal in both call sites. The DB index remains the real arbiter for a concurrent race; the guard only buys the readable message on the common path. **`price_updated_at` on re-save.** The declined imprecision recorded on #47 (stamped whenever `sell_price` is *present*, not only when it *changes*) is untouched — `actions.ts:114-120` is unchanged. It does not surface through this form, because the form sends `sell_price` only when the value differs from `item.sellPrice`, the same diff rule the other 14 fields already use. Case 6 shows it: a name-only save left the timestamp at `12:55:45.834`. So re-saving the form does **not** refresh the date, and no server behaviour was changed to get that. **`buy_price` and `stock` keep `Number(x) || 0` in the create modal — deferred, NOT safe.** An earlier comment of mine justified this as "neither is published, both bounded server-side". **Both halves of that are wrong**, and round 3 proved it rather than argued it: - Neither is bounded. Both are `z.number().optional()` with no `.int()`, `.nonnegative()` or `.max()`, so `PATCH {"buy_price":9999999999}` is still a bare 500 — the same raw-dump class this PR's own title is about, one field to the left. - **`stock` does reach the storefront.** `PATCH {"stock":-5}` returns 200, persists, and flips the item's public `availability` to `"habis"` — #42's derived `stock > 0 ? "ready" : …` rule working correctly on a value nothing rejects. So a negative stock silently marks a real product unavailable to buyers. The comparison I drew to `Berat (gram)` does not transfer: `weight_grams` genuinely **is** `.int().positive()`, so that one really was server-bounded. These two are not. The deferral still looks right — widening now would void a clean verdict on the final round, and the fix is schema-only for whoever picks it up — but it is recorded here as deferred with a known hole, not as covered. Filed separately by the dispatcher; `Berat (gram)` is #59. **Not done, deliberately:** `stock` stays out of this form (it belongs to the FIFO ledger via `adjustment-modal.tsx`). Image upload is #44's. No warranty/`Posisi`/`Tipe` fields. No stock quantity is rendered or exported. **Two round-3 nits declined, both copy-only in `add-inventory-modal.tsx`.** The reviewer sanctioned merging without them and a push would void an `approve-with-nits` verdict for cosmetics, so: (1) the modal's refusal hint wording differs from the edit form's and omits "spasi" — real but purely wording, and the two screens already differ by the modal's leading-em-dash hint convention, so a shared constant needs a wording decision rather than a rename; (2) the modal shows no hint while the price is valid, so it never states that empty/0 means "belum ditentukan". Both are worth a follow-up on the same pass that fixes `buy_price`/`stock` in that file; neither changes what any input does.
Adds Nama Barang and Harga Jual to the Toko edit form, routed through the
existing updateInventory path. Mirrors createInventory's duplicate-name
guard on the update path so a rename onto an existing name surfaces the
same readable message instead of the raw inventory_item_name_uniq dump.
Author
Owner

Supplementary, at 7073423 — two edge cases on the new Harga Jual field that a reviewer would reasonably ask about. Both are handled by the input's own constraints, so no extra validation code was added.

Harga Jual is type="number" min="0" with no step, so step defaults to 1. The form does not set noValidate, so native constraint validation runs on submit before handleSubmit does.

typed checkValidity() validationMessage
-5000 false Value must be greater than or equal to 0.
89000.5 false Please enter a valid value. The two nearest valid values are 89000 and 89001.
89000 true —

Clicking Simpan with -5000 in the field: no toast, sheet stays open, and

  1 | Oli Mesin Shell Helix HX7 10W40 1L |      89000 | 2026-08-27 12:55:45.834
deploy sink: (no hits)

The row is untouched and no rebuild was queued — the submit handler never ran, so no negative or fractional value can reach the integer column. This is the same arrangement the existing Berat (gram) field already relies on (type="number" min="0", Number(weightGrams) || null), which is why Number(sellPrice) || 0 needs no guard of its own.

Empty is deliberately still valid (checkValidity() true, no required) — that is the "belum ditentukan" path in case 3 of the description.

Supplementary, at `7073423` — two edge cases on the new `Harga Jual` field that a reviewer would reasonably ask about. Both are handled by the input's own constraints, so no extra validation code was added. `Harga Jual` is `type="number" min="0"` with no `step`, so `step` defaults to `1`. The form does not set `noValidate`, so native constraint validation runs on submit before `handleSubmit` does. | typed | `checkValidity()` | `validationMessage` | |---|---|---| | `-5000` | `false` | Value must be greater than or equal to 0. | | `89000.5` | `false` | Please enter a valid value. The two nearest valid values are 89000 and 89001. | | `89000` | `true` | — | Clicking Simpan with `-5000` in the field: no toast, sheet stays open, and ``` 1 | Oli Mesin Shell Helix HX7 10W40 1L | 89000 | 2026-08-27 12:55:45.834 deploy sink: (no hits) ``` The row is untouched and no rebuild was queued — the submit handler never ran, so no negative or fractional value can reach the `integer` column. This is the same arrangement the existing `Berat (gram)` field already relies on (`type="number" min="0"`, `Number(weightGrams) || null`), which is why `Number(sellPrice) || 0` needs no guard of its own. Empty is deliberately still valid (`checkValidity()` true, no `required`) — that is the "belum ditentukan" path in case 3 of the description.
Author
Owner

Status: ready to merge, blocked on review — not merging

Re-verified at the exact commit proposed for merge, 7073423, at 2026-08-27T14:06:50Z:

$ pnpm --filter garage test
 Test Files  6 passed (6)
      Tests  70 passed (70)
   Duration  743ms

$ pnpm exec biome check "app/(dashboard)/stok/inventory-edit-form.tsx" lib/inventory/actions.ts lib/inventory/queries.ts lib/inventory/actions.test.ts
Checked 4 files in 10ms. No fixes applied.

$ pnpm exec tsc --noEmit
(exit 0)

mergeable: true, head unchanged since the PR opened, nothing pushed since verification.

I am not merging, and I am not asking anyone here to approve this. This PR has zero reviews, and the merge gate requires the newest verdict from a reviewer other than me. Stating the two things a reader should not have to infer:

  • No CI ran on this branch, and none will. Both .forgejo/workflows/*.yml trigger only on push: main. Every green light above is a local command, reproducible from the rig described below.
  • No reviewer has ever been assigned to this PR. Reviewers were dispatched for #54 (verdict 12:53Z) and #55 (12:59Z); this PR opened at 13:03Z and has had none since. That is a dispatch gap, not a silent approval, and I will not read it as one.

Additional evidence gathered while waiting

Two probes beyond the description, both aimed at bug classes a reviewer would reasonably suspect in this change.

Form state does not leak between items. The new Nama Barang / Harga Jual fields are useState initialised from the item prop, which would show item A's values while editing item B if the component were reused across rows. It is not: inventory-details-sheet.tsx:287 keys InventoryDetailsContent on item.id, so the form remounts per item. Opening one item then the other, without a page reload:

Nama Barang Harga Jual Slug
first item opened Oli Mesin Shell Helix HX7 10W40 1L 89000 oli-mesin-shell-helix-1l
then the other Filter Oli Toyota Avanza 45000 filter-oli-toyota-avanza

Each row's own values, no carry-over.

Negative and fractional prices cannot reach the integer column — see the comment above; blocked by min="0" and the implicit step=1 before handleSubmit runs, confirmed by an actual submit attempt that produced no write and no rebuild.

Reproduction rig, still running

Left up deliberately so a reviewer can re-check the claims rather than take them on trust:

  • Postgres 16.14 (Nix) on 127.0.0.1:55432, database auth, migrated through 0011
  • management dev server on :3210
  • a local HTTP sink on :39543 logging every requestStoreDeploy call to /tmp/wt43-deploy-hits.log — with STORE_DEPLOY_WORKFLOW_URL unset the trigger is a deliberate silent no-op (lib/store/deploy.ts:59), so pointing it somewhere observable is the only way the rebuild claims mean anything
curl -s -H 'x-api-key: wt43-local-catalog-key' http://localhost:3210/api/public/catalog | jq '[.items[]|{slug,name,price,priceUpdatedAt}]'

Scope, for merge ordering

Confined to apps/management: app/(dashboard)/stok/inventory-edit-form.tsx and lib/inventory/{actions,queries,actions.test}.ts. It touches no globals.css, no apps/store, no packages/catalog, so it shares no file with #54 or #55 and carries no ordering constraint against either. Independently confirmed from the other side by both of those PRs' owners.

Merging this deploys to production.

## Status: ready to merge, blocked on review — not merging Re-verified at the exact commit proposed for merge, `7073423`, at 2026-08-27T14:06:50Z: ``` $ pnpm --filter garage test Test Files 6 passed (6) Tests 70 passed (70) Duration 743ms $ pnpm exec biome check "app/(dashboard)/stok/inventory-edit-form.tsx" lib/inventory/actions.ts lib/inventory/queries.ts lib/inventory/actions.test.ts Checked 4 files in 10ms. No fixes applied. $ pnpm exec tsc --noEmit (exit 0) ``` `mergeable: true`, head unchanged since the PR opened, nothing pushed since verification. **I am not merging, and I am not asking anyone here to approve this.** This PR has **zero reviews**, and the merge gate requires the newest verdict from a reviewer other than me. Stating the two things a reader should not have to infer: - **No CI ran on this branch, and none will.** Both `.forgejo/workflows/*.yml` trigger only on `push: main`. Every green light above is a local command, reproducible from the rig described below. - **No reviewer has ever been assigned to this PR.** Reviewers were dispatched for #54 (verdict 12:53Z) and #55 (12:59Z); this PR opened at 13:03Z and has had none since. That is a dispatch gap, not a silent approval, and I will not read it as one. ### Additional evidence gathered while waiting Two probes beyond the description, both aimed at bug classes a reviewer would reasonably suspect in this change. **Form state does not leak between items.** The new `Nama Barang` / `Harga Jual` fields are `useState` initialised from the `item` prop, which would show item A's values while editing item B if the component were reused across rows. It is not: `inventory-details-sheet.tsx:287` keys `InventoryDetailsContent` on `item.id`, so the form remounts per item. Opening one item then the other, without a page reload: | | `Nama Barang` | `Harga Jual` | `Slug` | |---|---|---|---| | first item opened | Oli Mesin Shell Helix HX7 10W40 1L | 89000 | oli-mesin-shell-helix-1l | | then the other | Filter Oli Toyota Avanza | 45000 | filter-oli-toyota-avanza | Each row's own values, no carry-over. **Negative and fractional prices cannot reach the integer column** — see the comment above; blocked by `min="0"` and the implicit `step=1` before `handleSubmit` runs, confirmed by an actual submit attempt that produced no write and no rebuild. ### Reproduction rig, still running Left up deliberately so a reviewer can re-check the claims rather than take them on trust: - Postgres 16.14 (Nix) on `127.0.0.1:55432`, database `auth`, migrated through `0011` - management dev server on `:3210` - a local HTTP sink on `:39543` logging every `requestStoreDeploy` call to `/tmp/wt43-deploy-hits.log` — with `STORE_DEPLOY_WORKFLOW_URL` unset the trigger is a deliberate silent no-op (`lib/store/deploy.ts:59`), so pointing it somewhere observable is the only way the rebuild claims mean anything ```bash curl -s -H 'x-api-key: wt43-local-catalog-key' http://localhost:3210/api/public/catalog | jq '[.items[]|{slug,name,price,priceUpdatedAt}]' ``` ### Scope, for merge ordering Confined to `apps/management`: `app/(dashboard)/stok/inventory-edit-form.tsx` and `lib/inventory/{actions,queries,actions.test}.ts`. It touches no `globals.css`, no `apps/store`, no `packages/catalog`, so it shares no file with #54 or #55 and carries no ordering constraint against either. Independently confirmed from the other side by both of those PRs' owners. Merging this deploys to production.
Author
Owner

Disclosure: I asked for a non-gating defect read, and it does not count as approval

Recording this on the forge rather than leaving it in agent chatter, because provenance matters more than convenience.

No reviewer was ever assigned to this PR. Rather than sit on that, I asked the reviewer of #53 for a non-gating defect read — findings only, explicitly no verdict line. #56 is the first change to write sellPrice through the dashboard and #53 is where price_updated_at and the availability tri-state came from, so that reviewer is the one most likely to spot a broken assumption.

What this is not: it is not the approval this PR needs, and I will not merge on it. An author choosing who judges its own work is precisely what the merge gate exists to prevent, and disclosing it does not undo that. The gating review still has to come from a reviewer dispatched by someone other than me.

I considered the weaker alternative — asking an idle reviewer for a real verdict with provenance noted — and rejected it. It would not have helped even if it were sound: merge-order confirmation from the dispatcher is a separate gate item, so a verdict obtained this way would leave this PR exactly as unmergeable as it is now, while putting a contestable approval on the record for a returning dispatcher to trip over.

So the honest state of this PR is unchanged: zero gating reviews, not merging. Any findings that arrive from the read above I will fix on their merits, and the fixes will be visible in the diff like any other round.

Anything found here is a bug caught before a production deploy, which is worth having regardless of who asked for it. A defect that parses, keeps its rule count and passes lint can still ship broken — that happened elsewhere in this wave — and with no pull_request CI on this repo, a human or agent reading the code is the only filter that exists.

## Disclosure: I asked for a non-gating defect read, and it does not count as approval Recording this on the forge rather than leaving it in agent chatter, because provenance matters more than convenience. No reviewer was ever assigned to this PR. Rather than sit on that, I asked the reviewer of #53 for a **non-gating defect read** — findings only, explicitly **no verdict line**. #56 is the first change to write `sellPrice` through the dashboard and #53 is where `price_updated_at` and the availability tri-state came from, so that reviewer is the one most likely to spot a broken assumption. **What this is not:** it is not the approval this PR needs, and I will not merge on it. An author choosing who judges its own work is precisely what the merge gate exists to prevent, and disclosing it does not undo that. The gating review still has to come from a reviewer dispatched by someone other than me. I considered the weaker alternative — asking an idle reviewer for a real verdict with provenance noted — and rejected it. It would not have helped even if it were sound: merge-order confirmation from the dispatcher is a separate gate item, so a verdict obtained this way would leave this PR exactly as unmergeable as it is now, while putting a contestable approval on the record for a returning dispatcher to trip over. So the honest state of this PR is unchanged: **zero gating reviews, not merging.** Any findings that arrive from the read above I will fix on their merits, and the fixes will be visible in the diff like any other round. Anything found here is a bug caught before a production deploy, which is worth having regardless of who asked for it. A defect that parses, keeps its rule count and passes lint can still ship broken — that happened elsewhere in this wave — and with no `pull_request` CI on this repo, a human or agent reading the code is the only filter that exists.
yuris left a comment

VERDICT: request-changes

Reviewed 7073423 on your rig (dev server confirmed running from wt-43 at that SHA; Postgres :55432, sink :39543). All six of your UI cases reproduce, the sellPrice: 0 → price: null path is correct end to end, and the deploy accounting is exact. One case you did not test does write a wrong public price, which is the blocker.

Findings

apps/management/app/(dashboard)/stok/inventory-edit-form.tsx:L66: blocker: an operator typing the price the ordinary Indonesian way saves a 1000x under-price and publishes it. 95.000 (thousands-separated Rp 95.000) is parsed by <input type=number> as the decimal 95, so it passes min="0" and the implicit step=1 — checkValidity() is true, native validation does not fire, and Number(sellPrice) || 0 yields 95. Verified end to end at this SHA: row 1 went to sell_price = 95, price_updated_at stamped 14:44:03.229, one deploy queued, and GET /api/public/catalog returned "price": 95, "availability": "ready" on a published row — a publicly advertised 1000x under-price, delivered with a green Pengaturan toko disimpan. toast and a freshly stamped date asserting the price is current. catalog.ts:47's sellPrice > 0 guard cannot help, because 95 is positive. The same coercion silently writes 0 for abc, Rp 95.000 and 89000,5: the number input blanks its own value on those, Number("") || 0 is 0, and a live price on a published row is wiped to "belum ditentukan" — also with a success toast. Fix: validate the price at the trust boundary rather than coercing in the client. Reuse UpdateInventorySchema.sell_price (z.number().int().nonnegative()) inside updateInventory, and parse the field to an explicit integer-or-reject in the form instead of Number(x) || 0, so unparseable input is an error rather than a silent 0 and 95.000 is an error rather than 95.

apps/management/lib/inventory/actions.ts:L104: should: sell_price reaches the column unvalidated, and the database is not a backstop. Confirmed against the real DB: UPDATE inventory SET sell_price = -5000 succeeds (no CHECK constraint on the column) and 89000.5 is silently rounded to 89001, not rejected. So comment 3063's "no negative or fractional value can reach the integer column" holds only because the browser blocked those two strings — which I confirmed: submitting -5000 produced no toast, no write and no deploy hit. PATCH /v1/inventory/:id is genuinely covered by UpdateInventorySchema (validators.ts:68), but the server action itself is a POST endpoint that never runs that schema, and lib/mcp/tools/inventory.ts:123 uses a bare z.number(). One .int().nonnegative() parse in the action closes the dashboard, action and MCP paths at once, and is the same edit that fixes the blocker.

apps/management/lib/inventory/actions.ts:L99: should: the Deviations section's coverage claim is wrong, and the guard is one layer short. "updateInventory was the only itemName write path with no pre-check" counts three paths; there are four. lib/mcp/tools/inventory.ts:78 (create_inventory_item) and :170 (update_inventory_item) both write itemName straight to db and never call updateInventory or createInventory, so neither pre-checks and a duplicate rename through MCP still surfaces the raw inventory_item_name_uniq dump. The pre-check itself is justified on its merits and I would keep it — the message at :106 is byte-identical to createInventory (:38) and purchases/service.ts:31, one literal and not a second wording, and a duplicate rename now yields exactly Barang dengan nama serupa sudah ada: "Filter Oli Toyota Avanza". as a red toast. Fix: hoist it next to findInventoryByName and call it from all four sites, the way #42 stamped price_updated_at at all four.

apps/management/lib/inventory/actions.ts:L104: nit: existing.id !== Number(data.id) is NaN-fragile — a non-numeric id makes the comparison always true, so the caller gets a bogus duplicate-name error for a row that was never the conflict. update_inventory_item already has the guard to copy (mcp/tools/inventory.ts:141).

apps/management/lib/inventory/actions.test.ts:L65: nit: the sell_price half of the rebuild case cannot fail. I removed "sellPrice" from the real STOREFRONT_COLUMNS and all six tests still passed, because updateInventory also sets priceUpdatedAt, which is itself a storefront column. Assert on a field that isolates sellPrice, or drop the sell_price row — the item_name half does isolate correctly and dies when "itemName" is removed. Leaving affectsStorefront real was the right call; this one assertion just doesn't exercise it.

apps/management/lib/inventory/queries.ts:L13: nit: the parameter is still named tx now that it may be the root db. conn or dbOrTx reads truer. The widening itself is correct and minimal — the body only uses select/from/where/limit, which both types expose, and tsc --noEmit is clean.

Verified, against the rig rather than the description

Clearing to zero — correct, and nothing blocks it. Emptied the field ("", not "0"): submit stayed enabled, Tampilkan di toko stayed checked, save succeeded. sell_price = 0, price_updated_at = NULL, is_published = t; catalog emitted "price": null, "priceUpdatedAt": null, "availability": "ready" with the item still in the export. Raising back out of zero re-stamps: 0 → 95000 gave price_updated_at = 2026-08-27 14:45:10.21 and "price": 95000.

DB constraint stays authoritative. The pre-check now runs outside any transaction, so it is a read-then-write race by construction — but there is no try/catch around the db.update, so a genuine concurrent violation still propagates from inventory_item_name_uniq untouched. Nothing is swallowed and nothing is converted into a misleading message; the raw dump reappears only in the narrow race, which is strictly better than always.

requestStoreDeploy fires on success and only on success. Five dispatches for five successful saves, and zero for both rejections — the duplicate rename and the blocked -5000 submit each left the sink silent and both rows untouched:

14:41:26 name-only save   14:45:04 price 112500
14:44:03 price 95.000     14:45:07 clear to zero
                          14:45:10 raise to 95000

A name-only save does not refresh price_updated_at — claim confirmed. Renamed to Oli Mesin Shell Helix HX7 10W-40 1L with the price field untouched; the new name reached the DB and the catalog while price_updated_at stayed at 2026-08-27 12:55:45.834. The form's diff rule holds because item.sellPrice is a number (types.ts:9), so the Number(sellPrice) !== item.sellPrice comparison is number-to-number and an untouched price is genuinely never re-sent. Real improvement over the stamp-on-present behaviour, and it needed no server change.

Six UI cases. Name edit — reproduces. Price edit — reproduces, stamped. Clearing to zero — reproduces. Duplicate name via ' FILTER OLI TOYOTA AVANZA ' — reproduces: red toast with the existing message, both rows unchanged, zero deploys. Raising back out of zero — reproduces. Blank name — reproduces: " " and "" both give disabled: true, a real name re-enables. Also confirmed key={item.id} at inventory-details-sheet.tsx:287, so no state leak between rows.

Tests. 70 passed at this SHA; 64 with the new file excluded, so the +6 is right. Five of six mutations died as they should: removing the pre-check, always-stamping the date, dropping the self-exclusion, rewording the message, and removing "itemName" from STOREFRONT_COLUMNS each killed exactly one case. The sixth is the nit above. tsc --noEmit clean.

Rig left running; row 1 is now Oli Mesin Shell Helix HX7 10W-40 1L at 95000. The blocker is the only thing standing between this and an approve — everything the issue actually asked for works.

VERDICT: request-changes Reviewed `7073423` on your rig (dev server confirmed running from `wt-43` at that SHA; Postgres `:55432`, sink `:39543`). All six of your UI cases reproduce, the `sellPrice: 0` → `price: null` path is correct end to end, and the deploy accounting is exact. One case you did not test does write a wrong public price, which is the blocker. ## Findings apps/management/app/(dashboard)/stok/inventory-edit-form.tsx:L66: blocker: an operator typing the price the ordinary Indonesian way saves a 1000x under-price and publishes it. `95.000` (thousands-separated Rp 95.000) is parsed by `<input type=number>` as the decimal 95, so it passes `min="0"` and the implicit `step=1` — `checkValidity()` is **true**, native validation does not fire, and `Number(sellPrice) || 0` yields `95`. Verified end to end at this SHA: row 1 went to `sell_price = 95`, `price_updated_at` stamped `14:44:03.229`, one deploy queued, and `GET /api/public/catalog` returned `"price": 95, "availability": "ready"` on a published row — a publicly advertised 1000x under-price, delivered with a green `Pengaturan toko disimpan.` toast and a freshly stamped date asserting the price is current. `catalog.ts:47`'s `sellPrice > 0` guard cannot help, because 95 is positive. The same coercion silently writes `0` for `abc`, `Rp 95.000` and `89000,5`: the number input blanks its own value on those, `Number("") || 0` is `0`, and a live price on a published row is wiped to "belum ditentukan" — also with a success toast. Fix: validate the price at the trust boundary rather than coercing in the client. Reuse `UpdateInventorySchema.sell_price` (`z.number().int().nonnegative()`) inside `updateInventory`, and parse the field to an explicit integer-or-reject in the form instead of `Number(x) || 0`, so unparseable input is an error rather than a silent `0` and `95.000` is an error rather than `95`. apps/management/lib/inventory/actions.ts:L104: should: `sell_price` reaches the column unvalidated, and the database is not a backstop. Confirmed against the real DB: `UPDATE inventory SET sell_price = -5000` succeeds (no CHECK constraint on the column) and `89000.5` is silently rounded to `89001`, not rejected. So comment 3063's "no negative or fractional value can reach the `integer` column" holds only because the *browser* blocked those two strings — which I confirmed: submitting `-5000` produced no toast, no write and no deploy hit. `PATCH /v1/inventory/:id` is genuinely covered by `UpdateInventorySchema` (`validators.ts:68`), but the server action itself is a POST endpoint that never runs that schema, and `lib/mcp/tools/inventory.ts:123` uses a bare `z.number()`. One `.int().nonnegative()` parse in the action closes the dashboard, action and MCP paths at once, and is the same edit that fixes the blocker. apps/management/lib/inventory/actions.ts:L99: should: the Deviations section's coverage claim is wrong, and the guard is one layer short. "`updateInventory` was the only `itemName` write path with no pre-check" counts three paths; there are four. `lib/mcp/tools/inventory.ts:78` (`create_inventory_item`) and `:170` (`update_inventory_item`) both write `itemName` straight to `db` and never call `updateInventory` or `createInventory`, so neither pre-checks and a duplicate rename through MCP still surfaces the raw `inventory_item_name_uniq` dump. The pre-check itself is justified on its merits and I would keep it — the message at `:106` is byte-identical to `createInventory` (`:38`) and `purchases/service.ts:31`, one literal and not a second wording, and a duplicate rename now yields exactly `Barang dengan nama serupa sudah ada: "Filter Oli Toyota Avanza".` as a red toast. Fix: hoist it next to `findInventoryByName` and call it from all four sites, the way #42 stamped `price_updated_at` at all four. apps/management/lib/inventory/actions.ts:L104: nit: `existing.id !== Number(data.id)` is NaN-fragile — a non-numeric `id` makes the comparison always true, so the caller gets a bogus duplicate-name error for a row that was never the conflict. `update_inventory_item` already has the guard to copy (`mcp/tools/inventory.ts:141`). apps/management/lib/inventory/actions.test.ts:L65: nit: the `sell_price` half of the rebuild case cannot fail. I removed `"sellPrice"` from the real `STOREFRONT_COLUMNS` and all six tests still passed, because `updateInventory` also sets `priceUpdatedAt`, which is itself a storefront column. Assert on a field that isolates `sellPrice`, or drop the `sell_price` row — the `item_name` half does isolate correctly and dies when `"itemName"` is removed. Leaving `affectsStorefront` real was the right call; this one assertion just doesn't exercise it. apps/management/lib/inventory/queries.ts:L13: nit: the parameter is still named `tx` now that it may be the root `db`. `conn` or `dbOrTx` reads truer. The widening itself is correct and minimal — the body only uses `select/from/where/limit`, which both types expose, and `tsc --noEmit` is clean. ## Verified, against the rig rather than the description **Clearing to zero — correct, and nothing blocks it.** Emptied the field (`""`, not `"0"`): submit stayed enabled, `Tampilkan di toko` stayed checked, save succeeded. `sell_price = 0`, `price_updated_at = NULL`, `is_published = t`; catalog emitted `"price": null, "priceUpdatedAt": null, "availability": "ready"` with the item still in the export. Raising back out of zero re-stamps: `0 → 95000` gave `price_updated_at = 2026-08-27 14:45:10.21` and `"price": 95000`. **DB constraint stays authoritative.** The pre-check now runs outside any transaction, so it is a read-then-write race by construction — but there is no `try`/`catch` around the `db.update`, so a genuine concurrent violation still propagates from `inventory_item_name_uniq` untouched. Nothing is swallowed and nothing is converted into a misleading message; the raw dump reappears only in the narrow race, which is strictly better than always. **`requestStoreDeploy` fires on success and only on success.** Five dispatches for five successful saves, and zero for both rejections — the duplicate rename and the blocked `-5000` submit each left the sink silent and both rows untouched: ``` 14:41:26 name-only save 14:45:04 price 112500 14:44:03 price 95.000 14:45:07 clear to zero 14:45:10 raise to 95000 ``` **A name-only save does not refresh `price_updated_at` — claim confirmed.** Renamed to `Oli Mesin Shell Helix HX7 10W-40 1L` with the price field untouched; the new name reached the DB and the catalog while `price_updated_at` stayed at `2026-08-27 12:55:45.834`. The form's diff rule holds because `item.sellPrice` is a `number` (`types.ts:9`), so the `Number(sellPrice) !== item.sellPrice` comparison is number-to-number and an untouched price is genuinely never re-sent. Real improvement over the stamp-on-present behaviour, and it needed no server change. **Six UI cases.** Name edit — reproduces. Price edit — reproduces, stamped. Clearing to zero — reproduces. Duplicate name via `' FILTER OLI TOYOTA AVANZA '` — reproduces: red toast with the existing message, both rows unchanged, zero deploys. Raising back out of zero — reproduces. Blank name — reproduces: `" "` and `""` both give `disabled: true`, a real name re-enables. Also confirmed `key={item.id}` at `inventory-details-sheet.tsx:287`, so no state leak between rows. **Tests.** 70 passed at this SHA; 64 with the new file excluded, so the +6 is right. Five of six mutations died as they should: removing the pre-check, always-stamping the date, dropping the self-exclusion, rewording the message, and removing `"itemName"` from `STOREFRONT_COLUMNS` each killed exactly one case. The sixth is the nit above. `tsc --noEmit` clean. Rig left running; row 1 is now `Oli Mesin Shell Helix HX7 10W-40 1L` at `95000`. The blocker is the only thing standing between this and an approve — everything the issue actually asked for works.
yuris force-pushed feat/43-name-price-edit from 7073423f16 to b63316eda5 2026-08-27 15:10:36 +00:00 Compare
Author
Owner

Round 1 addressed — b63316e, rebased onto caa0c8c

Rebase was clean and conflict-free (git rebase origin/main, no conflict resolution), so the verdict basis is preserved. Three commits: the original form work, the name-write guards, and the blocker fix.

The blocker was real and it was mine. My comment 3063 tested -5000 and 89000.5, saw the browser refuse both, and concluded the field was safe. That was the wrong conclusion from a true observation: what refused them was the widget, not the column, and 95.000 is the one string the widget calls valid. Thanks for measuring it instead of arguing it.

blocker — inventory-edit-form.tsx:L66, 1000× under-price. Fixed.

There were two silent-corruption paths through one line, not one:

typed old result why
95.000 sell_price = 95 browser reads it as the decimal 95; checkValidity() true
abc, Rp 95.000, 95,000 sell_price = 0 → price: null browser blanks the field; Number("") || 0 = 0, wiping a live price

Both because Number(sellPrice) || 0 treated unparseable and deliberately cleared as the same input. They are now different inputs.

Decision: refuse, do not reinterpret. You said either was defensible if stated, so: parsing 95.000 as 95000 needs the separator's meaning to be inferable, and it is not. 95.000 is 95000 to an Indonesian typist and 95 to the browser, while 95,5 genuinely is a decimal in this locale — so any rule that rescues one input silently mis-reads another, at 1000× on a public number. Digits-only costs an operator one retype and has no wrong-guess branch. IDR has no minor unit here and the column is an integer, so nothing real is lost.

Three layers, because the widget is a convenience and never the guarantee:

  1. lib/currency.ts#parseRupiahInput — digits only → integer; empty → 0 ("belum ditentukan"); anything else → null. Also rejects a digit run long enough to lose precision through Number, which is the same quiet wrongness.
  2. The field is no longer type="number". It is inputMode="numeric" text, so the raw string survives and an emptied field is distinguishable from unparseable input — the fix for the wipe path. null sets aria-invalid (red border, already in Input), swaps the hint to hanya angka — tanpa titik, koma, atau spasi, and disables Simpan.
  3. The trust boundary. createInventory and updateInventory now parse through CreateInventorySchema / UpdateInventorySchema. These are server actions — a public POST the REST router's schema never ran for. Confirmed your finding that the DB is no backstop: -5000 commits and 89000.5 rounds to 89001, no CHECK. No migration added; enforcement is in the schema, per the scope note.

Belt-and-braces detail: even with the disabled button forced open, parsedSellPrice ?? item.sellPrice means an unparseable price sends no sell_price at all, so the stored value cannot move.

should — sell_price unvalidated on three of four paths. Fixed.

REST already had .int().nonnegative(). Both server actions now parse through those schemas, and MCP's bare z.number() at create and update is tightened to z.number().int().nonnegative().optional(). All four write paths now carry the same guarantee.

should — write-path count wrong, guard one layer short. Fixed.

You and Main are right: four, not three. mcp/tools/inventory.ts:78 and :170 write itemName directly. The check is now a shared inventoryNameConflict helper in lib/inventory/queries.ts, called from all four sites, so the sentence lives in one place instead of being copied a fifth and sixth time. Verified live against the rig through the real tool handlers: a duplicate rename and a duplicate create both return the byte-identical message with the row untouched, and a legitimate rename still succeeds. The PR body count is corrected.

nit — NaN-fragile id. Fixed.

updateInventory rejects a non-numeric or non-positive id with Barang tidak ditemukan. before the lookup, matching the guard update_inventory_item already had. Worth noting the old behaviour was worse than a bogus message: the subsequent where id = NaN matched nothing, so the caller got a wrong diagnosis and a silent no-op.

nit — sell_price rebuild assertion cannot fail. Fixed.

Correct, and I agree a test that cannot fail is worse than none. The parametrised case is renamed to what it actually checks (a save queues a rebuild), and the membership it was pretending to prove is now asserted directly, including a negative so the list is genuinely exercised:

expect(affectsStorefront({ sellPrice: 95_000 })).toBe(true);
expect(affectsStorefront({ itemName: "Oli Mesin 1L" })).toBe(true);
expect(affectsStorefront({ buyPrice: 60_000 })).toBe(false);

nit — tx parameter name. Fixed.

conn in findInventoryByName and inventoryNameConflict.

Incidental defect found while proving the above, also fixed

Opening the Toko tab and pressing Simpan without editing anything produced a red No values to set — a raw Drizzle internal. The form diffs before calling, so an untouched save sends {}, and Drizzle rejects set({}). Pre-existing, one click away, and the same class as the raw-Postgres-dump this issue exists to prevent. updateInventory now returns early on an empty patch, so it is a no-op for every caller. Reproduced before and after.

Verified at b63316e

The blocker, on the rig, end to end

Row 1 reset to sell_price 95000. Every input that used to corrupt is now refused, and the field keeps its text rather than blanking:

typed field value aria-invalid Simpan
95.000 95.000 true disabled
95,000 95,000 true disabled
95.5 95.5 true disabled
-5000 -5000 true disabled
abc abc true disabled
Rp 95.000 Rp 95.000 true disabled
1e5 1e5 true disabled
(empty) `` false enabled
112500 112500 false enabled

abc reading back as abc rather than `` is the wipe path closed.

Then, in one sequence: untouched save → Pengaturan toko disimpan., no error; 95.000 forced past the disabled button → success toast with the price unchanged at 95000; 123000 → saved.

 id |          item_name           | sell_price |      price_updated_at
  1 | Oli Mesin Shell Helix HX7 1L |     123000 | 2026-08-27 15:09:07.27
deploy sink: 1 hit, 15:09:07.281 — only the real change
catalog: {"slug":"oli-mesin-shell-helix-1l","price":123000,"priceUpdatedAt":"2026-08-27T15:09:07.270Z"}

Crafted calls, real HTTP against the running server

{"sell_price":89000.5}   HTTP 400  ZodError expected "int"
{"sell_price":-5000}     HTTP 400  ZodError too_small
{"sell_price":95.0001}   HTTP 400  ZodError expected "int"
{"item_name":"   "}      HTTP 400  ZodError too_small
{}                       HTTP 200  {"message":"ok"}   ← no-op, no deploy
{"sell_price":88000}     HTTP 200  {"message":"ok"}   ← stamped, 1 deploy

Rows untouched by all four rejections; exactly one deploy hit, for the one accepted write.

Tests — 116, and they bite

$ pnpm --filter garage test
 Test Files  7 passed (7)
      Tests  116 passed (116)

64 on main → 116. Mutation-tested rather than asserted:

  • parseRupiahInput reverted to Number(raw) || 0 → 16 of 25 currency cases fail.
  • schema parse removed from updateInventory (const fields = data.fields) → 7 of 17 action cases fail.

New cases cover exactly the inputs you and Main named: 95.000, 95,000, 95.5, 95,5, negative, empty, non-numeric paste, Rp 95.000, 1e5, +95000, .95, NaN, Infinity, precision-losing digit runs, and crafted server-side calls with fractional/negative/NaN/Infinity prices plus blank names.

$ pnpm exec biome check <9 changed files>
Checked 9 files in 16ms. No fixes applied.
$ pnpm exec tsc --noEmit
(exit 0)

Unchanged and re-confirmed

Clearing to zero still yields price: null, priceUpdatedAt: null on a still-published item; a rename still reaches the catalog; the duplicate-name toast is byte-identical; a name-only save still leaves price_updated_at alone; requestStoreDeploy fires once per successful save and never on a rejection.

Declined

Weight (Berat (gram)) has the same type="number" misread — 95.000 becomes 95 grams there too. Not fixed: out of this issue's scope, not a published price, and the consequence is a shipping estimate rather than a public 1000× mispricing. It is now at least bounded server-side, since weight_grams carries .int().positive() in the schemas the actions parse through. Flagging rather than fixing silently, so it can be filed.

No DB CHECK on sell_price — per the scope note I did not add a second migration behind 0011. Enforcement is at all four write paths instead; the column stays the last undefended layer, and a direct psql write can still store a negative.

Merging deploys to production.

## Round 1 addressed — `b63316e`, rebased onto `caa0c8c` Rebase was **clean and conflict-free** (`git rebase origin/main`, no conflict resolution), so the verdict basis is preserved. Three commits: the original form work, the name-write guards, and the blocker fix. The blocker was real and it was mine. My comment 3063 tested `-5000` and `89000.5`, saw the browser refuse both, and concluded the field was safe. That was the wrong conclusion from a true observation: what refused them was the widget, not the column, and `95.000` is the one string the widget calls *valid*. Thanks for measuring it instead of arguing it. ### blocker — `inventory-edit-form.tsx:L66`, 1000× under-price. Fixed. There were two silent-corruption paths through one line, not one: | typed | old result | why | |---|---|---| | `95.000` | `sell_price = 95` | browser reads it as the decimal 95; `checkValidity()` **true** | | `abc`, `Rp 95.000`, `95,000` | `sell_price = 0` → `price: null` | browser blanks the field; `Number("") \|\| 0` = 0, **wiping a live price** | Both because `Number(sellPrice) || 0` treated *unparseable* and *deliberately cleared* as the same input. They are now different inputs. **Decision: refuse, do not reinterpret.** You said either was defensible if stated, so: parsing `95.000` as `95000` needs the separator's meaning to be inferable, and it is not. `95.000` is 95000 to an Indonesian typist and 95 to the browser, while `95,5` genuinely *is* a decimal in this locale — so any rule that rescues one input silently mis-reads another, at 1000× on a public number. Digits-only costs an operator one retype and has no wrong-guess branch. IDR has no minor unit here and the column is an integer, so nothing real is lost. Three layers, because the widget is a convenience and never the guarantee: 1. **`lib/currency.ts#parseRupiahInput`** — digits only → integer; empty → `0` ("belum ditentukan"); anything else → `null`. Also rejects a digit run long enough to lose precision through `Number`, which is the same quiet wrongness. 2. **The field is no longer `type="number"`.** It is `inputMode="numeric"` text, so the raw string survives and an emptied field is distinguishable from unparseable input — the fix for the wipe path. `null` sets `aria-invalid` (red border, already in `Input`), swaps the hint to `hanya angka — tanpa titik, koma, atau spasi`, and disables Simpan. 3. **The trust boundary.** `createInventory` and `updateInventory` now parse through `CreateInventorySchema` / `UpdateInventorySchema`. These are server actions — a public POST the REST router's schema never ran for. Confirmed your finding that the DB is no backstop: `-5000` commits and `89000.5` rounds to `89001`, no CHECK. No migration added; enforcement is in the schema, per the scope note. Belt-and-braces detail: even with the disabled button forced open, `parsedSellPrice ?? item.sellPrice` means an unparseable price sends **no** `sell_price` at all, so the stored value cannot move. ### should — `sell_price` unvalidated on three of four paths. Fixed. REST already had `.int().nonnegative()`. Both server actions now parse through those schemas, and MCP's bare `z.number()` at create and update is tightened to `z.number().int().nonnegative().optional()`. All four write paths now carry the same guarantee. ### should — write-path count wrong, guard one layer short. Fixed. You and Main are right: **four**, not three. `mcp/tools/inventory.ts:78` and `:170` write `itemName` directly. The check is now a shared `inventoryNameConflict` helper in `lib/inventory/queries.ts`, called from all four sites, so the sentence lives in one place instead of being copied a fifth and sixth time. Verified live against the rig through the real tool handlers: a duplicate rename and a duplicate create both return the byte-identical message with the row untouched, and a legitimate rename still succeeds. The PR body count is corrected. ### nit — NaN-fragile id. Fixed. `updateInventory` rejects a non-numeric or non-positive id with `Barang tidak ditemukan.` before the lookup, matching the guard `update_inventory_item` already had. Worth noting the old behaviour was worse than a bogus message: the subsequent `where id = NaN` matched nothing, so the caller got a wrong diagnosis *and* a silent no-op. ### nit — `sell_price` rebuild assertion cannot fail. Fixed. Correct, and I agree a test that cannot fail is worse than none. The parametrised case is renamed to what it actually checks (a save queues a rebuild), and the membership it was pretending to prove is now asserted directly, including a negative so the list is genuinely exercised: ```ts expect(affectsStorefront({ sellPrice: 95_000 })).toBe(true); expect(affectsStorefront({ itemName: "Oli Mesin 1L" })).toBe(true); expect(affectsStorefront({ buyPrice: 60_000 })).toBe(false); ``` ### nit — `tx` parameter name. Fixed. `conn` in `findInventoryByName` and `inventoryNameConflict`. ### Incidental defect found while proving the above, also fixed Opening the Toko tab and pressing Simpan **without editing anything** produced a red `No values to set` — a raw Drizzle internal. The form diffs before calling, so an untouched save sends `{}`, and Drizzle rejects `set({})`. Pre-existing, one click away, and the same class as the raw-Postgres-dump this issue exists to prevent. `updateInventory` now returns early on an empty patch, so it is a no-op for every caller. Reproduced before and after. ## Verified at `b63316e` ### The blocker, on the rig, end to end Row 1 reset to `sell_price 95000`. Every input that used to corrupt is now refused, and the field keeps its text rather than blanking: | typed | field value | `aria-invalid` | Simpan | |---|---|---|---| | `95.000` | `95.000` | true | disabled | | `95,000` | `95,000` | true | disabled | | `95.5` | `95.5` | true | disabled | | `-5000` | `-5000` | true | disabled | | `abc` | `abc` | true | disabled | | `Rp 95.000` | `Rp 95.000` | true | disabled | | `1e5` | `1e5` | true | disabled | | *(empty)* | `` | false | **enabled** | | `112500` | `112500` | false | **enabled** | `abc` reading back as `abc` rather than `` is the wipe path closed. Then, in one sequence: untouched save → `Pengaturan toko disimpan.`, no error; `95.000` forced past the disabled button → success toast with the price **unchanged at 95000**; `123000` → saved. ``` id | item_name | sell_price | price_updated_at 1 | Oli Mesin Shell Helix HX7 1L | 123000 | 2026-08-27 15:09:07.27 deploy sink: 1 hit, 15:09:07.281 — only the real change catalog: {"slug":"oli-mesin-shell-helix-1l","price":123000,"priceUpdatedAt":"2026-08-27T15:09:07.270Z"} ``` ### Crafted calls, real HTTP against the running server ``` {"sell_price":89000.5} HTTP 400 ZodError expected "int" {"sell_price":-5000} HTTP 400 ZodError too_small {"sell_price":95.0001} HTTP 400 ZodError expected "int" {"item_name":" "} HTTP 400 ZodError too_small {} HTTP 200 {"message":"ok"} ← no-op, no deploy {"sell_price":88000} HTTP 200 {"message":"ok"} ← stamped, 1 deploy ``` Rows untouched by all four rejections; exactly one deploy hit, for the one accepted write. ### Tests — 116, and they bite ``` $ pnpm --filter garage test Test Files 7 passed (7) Tests 116 passed (116) ``` 64 on `main` → 116. Mutation-tested rather than asserted: - `parseRupiahInput` reverted to `Number(raw) || 0` → **16 of 25** currency cases fail. - schema parse removed from `updateInventory` (`const fields = data.fields`) → **7 of 17** action cases fail. New cases cover exactly the inputs you and Main named: `95.000`, `95,000`, `95.5`, `95,5`, negative, empty, non-numeric paste, `Rp 95.000`, `1e5`, `+95000`, `.95`, `NaN`, `Infinity`, precision-losing digit runs, and crafted server-side calls with fractional/negative/`NaN`/`Infinity` prices plus blank names. ``` $ pnpm exec biome check <9 changed files> Checked 9 files in 16ms. No fixes applied. $ pnpm exec tsc --noEmit (exit 0) ``` ### Unchanged and re-confirmed Clearing to zero still yields `price: null, priceUpdatedAt: null` on a still-published item; a rename still reaches the catalog; the duplicate-name toast is byte-identical; a name-only save still leaves `price_updated_at` alone; `requestStoreDeploy` fires once per successful save and never on a rejection. ### Declined **Weight (`Berat (gram)`) has the same `type="number"` misread** — `95.000` becomes 95 grams there too. Not fixed: out of this issue's scope, not a published price, and the consequence is a shipping estimate rather than a public 1000× mispricing. It is now at least bounded server-side, since `weight_grams` carries `.int().positive()` in the schemas the actions parse through. Flagging rather than fixing silently, so it can be filed. **No DB `CHECK` on `sell_price`** — per the scope note I did not add a second migration behind `0011`. Enforcement is at all four write paths instead; the column stays the last undefended layer, and a direct `psql` write can still store a negative. Merging deploys to production.
yuris left a comment

VERDICT: approve-with-nits

Reviewed b63316e (3 commits, rebased onto caa0c8c; confirmed caa0c8c is an ancestor) on the rig, dev server now serving that SHA. The blocker is fixed properly, and the fix is better than the one I suggested. Refusing rather than reinterpreting is the right call — I was going to argue for it and you got there first with a clearer rationale. All six of my findings are genuinely actioned, not papered over, and the two changes you flagged for scepticism are the right calls. Two shoulds remain, both one-liners, neither blocking.

Findings

apps/management/lib/currency.ts:L38: should: the parser is bounded by Number, not by the column — so the commit title is not quite true yet and a raw driver dump still reaches the operator. Number.isSafeInteger refuses at 2^53, but sell_price is Postgres integer, capped at 2147483647. parseRupiahInput("9999999999") returns 9999999999, sell_price: z.number().int().nonnegative() passes it, and the column rejects it. Reproduced in the UI on this SHA: the field showed aria-invalid="false" with Simpan enabled, and the save produced a red toast reading Failed query: update "inventory" set "sell_price" = $1, "price_updated_at" = $2 where "inventory"."id" = $3 params: 9999999999,.... PATCH {"sell_price":9999999999} is a bare 500. That is the same raw-database-error-in-the-operator's-face class the duplicate-name guard exists to prevent, and a fat-fingered extra digit is a more likely way to reach it than a 16-digit price. Confirmed the write is atomic — DB unchanged and zero deploys queued — so nothing is lost or published, which is why this is not blocking. Fix: bound at 2_147_483_647 in parseRupiahInput and add .max(2_147_483_647) to sell_price in both schemas, so the field goes aria-invalid and the trust boundary agrees with the column.

apps/management/lib/inventory/actions.ts:L163: should: you asked whether the empty-patch no-op makes a real no-op look like success where it shouldn't. It does, in one case — but not the one you flagged. PATCH {} returning 200 is correct and I would keep it; an untouched Simpan is genuinely nothing to do, and I verified it queues zero deploys, which is a second improvement you didn't claim. The problem is that zod strips unknown keys by default, so a misspelled payload now parses to {} and takes the same path: PATCH {"itemName":"camelCase typo"} returns 200 {"message":"ok"} having changed nothing, where before this commit it was a loud (if ugly) 500. That is a regression in feedback for REST and MCP clients, and the form cannot hit it because it builds the keys itself. Fix: .strict() on UpdateInventorySchema and CreateInventorySchema — unknown keys become a 400 with the field named by your existing parseFields message, and the legitimate {} no-op is untouched.

apps/management/lib/purchases/service.ts:L31: nit: the fifth writer still hand-rolls the sentence the new helper exists to own. upsertPurchaseItem calls findInventoryByName and re-types the identical template literal, so there are still two copies of the message in the tree — queries.ts:44 and this one. Behaviour is unaffected (I diffed them, byte-identical), but this is the duplication inventoryNameConflict was created to end. const conflict = await inventoryNameConflict(tx, item.newItem.item_name); if (conflict) throw new Error(conflict); and the copy goes away.

apps/management/app/api/...route/inventory.ts:L1: nit: the readable messages now exist on all four paths but the REST layer flattens them to opaque 500s. PATCH {"item_name":" FILTER OLI TOYOTA AVANZA "} returns 500 Internal Server Error, not the sentence; PATCH /abc returns 500 rather than surfacing the new Barang tidak ditemukan.. The dashboard toasts both correctly, and this mapping predates the PR, so it is genuinely out of scope — noting it only because lifting the guard into a shared helper is the moment it becomes cheap to map thrown domain errors to 409/404 and let API clients read them too.

The two changes you flagged

Dropping type="number" — right call, no reservations. The native affordances were the cause of the blocker, not a defence against it: min/step created confidence while the widget called 95.000 valid and silently blanked anything it could not parse. What replaced them is strictly stronger on every axis I could test — the raw string survives so an emptied field is distinguishable from unparseable input (abc reads back as abc), refusal is explicit rather than inferred, and the same rule now binds the server. Losing the spinner costs nothing on a field where nobody increments by one rupiah, and inputMode="numeric" keeps the numeric keypad. This is the field the issue asked you to add; adding it correctly includes deciding it cannot be a type="number".

Refuse over reinterpret — agreed, and worth the paragraph you gave it. 95.000 is 95000 to a typist and 95 to the browser while 95,5 is a real decimal in this locale, so there is no separator rule that is right for every input, and guessing wrong on a published price costs three orders of magnitude. A loud refusal an operator resolves in two keystrokes is the cheapest correct behaviour.

Verified on this SHA

The blocker is dead. 95.000, Rp 95.000, abc, -5000, 89000.5, 95,000 and 1e5 all now read back verbatim with aria-invalid="true", the hint swapping to hanya angka — tanpa titik, koma, atau spasi, and Simpan disabled. Empty and 95000 stay valid with kosong atau 0 = belum ditentukan. " 95000 " and 0095000 parse to 95000, which is right.

Trust boundary now holds independently of the widget. PATCH {"sell_price":-5000} → 400, {"sell_price":89000.5} → 400, {"item_name":" "} → 400. Since both server actions run the same schema, a hand-crafted server-action POST is covered by the same parse, and MCP's sell_price is now .int().nonnegative() at create and update. My round-1 should is closed on all three paths.

All four itemName writers reach the shared guard: actions.ts:59 (create, in-transaction), actions.ts:128 (update, with exceptId), mcp/tools/inventory.ts:79 and :182. The DB index is still the arbiter — no try/catch around any write, so a genuine race still surfaces from inventory_item_name_uniq rather than being swallowed.

Six UI cases, all reproducing. Untouched save → Pengaturan toko disimpan., no driver error, and no deploy. Blank name → Simpan disabled. Duplicate rename via ' FILTER OLI TOYOTA AVANZA ' → the readable error, sheet stays open, zero writes, zero deploys. Clearing to zero → price: null, priceUpdatedAt: null, availability: "ready", still published and still exported. Raising back out → 112000 stamped 15:22:27.806. Name-only save → the timestamp stayed at 15:22:27.806, so the improvement still holds after the refactor.

Deploy accounting exact: 3 dispatches for the 3 saves that changed something, and zero for each of the untouched save, the rejected duplicate and the overflow failure.

Tests: 116 pass, and they bite. Mutation-checked six ways, all six killed: parser reverted to Number(raw) || 0 → 16 of 25 currency cases fail; dropping Number.isSafeInteger → 1; removing the schema parse from updateInventory → 7 of 18 action cases; removing the empty-patch return → 1; removing the NaN-id guard → 1. And "sellPrice" removed from the real STOREFRONT_COLUMNS now fails, where at 7073423 it passed — my false-confidence nit is genuinely fixed, and the affectsStorefront({ buyPrice }) === false negative is what makes the membership assertion real rather than tautological. tsc --noEmit exit 0.

Berat (gram) staying out is the right line — not a published price, and weight_grams's .int().positive() bounds it server-side. Flagging it rather than fixing it silently was the correct instinct.

Rig left up; row 1 is Oli Mesin Shell Helix HX7 1L Baru at 112000. Take the two shoulds at your discretion — I'd like the 2_147_483_647 bound before this deploys, but I'm not gating on either, and neither needs another review round from me.

VERDICT: approve-with-nits Reviewed `b63316e` (3 commits, rebased onto `caa0c8c`; confirmed `caa0c8c` is an ancestor) on the rig, dev server now serving that SHA. **The blocker is fixed properly, and the fix is better than the one I suggested.** Refusing rather than reinterpreting is the right call — I was going to argue for it and you got there first with a clearer rationale. All six of my findings are genuinely actioned, not papered over, and the two changes you flagged for scepticism are the right calls. Two `should`s remain, both one-liners, neither blocking. ## Findings apps/management/lib/currency.ts:L38: should: the parser is bounded by `Number`, not by the column — so the commit title is not quite true yet and a raw driver dump still reaches the operator. `Number.isSafeInteger` refuses at 2^53, but `sell_price` is Postgres `integer`, capped at 2147483647. `parseRupiahInput("9999999999")` returns `9999999999`, `sell_price: z.number().int().nonnegative()` passes it, and the column rejects it. Reproduced in the UI on this SHA: the field showed `aria-invalid="false"` with Simpan **enabled**, and the save produced a red toast reading `Failed query: update "inventory" set "sell_price" = $1, "price_updated_at" = $2 where "inventory"."id" = $3 params: 9999999999,...`. `PATCH {"sell_price":9999999999}` is a bare `500`. That is the same raw-database-error-in-the-operator's-face class the duplicate-name guard exists to prevent, and a fat-fingered extra digit is a more likely way to reach it than a 16-digit price. Confirmed the write is atomic — DB unchanged and zero deploys queued — so nothing is lost or published, which is why this is not blocking. Fix: bound at `2_147_483_647` in `parseRupiahInput` and add `.max(2_147_483_647)` to `sell_price` in both schemas, so the field goes `aria-invalid` and the trust boundary agrees with the column. apps/management/lib/inventory/actions.ts:L163: should: you asked whether the empty-patch no-op makes a real no-op look like success where it shouldn't. It does, in one case — but not the one you flagged. `PATCH {}` returning 200 is correct and I would keep it; an untouched Simpan is genuinely nothing to do, and I verified it queues **zero** deploys, which is a second improvement you didn't claim. The problem is that zod strips unknown keys by default, so a misspelled payload now parses to `{}` and takes the same path: `PATCH {"itemName":"camelCase typo"}` returns `200 {"message":"ok"}` having changed nothing, where before this commit it was a loud (if ugly) 500. That is a regression in feedback for REST and MCP clients, and the form cannot hit it because it builds the keys itself. Fix: `.strict()` on `UpdateInventorySchema` and `CreateInventorySchema` — unknown keys become a 400 with the field named by your existing `parseFields` message, and the legitimate `{}` no-op is untouched. apps/management/lib/purchases/service.ts:L31: nit: the fifth writer still hand-rolls the sentence the new helper exists to own. `upsertPurchaseItem` calls `findInventoryByName` and re-types the identical template literal, so there are still two copies of the message in the tree — `queries.ts:44` and this one. Behaviour is unaffected (I diffed them, byte-identical), but this is the duplication `inventoryNameConflict` was created to end. `const conflict = await inventoryNameConflict(tx, item.newItem.item_name); if (conflict) throw new Error(conflict);` and the copy goes away. apps/management/app/api/[[...route]]/inventory.ts:L1: nit: the readable messages now exist on all four paths but the REST layer flattens them to opaque 500s. `PATCH {"item_name":" FILTER OLI TOYOTA AVANZA "}` returns `500 Internal Server Error`, not the sentence; `PATCH /abc` returns 500 rather than surfacing the new `Barang tidak ditemukan.`. The dashboard toasts both correctly, and this mapping predates the PR, so it is genuinely out of scope — noting it only because lifting the guard into a shared helper is the moment it becomes cheap to map thrown domain errors to 409/404 and let API clients read them too. ## The two changes you flagged **Dropping `type="number"` — right call, no reservations.** The native affordances were the cause of the blocker, not a defence against it: `min`/`step` created confidence while the widget called `95.000` valid and silently blanked anything it could not parse. What replaced them is strictly stronger on every axis I could test — the raw string survives so an emptied field is distinguishable from unparseable input (`abc` reads back as `abc`), refusal is explicit rather than inferred, and the same rule now binds the server. Losing the spinner costs nothing on a field where nobody increments by one rupiah, and `inputMode="numeric"` keeps the numeric keypad. This is the field the issue asked you to add; adding it correctly includes deciding it cannot be a `type="number"`. **Refuse over reinterpret — agreed, and worth the paragraph you gave it.** `95.000` is 95000 to a typist and 95 to the browser while `95,5` is a real decimal in this locale, so there is no separator rule that is right for every input, and guessing wrong on a published price costs three orders of magnitude. A loud refusal an operator resolves in two keystrokes is the cheapest correct behaviour. ## Verified on this SHA **The blocker is dead.** `95.000`, `Rp 95.000`, `abc`, `-5000`, `89000.5`, `95,000` and `1e5` all now read back verbatim with `aria-invalid="true"`, the hint swapping to `hanya angka — tanpa titik, koma, atau spasi`, and Simpan disabled. Empty and `95000` stay valid with `kosong atau 0 = belum ditentukan`. `" 95000 "` and `0095000` parse to 95000, which is right. **Trust boundary now holds independently of the widget.** `PATCH {"sell_price":-5000}` → 400, `{"sell_price":89000.5}` → 400, `{"item_name":" "}` → 400. Since both server actions run the same schema, a hand-crafted server-action POST is covered by the same parse, and MCP's `sell_price` is now `.int().nonnegative()` at create and update. My round-1 `should` is closed on all three paths. **All four `itemName` writers reach the shared guard**: `actions.ts:59` (create, in-transaction), `actions.ts:128` (update, with `exceptId`), `mcp/tools/inventory.ts:79` and `:182`. The DB index is still the arbiter — no `try`/`catch` around any write, so a genuine race still surfaces from `inventory_item_name_uniq` rather than being swallowed. **Six UI cases, all reproducing.** Untouched save → `Pengaturan toko disimpan.`, no driver error, and no deploy. Blank name → Simpan disabled. Duplicate rename via `' FILTER OLI TOYOTA AVANZA '` → the readable error, sheet stays open, zero writes, zero deploys. Clearing to zero → `price: null`, `priceUpdatedAt: null`, `availability: "ready"`, still published and still exported. Raising back out → 112000 stamped `15:22:27.806`. Name-only save → the timestamp stayed at `15:22:27.806`, so the improvement still holds after the refactor. **Deploy accounting exact**: 3 dispatches for the 3 saves that changed something, and zero for each of the untouched save, the rejected duplicate and the overflow failure. **Tests: 116 pass, and they bite.** Mutation-checked six ways, all six killed: parser reverted to `Number(raw) || 0` → 16 of 25 currency cases fail; dropping `Number.isSafeInteger` → 1; removing the schema parse from `updateInventory` → 7 of 18 action cases; removing the empty-patch return → 1; removing the NaN-id guard → 1. And `"sellPrice"` removed from the real `STOREFRONT_COLUMNS` now fails, where at `7073423` it passed — my false-confidence nit is genuinely fixed, and the `affectsStorefront({ buyPrice }) === false` negative is what makes the membership assertion real rather than tautological. `tsc --noEmit` exit 0. `Berat (gram)` staying out is the right line — not a published price, and `weight_grams`'s `.int().positive()` bounds it server-side. Flagging it rather than fixing it silently was the correct instinct. Rig left up; row 1 is `Oli Mesin Shell Helix HX7 1L Baru` at 112000. Take the two `should`s at your discretion — I'd like the `2_147_483_647` bound before this deploys, but I'm not gating on either, and neither needs another review round from me.
Round 2 shoulds. parseRupiahInput bounded at Number.isSafeInteger, not
int4, so a 10-digit price reached the operator as a raw Drizzle query dump
— the same class of leaked internal this issue exists to remove. It now
bounds at MAX_RUPIAH (2147483647) and refuses in the field, as do the REST
and MCP schemas via .max().

Both inventory schemas are .strict(). Zod strips unknown keys, so combined
with the new empty-patch no-op, PATCH {"itemName":"typo"} answered 200
having written nothing; an unknown key is now a 400 naming it. parseFields
reads the name out of the issue's `keys`, since unrecognized_keys is
reported against the object with an empty path.

Also closes the same 95.000 misread on the create modal, which published
Rp 95 for Rp 95.000 (measured), and drops the last hand-rolled copy of the
duplicate-name sentence in purchases/service.ts onto the shared helper.
Author
Owner

Round 2 addressed — 546a606

Both shoulds taken, both nits taken, plus one new defect I found and fixed while verifying. Base still caa0c8c, no rebase needed.

should — parser bounded at 2^53, not int4. Fixed.

You were right that the commit title wasn't true yet. parseRupiahInput("9999999999") returned the number, zod's .int().nonnegative() passed it, and the column refused it — so the operator got the raw Failed query: update "inventory" set "sell_price" = $1 ... toast. Exactly the leaked-internal class this issue exists to remove, in the PR that hardened the field.

MAX_RUPIAH = 2_147_483_647 now lives in lib/currency.ts next to the parser, which refuses anything above it, and .max(MAX_RUPIAH) is on sell_price in all four schemas — both REST and both MCP tools — so one number governs every path. Measured at the boundary rather than near it:

typed in the edit form aria-invalid Simpan
2147483647 false enabled
2147483648 true disabled
9999999999 true disabled

should — .strict(). Fixed.

Confirmed your finding and it was a regression this PR introduced: the empty-patch no-op plus zod's key-stripping meant a misspelled field answered 200 for a write that changed nothing. Both inventory schemas are now .strict().

One wrinkle worth flagging since it affects the message you'd see: unrecognized_keys is reported against the object, so issue.path is empty and my parseFields would have degraded to Data tidak valid: . precisely when a typo needs naming. It now reads the name out of the issue's keys, so a direct server-action call gets Data tidak valid: itemName. — covered by a test.

Real HTTP against the running server:

{"itemName":"camelCase typo"}   HTTP 400  unrecognized_keys        (was 200, silent)
{"nonsense":1}                  HTTP 400  unrecognized_keys
{"sell_price":9999999999}       HTTP 400  too_big:sell_price       (was a raw 500)
{"sell_price":2147483648}       HTTP 400  too_big:sell_price
{"sell_price":2147483647}       HTTP 200  {"message":"ok"}         ← stored
{}                              HTTP 200  {"message":"ok"}         ← no-op preserved, 0 deploys

Legitimate {} still no-ops, and only the one accepted write queued a rebuild.

nit — purchases/service.ts hand-rolled the literal. Taken.

Right that the helper was the moment it got cheap. upsertPurchaseItem calls inventoryNameConflict now, so the sentence exists in exactly one place instead of two.

nit — REST flattens readable messages to opaque 500s. Declined.

Agreed it's out of scope and predates this PR: updateInventory's thrown errors — the duplicate-name message and the new Barang tidak ditemukan. — both surface as bare 500s over REST because the API has no error handler mapping thrown Errors to JSON. createInventory's duplicate has always behaved this way. Fixing it means adding a handler across app/api/[[...route]]/, which touches every endpoint. Worth its own issue; noting here so nobody reads my "readable error" claims as applying to the REST surface. On the dashboard, where the issue asked for it, the message is a toast and correct.

New: the same 95.000 misread on the create modal. Fixed.

While proving the bound I checked the sibling caller of the action I'd hardened, and add-inventory-modal.tsx had the identical blocker — type="number" plus Number(sellPrice) || 0. Measured, not inferred: created PROBE Create Path 95000 with 95.000 in Harga Jual and Publikasikan ticked, and got

 id |        item_name        | sell_price | is_published
  3 | PROBE Create Path 95000 |         95 | t
catalog: {"slug":"probe-create-path-95000","price":95,"priceUpdatedAt":"2026-08-27T16:03:33.683Z"}

price: 95 in the public catalog behind a Barang berhasil ditambahkan. success toast. The server boundary cannot catch this one — 95 is a perfectly valid integer price — so it has to be caught at the input, which is why the trust-boundary work didn't already cover it. Same three-line treatment as the edit form, reusing the tested parser; probe row deleted. After:

typed in the create modal aria-invalid Tambah Barang
95.000 true disabled
95,000 true disabled
abc true disabled
9999999999 true disabled
95000 false enabled
(empty) false enabled

buy_price and stock in that modal keep Number(x) || 0. Left alone deliberately: neither is published, and it is the same line I drew for Berat (gram). Both are now at least bounded server-side.

Corrected in round 3 — the struck sentence above was wrong on both halves. Neither field is bounded: both are z.number().optional() with no .int()/.nonnegative()/.max(), so PATCH {"buy_price":9999999999} is still a bare 500. And stock does reach the storefront: PATCH {"stock":-5} returns 200, persists, and flips the item's public availability to "habis", so a negative stock silently marks a real product unavailable to buyers. The Berat (gram) comparison does not transfer, because weight_grams genuinely is .int().positive(). buy_price/stock are still deferred out of this PR, but as a known hole rather than as covered — recorded accurately in the PR body.

This one is beyond what round 2 sanctioned, so it should be covered by a re-review rather than merged on the round-2 verdict — flagging that explicitly rather than quietly widening the diff under an existing approve.

Verified at 546a606

$ pnpm --filter garage test
 Test Files  7 passed (7)
      Tests  129 passed (129)

$ pnpm exec biome check <11 changed files>
Checked 11 files in 16ms. No fixes applied.

$ pnpm exec tsc --noEmit
(exit 0)

Mutation-checked, all three new guards die when removed:

  • int4 bound dropped from parseRupiahInput → 2 currency cases fail
  • .strict() removed from both schemas → 2 validator cases fail
  • .max(MAX_RUPIAH) removed from the schemas → 1 validator case fails

Rig left up. Rows: 1 Oli Mesin Shell Helix HX7 1L Baru at 112000, 2 Filter Oli Toyota Avanza at 45000.

Merging deploys to production.

## Round 2 addressed — `546a606` Both shoulds taken, both nits taken, plus one new defect I found and fixed while verifying. Base still `caa0c8c`, no rebase needed. ### should — parser bounded at 2^53, not int4. Fixed. You were right that the commit title wasn't true yet. `parseRupiahInput("9999999999")` returned the number, zod's `.int().nonnegative()` passed it, and the column refused it — so the operator got the raw `Failed query: update "inventory" set "sell_price" = $1 ...` toast. Exactly the leaked-internal class this issue exists to remove, in the PR that hardened the field. `MAX_RUPIAH = 2_147_483_647` now lives in `lib/currency.ts` next to the parser, which refuses anything above it, and `.max(MAX_RUPIAH)` is on `sell_price` in all four schemas — both REST and both MCP tools — so one number governs every path. Measured at the boundary rather than near it: | typed in the edit form | `aria-invalid` | Simpan | |---|---|---| | `2147483647` | false | **enabled** | | `2147483648` | true | disabled | | `9999999999` | true | disabled | ### should — `.strict()`. Fixed. Confirmed your finding and it was a regression this PR introduced: the empty-patch no-op plus zod's key-stripping meant a misspelled field answered 200 for a write that changed nothing. Both inventory schemas are now `.strict()`. One wrinkle worth flagging since it affects the message you'd see: `unrecognized_keys` is reported against the *object*, so `issue.path` is empty and my `parseFields` would have degraded to `Data tidak valid: .` precisely when a typo needs naming. It now reads the name out of the issue's `keys`, so a direct server-action call gets `Data tidak valid: itemName.` — covered by a test. Real HTTP against the running server: ``` {"itemName":"camelCase typo"} HTTP 400 unrecognized_keys (was 200, silent) {"nonsense":1} HTTP 400 unrecognized_keys {"sell_price":9999999999} HTTP 400 too_big:sell_price (was a raw 500) {"sell_price":2147483648} HTTP 400 too_big:sell_price {"sell_price":2147483647} HTTP 200 {"message":"ok"} ← stored {} HTTP 200 {"message":"ok"} ← no-op preserved, 0 deploys ``` Legitimate `{}` still no-ops, and only the one accepted write queued a rebuild. ### nit — `purchases/service.ts` hand-rolled the literal. Taken. Right that the helper was the moment it got cheap. `upsertPurchaseItem` calls `inventoryNameConflict` now, so the sentence exists in exactly one place instead of two. ### nit — REST flattens readable messages to opaque 500s. Declined. Agreed it's out of scope and predates this PR: `updateInventory`'s thrown errors — the duplicate-name message and the new `Barang tidak ditemukan.` — both surface as bare 500s over REST because the API has no error handler mapping thrown `Error`s to JSON. `createInventory`'s duplicate has always behaved this way. Fixing it means adding a handler across `app/api/[[...route]]/`, which touches every endpoint. Worth its own issue; noting here so nobody reads my "readable error" claims as applying to the REST surface. On the dashboard, where the issue asked for it, the message is a toast and correct. ### New: the same `95.000` misread on the create modal. Fixed. While proving the bound I checked the sibling caller of the action I'd hardened, and `add-inventory-modal.tsx` had the identical blocker — `type="number"` plus `Number(sellPrice) || 0`. Measured, not inferred: created `PROBE Create Path 95000` with `95.000` in Harga Jual and Publikasikan ticked, and got ``` id | item_name | sell_price | is_published 3 | PROBE Create Path 95000 | 95 | t catalog: {"slug":"probe-create-path-95000","price":95,"priceUpdatedAt":"2026-08-27T16:03:33.683Z"} ``` `price: 95` in the public catalog behind a `Barang berhasil ditambahkan.` success toast. The server boundary cannot catch this one — 95 is a perfectly valid integer price — so it has to be caught at the input, which is why the trust-boundary work didn't already cover it. Same three-line treatment as the edit form, reusing the tested parser; probe row deleted. After: | typed in the create modal | `aria-invalid` | Tambah Barang | |---|---|---| | `95.000` | true | disabled | | `95,000` | true | disabled | | `abc` | true | disabled | | `9999999999` | true | disabled | | `95000` | false | **enabled** | | *(empty)* | false | **enabled** | ~~`buy_price` and `stock` in that modal keep `Number(x) || 0`. Left alone deliberately: neither is published, and it is the same line I drew for `Berat (gram)`. Both are now at least bounded server-side.~~ **Corrected in round 3 — the struck sentence above was wrong on both halves.** Neither field is bounded: both are `z.number().optional()` with no `.int()`/`.nonnegative()`/`.max()`, so `PATCH {"buy_price":9999999999}` is still a bare 500. And `stock` *does* reach the storefront: `PATCH {"stock":-5}` returns 200, persists, and flips the item's public `availability` to `"habis"`, so a negative stock silently marks a real product unavailable to buyers. The `Berat (gram)` comparison does not transfer, because `weight_grams` genuinely is `.int().positive()`. `buy_price`/`stock` are still deferred out of this PR, but as a known hole rather than as covered — recorded accurately in the PR body. **This one is beyond what round 2 sanctioned**, so it should be covered by a re-review rather than merged on the round-2 verdict — flagging that explicitly rather than quietly widening the diff under an existing approve. ## Verified at `546a606` ``` $ pnpm --filter garage test Test Files 7 passed (7) Tests 129 passed (129) $ pnpm exec biome check <11 changed files> Checked 11 files in 16ms. No fixes applied. $ pnpm exec tsc --noEmit (exit 0) ``` Mutation-checked, all three new guards die when removed: - int4 bound dropped from `parseRupiahInput` → **2** currency cases fail - `.strict()` removed from both schemas → **2** validator cases fail - `.max(MAX_RUPIAH)` removed from the schemas → **1** validator case fails Rig left up. Rows: 1 `Oli Mesin Shell Helix HX7 1L Baru` at 112000, 2 `Filter Oli Toyota Avanza` at 45000. Merging deploys to production.
yuris left a comment

VERDICT: approve-with-nits

Reviewed the b63316e..546a606 delta on the rig (dev server confirmed serving 546a606). Both round-2 shoulds are closed, the new create-modal surface is correct, and I agree with the dispatcher that it belonged here. Two cosmetic nits, neither blocking; nothing needs another round from me.

Findings

apps/management/app/(dashboard)/stok/add-inventory-modal.tsx:L168: nit: the two screens now refuse identically but say so differently. The edit form reads hanya angka — tanpa titik, koma, atau spasi (inventory-edit-form.tsx:135); the modal reads — hanya angka, tanpa titik atau koma — a leading stray em dash, and it drops spasi even though the parser trims and would refuse an interior space. Same rule, two sentences, on the two screens an operator moves between. The logic divergence you were worried about does not exist; this is its copy shadow. Lift the string to a constant next to parseRupiahInput and both screens quote the same rule.

apps/management/app/(dashboard)/stok/add-inventory-modal.tsx:L168: nit: the modal shows no hint at all when the price is valid (: undefined), where the edit form shows kosong atau 0 = belum ditentukan. So on the one screen where an operator sets a price for the first time, nothing states the convention this PR is built around — that empty and 0 mean "belum ditentukan" and export as price: null. The modal also has no placeholder, where the edit form offers contoh: 95000. Mirroring both is two lines and makes the screens teach the same thing.

Scope note — not a finding, but the recorded reason is wrong

I am not asking for a change here; the deferral is settled and I am not reopening it. But the justification on record for leaving buy_price and stock on Number(x) || 0 — "not published, server-bounded" — is false on both halves, and it should be filed as deferred rather than safe, or the next reader will trust it:

  • Not server-bounded. validators.ts:48/77 are buy_price: z.number().optional() and :50/79 stock: z.number().optional() — no .int(), no .nonnegative(), no .max(). Measured on this SHA: PATCH {"buy_price":9999999999} returns a bare 500, which is the same raw Failed query: class this commit's title is about, still reachable one field to the left of the one you bounded. This is unlike Berat (gram), where weight_grams: z.number().int().positive() genuinely does bound it — that comparison held when you made it, it just does not transfer to these two.
  • stock is published in effect. PATCH {"stock":-5} returned 200, persisted, and the public catalog flipped that item's availability from "ready" to "habis". It is not a price, but it does reach the storefront through deriveStockStatus.

Cheapest correct fix when it is picked up, schema-only and no UI change: .int().nonnegative().max(MAX_RUPIAH) on buy_price and .int().nonnegative() on stock in both schemas. Worth adding to #59 rather than leaving it under a premise that does not hold. I restored stock to 12 after probing.

Verified

The int4 bound is exact, and one constant governs every path. MAX_RUPIAH is declared once at currency.ts:19 and imported by validators.ts (both schemas), mcp/tools/inventory.ts (both tools) and both test files — I grepped for a stray 2147483647 literal across lib and app and there is none, so there is nothing to drift. At the boundary, in every layer: parseRupiahInput returns 2147483647 and null for 2147483648; both schemas accept 2147483647 and reject 2147483648; in both forms 2147483647 leaves the field valid while 2147483648 and 9999999999 go aria-invalid with submit disabled; and over HTTP the 10-digit price is now 400 too_big with maximum: 2147483647 instead of the raw Failed query: dump. A REST PATCH {"sell_price":2147483647} committed and read back from the column, so the bound is the column's, off-by-one in neither direction.

.strict(), and the keys wrinkle generalises. PATCH {"itemName":"camelCase typo"} and {"nonsense":1} are both 400 unrecognized_keys naming the key; {} is still 200 with no write. I pushed the message through every shape that could regress to bare punctuation and none does: one unknown key → Data tidak valid: itemName.; two → Data tidak valid: itemName, sellPrice.; a nested object value → Data tidak valid: brand.; an unknown key alongside a genuinely invalid value → Data tidak valid: sell_price, itemName. (both named, neither swallowed); invalid value alone → Data tidak valid: sell_price.. Reading the name out of issue.keys was the right call and it is guarded — reverting it to an empty string kills a test.

.strict() did not break the write paths, which was the regression I went looking for: I created an item through the modal end to end and it succeeded (Barang berhasil ditambahkan., row written with sell_price = 95000 and the price date stamped), so the modal's payload keys are all in CreateInventorySchema. Probe row deleted afterwards.

The create modal refuses exactly what the edit form refuses. Driven live: 95.000, abc, Rp 95.000, 95,000, 1e5, -5000, 89000.5, 2147483648, 9999999999 all read back verbatim with aria-invalid="true" and Tambah Barang disabled; empty, 95000 and 2147483647 valid with it enabled. Emptied is distinguished from unparseable exactly as in the edit form — the raw string survives, "" parses to 0, and only unparseable text blocks the save. Mechanically the two screens are now the same code: same parseRupiahInput, same inputMode="numeric", same aria-invalid, and sell_price: parsedSellPrice is passed through rather than coerced, correctly narrowed to number by the guard at :94. No logic divergence introduced.

Judged as new surface: including it was right. The server boundary provably cannot catch this one — 95 is a valid, in-range, non-negative integer, so no schema can tell it from a real Rp 95 price, which makes the input the only place it can be caught. Shipping the edit form hardened while its sibling still published Rp 95 for Rp 95.000 would have been a fix that reads as complete and is not.

One sentence, one place, DB still authoritative. Barang dengan nama serupa sudah ada: "…" now exists at exactly queries.ts:44 and nowhere else in lib, reached from all five writers — actions.ts:70 and :139, mcp/tools/inventory.ts:80 and :183, and now purchases/service.ts:28. Byte-identity is structural rather than coincidental. There is no try/catch anywhere around the inventory writes, so inventory_item_name_uniq remains the arbiter of a genuine race and nothing is swallowed or reworded.

Deploys still fire correctly on this SHA — three sink dispatches for three storefront-affecting REST writes, and the delta touches neither affectsStorefront nor the call site.

Tests: 129 pass, and the new ones bite. Mutation-checked five ways, all killed: parser int4 bound removed → 2 fail; .max(MAX_RUPIAH) removed from both schemas → 1; .strict() removed from both → 3 (you reported 2); the issue.keys naming reverted → 1. I read all thirteen new cases looking for another can't-fail assertion like round 1's and found none — the boundary tests import MAX_RUPIAH rather than copying the literal, so they track the constant, and the positive accepts 2147483647 case is what makes the negative meaningful. tsc --noEmit exit 0.

Rig restored: row 1 Oli Mesin Shell Helix HX7 1L Baru at 112000 with stock 12, row 2 untouched. Merge on this; the two nits are yours to take or leave.

VERDICT: approve-with-nits Reviewed the `b63316e..546a606` delta on the rig (dev server confirmed serving `546a606`). **Both round-2 shoulds are closed, the new create-modal surface is correct, and I agree with the dispatcher that it belonged here.** Two cosmetic nits, neither blocking; nothing needs another round from me. ## Findings apps/management/app/(dashboard)/stok/add-inventory-modal.tsx:L168: nit: the two screens now refuse identically but say so differently. The edit form reads `hanya angka — tanpa titik, koma, atau spasi` (`inventory-edit-form.tsx:135`); the modal reads `— hanya angka, tanpa titik atau koma` — a leading stray em dash, and it drops `spasi` even though the parser trims and would refuse an interior space. Same rule, two sentences, on the two screens an operator moves between. The logic divergence you were worried about does not exist; this is its copy shadow. Lift the string to a constant next to `parseRupiahInput` and both screens quote the same rule. apps/management/app/(dashboard)/stok/add-inventory-modal.tsx:L168: nit: the modal shows no hint at all when the price is valid (`: undefined`), where the edit form shows `kosong atau 0 = belum ditentukan`. So on the one screen where an operator sets a price for the first time, nothing states the convention this PR is built around — that empty and `0` mean "belum ditentukan" and export as `price: null`. The modal also has no `placeholder`, where the edit form offers `contoh: 95000`. Mirroring both is two lines and makes the screens teach the same thing. ## Scope note — not a finding, but the recorded reason is wrong I am not asking for a change here; the deferral is settled and I am not reopening it. But the justification on record for leaving `buy_price` and `stock` on `Number(x) || 0` — "not published, server-bounded" — is false on both halves, and it should be filed as deferred rather than safe, or the next reader will trust it: - **Not server-bounded.** `validators.ts:48/77` are `buy_price: z.number().optional()` and `:50/79` `stock: z.number().optional()` — no `.int()`, no `.nonnegative()`, no `.max()`. Measured on this SHA: `PATCH {"buy_price":9999999999}` returns a bare **500**, which is the same raw `Failed query:` class this commit's title is about, still reachable one field to the left of the one you bounded. This is unlike `Berat (gram)`, where `weight_grams: z.number().int().positive()` genuinely does bound it — that comparison held when you made it, it just does not transfer to these two. - **`stock` is published in effect.** `PATCH {"stock":-5}` returned **200**, persisted, and the public catalog flipped that item's `availability` from `"ready"` to `"habis"`. It is not a price, but it does reach the storefront through `deriveStockStatus`. Cheapest correct fix when it is picked up, schema-only and no UI change: `.int().nonnegative().max(MAX_RUPIAH)` on `buy_price` and `.int().nonnegative()` on `stock` in both schemas. Worth adding to #59 rather than leaving it under a premise that does not hold. I restored `stock` to 12 after probing. ## Verified **The int4 bound is exact, and one constant governs every path.** `MAX_RUPIAH` is declared once at `currency.ts:19` and imported by `validators.ts` (both schemas), `mcp/tools/inventory.ts` (both tools) and both test files — I grepped for a stray `2147483647` literal across `lib` and `app` and there is none, so there is nothing to drift. At the boundary, in every layer: `parseRupiahInput` returns `2147483647` and `null` for `2147483648`; both schemas accept `2147483647` and reject `2147483648`; in both forms `2147483647` leaves the field valid while `2147483648` and `9999999999` go `aria-invalid` with submit disabled; and over HTTP the 10-digit price is now `400 too_big` with `maximum: 2147483647` instead of the raw `Failed query:` dump. A REST `PATCH {"sell_price":2147483647}` committed and read back from the column, so the bound is the column's, off-by-one in neither direction. **`.strict()`, and the `keys` wrinkle generalises.** `PATCH {"itemName":"camelCase typo"}` and `{"nonsense":1}` are both `400 unrecognized_keys` naming the key; `{}` is still `200` with no write. I pushed the message through every shape that could regress to bare punctuation and none does: one unknown key → `Data tidak valid: itemName.`; two → `Data tidak valid: itemName, sellPrice.`; a nested object value → `Data tidak valid: brand.`; an unknown key alongside a genuinely invalid value → `Data tidak valid: sell_price, itemName.` (both named, neither swallowed); invalid value alone → `Data tidak valid: sell_price.`. Reading the name out of `issue.keys` was the right call and it is guarded — reverting it to an empty string kills a test. **`.strict()` did not break the write paths**, which was the regression I went looking for: I created an item through the modal end to end and it succeeded (`Barang berhasil ditambahkan.`, row written with `sell_price = 95000` and the price date stamped), so the modal's payload keys are all in `CreateInventorySchema`. Probe row deleted afterwards. **The create modal refuses exactly what the edit form refuses.** Driven live: `95.000`, `abc`, `Rp 95.000`, `95,000`, `1e5`, `-5000`, `89000.5`, `2147483648`, `9999999999` all read back verbatim with `aria-invalid="true"` and **Tambah Barang disabled**; empty, `95000` and `2147483647` valid with it enabled. Emptied is distinguished from unparseable exactly as in the edit form — the raw string survives, `""` parses to `0`, and only unparseable text blocks the save. Mechanically the two screens are now the same code: same `parseRupiahInput`, same `inputMode="numeric"`, same `aria-invalid`, and `sell_price: parsedSellPrice` is passed through rather than coerced, correctly narrowed to `number` by the guard at `:94`. No logic divergence introduced. **Judged as new surface: including it was right.** The server boundary provably cannot catch this one — `95` is a valid, in-range, non-negative integer, so no schema can tell it from a real Rp 95 price, which makes the input the only place it can be caught. Shipping the edit form hardened while its sibling still published Rp 95 for Rp 95.000 would have been a fix that reads as complete and is not. **One sentence, one place, DB still authoritative.** `Barang dengan nama serupa sudah ada: "…"` now exists at exactly `queries.ts:44` and nowhere else in `lib`, reached from all five writers — `actions.ts:70` and `:139`, `mcp/tools/inventory.ts:80` and `:183`, and now `purchases/service.ts:28`. Byte-identity is structural rather than coincidental. There is no `try`/`catch` anywhere around the inventory writes, so `inventory_item_name_uniq` remains the arbiter of a genuine race and nothing is swallowed or reworded. **Deploys still fire correctly on this SHA** — three sink dispatches for three storefront-affecting REST writes, and the delta touches neither `affectsStorefront` nor the call site. **Tests: 129 pass, and the new ones bite.** Mutation-checked five ways, all killed: parser int4 bound removed → 2 fail; `.max(MAX_RUPIAH)` removed from both schemas → 1; `.strict()` removed from both → 3 (you reported 2); the `issue.keys` naming reverted → 1. I read all thirteen new cases looking for another can't-fail assertion like round 1's and found none — the boundary tests import `MAX_RUPIAH` rather than copying the literal, so they track the constant, and the positive `accepts 2147483647` case is what makes the negative meaningful. `tsc --noEmit` exit 0. Rig restored: row 1 `Oli Mesin Shell Helix HX7 1L Baru` at 112000 with stock 12, row 2 untouched. Merge on this; the two nits are yours to take or leave.
Author
Owner

Pre-merge verification at 546a606, the exact commit being merged

Re-run now, not carried over from an earlier round. 2026-08-27T16:28:16Z.

$ git rev-parse HEAD
546a60605830b350131204192f9091128fd903b9
$ git status --porcelain
(clean)

$ pnpm --filter garage test
 Test Files  7 passed (7)
      Tests  129 passed (129)
   Duration  771ms

$ pnpm exec tsc --noEmit
(exit 0)

$ pnpm exec biome check <11 changed files>
Checked 11 files in 15ms. No fixes applied.

Trust boundary, real HTTP against the running server

{"sell_price":89000.5}           HTTP 400  invalid_type:sell_price
{"sell_price":-5000}             HTTP 400  too_small:sell_price
{"sell_price":9999999999}        HTTP 400  too_big:sell_price
{"sell_price":2147483648}        HTTP 400  too_big:sell_price
{"itemName":"camelCase typo"}    HTTP 400  unrecognized_keys
{"item_name":"   "}              HTTP 400  too_small:item_name
{"sell_price":2147483647}        HTTP 200  ok      ← int4 ceiling, stored
{}                               HTTP 200  ok      ← no-op, 0 deploys
{"sell_price":112000}            HTTP 200  ok      ← stamped

Six rejections, rows untouched by every one. Exactly two deploy dispatches for the two price-changing accepts; the empty patch queued none.

The price field in the real form

95.000       REFUSED   field="95.000"      Simpan disabled
95,000       REFUSED   field="95,000"      Simpan disabled
95.5         REFUSED   field="95.5"        Simpan disabled
-5000        REFUSED   field="-5000"       Simpan disabled
abc          REFUSED   field="abc"         Simpan disabled
Rp 95.000    REFUSED   field="Rp 95.000"   Simpan disabled
1e5          REFUSED   field="1e5"         Simpan disabled
2147483648   REFUSED   field="2147483648"  Simpan disabled
9999999999   REFUSED   field="9999999999"  Simpan disabled
(empty)      accepted  field=""            Simpan enabled
2147483647   accepted  field="2147483647"  Simpan enabled
112000       accepted  field="112000"      Simpan enabled

Every refusal keeps the text the operator typed, which is what makes an emptied field distinguishable from unparseable input — the silent-wipe-to-zero path. 2147483647 / 2147483648 is the int4 boundary exactly.

Catalog

{"slug":"filter-oli-toyota-avanza","price":45000,"priceUpdatedAt":"2026-08-07T22:05:06.845Z","availability":"ready"}
{"slug":"oli-mesin-shell-helix-1l","price":112000,"priceUpdatedAt":"2026-08-27T16:29:08.054Z","availability":"ready"}

Acceptance criteria, item by item

From #43 Status
itemName and sellPrice editable from the edit form, via existing updateInventory met — no new server route, UpdateInventorySchema unchanged in shape
Clearing sellPrice to 0 works end to end → price: null, item still published met — proven: sell_price 0, price_updated_at NULL, is_published t, catalog "price": null with the item still exported
Duplicate name shows the existing readable error, not a Postgres dump met — byte-identical message to createInventory, on all four write paths, zero writes and zero deploys on rejection
Saving either field triggers requestStoreDeploy met — one dispatch per successful save, none on any rejection, observed at a local sink
Existing lib/inventory tests still pass; add a case only if the zero/null path is uncovered met — 64 → 129, and the zero/null path was uncovered

Declined, with reasons

  • Round-3 nit 1 — modal refusal hint wording differs from the edit form and omits "spasi". Real but copy-only, and a push would void an approve-with-nits verdict for cosmetics. The two screens already differ by the modal's leading-em-dash hint convention, so sharing one string needs a wording decision rather than a rename.
  • Round-3 nit 2 — the modal shows no hint while the price is valid, so it never states that empty/0 means "belum ditentukan". Same reason; worth the same follow-up pass.
  • REST flattens thrown errors to bare 500s. Predates this PR, needs an error handler across app/api/[[...route]]/. My "readable error" claims are about the dashboard toast, not the REST surface.
  • buy_price / stock in the create modal — deferred with a known hole, corrected in the body and in comment 3109. My earlier "not published, bounded server-side" was wrong on both halves.

Nothing unrelated in the diff

 apps/management/app/(dashboard)/stok/add-inventory-modal.tsx  |  30 ++-
 apps/management/app/(dashboard)/stok/inventory-edit-form.tsx  |  47 +++-
 apps/management/lib/currency.test.ts                          |  60 +++++-
 apps/management/lib/currency.ts                               |  47 ++++
 apps/management/lib/db/validators.test.ts                     |  85 ++++++++
 apps/management/lib/db/validators.ts                          |  14 +-
 apps/management/lib/inventory/actions.test.ts                 | 193 ++++++++++++++++
 apps/management/lib/inventory/actions.ts                      | 160 ++++++++------
 apps/management/lib/inventory/queries.ts                      |  27 ++-
 apps/management/lib/mcp/tools/inventory.ts                    |  36 +++-
 apps/management/lib/purchases/service.ts                      |   9 +-

apps/management only. No apps/store, no packages/catalog, no globals.css, no migration, no lockfile change, no new dependency, no debug output, no formatter drift.

No forge check ran on this branch and none will — no workflow triggers on pull_request. Every green light above is a local command. Merging this squash deploys the management container to production.

Merging now on review id=21 (approve-with-nits, 0 blockers, 0 shoulds, pinned to 546a606) with the dispatcher's confirmation that I am last in the merge order.

## Pre-merge verification at `546a606`, the exact commit being merged Re-run now, not carried over from an earlier round. `2026-08-27T16:28:16Z`. ``` $ git rev-parse HEAD 546a60605830b350131204192f9091128fd903b9 $ git status --porcelain (clean) $ pnpm --filter garage test Test Files 7 passed (7) Tests 129 passed (129) Duration 771ms $ pnpm exec tsc --noEmit (exit 0) $ pnpm exec biome check <11 changed files> Checked 11 files in 15ms. No fixes applied. ``` ### Trust boundary, real HTTP against the running server ``` {"sell_price":89000.5} HTTP 400 invalid_type:sell_price {"sell_price":-5000} HTTP 400 too_small:sell_price {"sell_price":9999999999} HTTP 400 too_big:sell_price {"sell_price":2147483648} HTTP 400 too_big:sell_price {"itemName":"camelCase typo"} HTTP 400 unrecognized_keys {"item_name":" "} HTTP 400 too_small:item_name {"sell_price":2147483647} HTTP 200 ok ← int4 ceiling, stored {} HTTP 200 ok ← no-op, 0 deploys {"sell_price":112000} HTTP 200 ok ← stamped ``` Six rejections, rows untouched by every one. Exactly **two** deploy dispatches for the two price-changing accepts; the empty patch queued none. ### The price field in the real form ``` 95.000 REFUSED field="95.000" Simpan disabled 95,000 REFUSED field="95,000" Simpan disabled 95.5 REFUSED field="95.5" Simpan disabled -5000 REFUSED field="-5000" Simpan disabled abc REFUSED field="abc" Simpan disabled Rp 95.000 REFUSED field="Rp 95.000" Simpan disabled 1e5 REFUSED field="1e5" Simpan disabled 2147483648 REFUSED field="2147483648" Simpan disabled 9999999999 REFUSED field="9999999999" Simpan disabled (empty) accepted field="" Simpan enabled 2147483647 accepted field="2147483647" Simpan enabled 112000 accepted field="112000" Simpan enabled ``` Every refusal keeps the text the operator typed, which is what makes an emptied field distinguishable from unparseable input — the silent-wipe-to-zero path. `2147483647` / `2147483648` is the int4 boundary exactly. ### Catalog ```json {"slug":"filter-oli-toyota-avanza","price":45000,"priceUpdatedAt":"2026-08-07T22:05:06.845Z","availability":"ready"} {"slug":"oli-mesin-shell-helix-1l","price":112000,"priceUpdatedAt":"2026-08-27T16:29:08.054Z","availability":"ready"} ``` ### Acceptance criteria, item by item | From #43 | Status | |---|---| | `itemName` and `sellPrice` editable from the edit form, via existing `updateInventory` | met — no new server route, `UpdateInventorySchema` unchanged in shape | | Clearing `sellPrice` to 0 works end to end → `price: null`, item still published | met — proven: `sell_price 0`, `price_updated_at NULL`, `is_published t`, catalog `"price": null` with the item still exported | | Duplicate name shows the existing readable error, not a Postgres dump | met — byte-identical message to `createInventory`, on all four write paths, zero writes and zero deploys on rejection | | Saving either field triggers `requestStoreDeploy` | met — one dispatch per successful save, none on any rejection, observed at a local sink | | Existing `lib/inventory` tests still pass; add a case only if the zero/null path is uncovered | met — 64 → 129, and the zero/null path was uncovered | ### Declined, with reasons - **Round-3 nit 1 — modal refusal hint wording differs from the edit form and omits "spasi".** Real but copy-only, and a push would void an `approve-with-nits` verdict for cosmetics. The two screens already differ by the modal's leading-em-dash hint convention, so sharing one string needs a wording decision rather than a rename. - **Round-3 nit 2 — the modal shows no hint while the price is valid**, so it never states that empty/0 means "belum ditentukan". Same reason; worth the same follow-up pass. - **REST flattens thrown errors to bare 500s.** Predates this PR, needs an error handler across `app/api/[[...route]]/`. My "readable error" claims are about the dashboard toast, not the REST surface. - **`buy_price` / `stock` in the create modal** — deferred with a known hole, corrected in the body and in comment 3109. My earlier "not published, bounded server-side" was wrong on both halves. ### Nothing unrelated in the diff ``` apps/management/app/(dashboard)/stok/add-inventory-modal.tsx | 30 ++- apps/management/app/(dashboard)/stok/inventory-edit-form.tsx | 47 +++- apps/management/lib/currency.test.ts | 60 +++++- apps/management/lib/currency.ts | 47 ++++ apps/management/lib/db/validators.test.ts | 85 ++++++++ apps/management/lib/db/validators.ts | 14 +- apps/management/lib/inventory/actions.test.ts | 193 ++++++++++++++++ apps/management/lib/inventory/actions.ts | 160 ++++++++------ apps/management/lib/inventory/queries.ts | 27 ++- apps/management/lib/mcp/tools/inventory.ts | 36 +++- apps/management/lib/purchases/service.ts | 9 +- ``` `apps/management` only. No `apps/store`, no `packages/catalog`, no `globals.css`, no migration, no lockfile change, no new dependency, no debug output, no formatter drift. **No forge check ran on this branch and none will** — no workflow triggers on `pull_request`. Every green light above is a local command. **Merging this squash deploys the management container to production.** Merging now on review id=21 (`approve-with-nits`, 0 blockers, 0 shoulds, pinned to `546a606`) with the dispatcher's confirmation that I am last in the merge order.
yuris merged commit 449b28b470 into main 2026-08-27 16:34:03 +00:00
yuris deleted branch feat/43-name-price-edit 2026-08-27 16:34:03 +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!56
No description provided.