Public: anyone can read this; the crew can change it.
An admin's change to the catalog, a discount or a tax rate is a row that names who made it #53
Check · Review · Land · Live done — Landed and live in production.
Pushed. `bun test` is green (92 pass, also under `--randomize`), and the app boots with `/admin/changes` answering 403 for anyone the forge did not sign as an admin.
shipped production is serving it deploy ab962da
Changes requested
— before the latest push
Changes requested; see the anchored review on src/changes.ts:71.
Sign in to join this conversation.
Pushed.
bun testis green (92 pass, also under--randomize), and the app boots with/admin/changesanswering 403 for anyone the forge did not sign as an admin.An admin's change to what the shop sells and what it charges is now a row.
src/changes.tsis the one concept: a writer that diffs two plain snapshots and writes oneadmin_changerow per field that actually moved, and a reader that returns the last hundred newest-first. Every call site insrc/admin.ts—productCreate,productUpdate,variantCreate,variantUpdate,discountCreate,discountToggle,taxSave,taxRemove— wraps its write and its row in onedb.transaction, so a change that rolls back takes its audit row with it and a change that lands cannot be missing one. A form submitted with nothing changed writes nothing.The actor is
ctx.whoand nothing else; at the terminal it is the account the forge names in the environment, which is whysrc/desk.tsrecords its own writes the same way rather than leaving a hole where the ssh session is. Values are capped at 120 characters on the way in (a 4,000-character blurb becomes 120 characters and an ellipsis) and escaped on the way out. No gift card is a subject here: its code is bearer money and its balance already has a ledger.Worth a look in the preview:
/admin/changes— edit a variant's price at/admin/products/:idand the row says$20.00 → $21.50with the forge id of whoever did it, with the subject linked to its own page; press7in the terminal for the same list, marked closed for a non-admin the way orders and cards are. The reader orders by the newadmin_change(at DESC, id DESC)index — on a table seeded with 10,000 rows the query is 0.7 ms and the page renders in 1.6 ms.What I chose not to do: #12's actor columns on the five existing ledgers are still #12's — this is only the half that is not about an order, and it settles the actor shape (
actor_id,actor_namefromctx.who) for that change to reuse.tax.upsertRatenow returns the row id andtax.rateAtwas added so an upsert can record what the rate was before; no arithmetic changed and no dependency was added.🤖 Generated with Claude Code
Closes #34