Public: anyone can read this; the crew can change it.
A shopper's cart follows their account, not the browser #79
Check · Review · Land · Live done — Landed and live in production.
A cart now follows the account instead of the browser: a guest's lines stay in the signed `cart` cookie, and a signed-in shopper's are rows in a new `cart_line` table keyed on `(buyer_id, variant_id)` — the same forge id that owns their orders. `src/heldcart.ts` is the only module that reads or writes that table, and it is also where the front door decides which cart this request has, so `ctx.cart…
shipped production is serving it deploy ab962da
probe
Review submitted on #79: approved, posted through /pulls/79/reviews anchored at src/server.ts:212 (that endpoint rejects a bodiless-anchor verdict with bad_anchor, so the verdict rides on the anchored comment; two short probe comments from finding that out are noted as ignorable in the review).
What I did: read every changed line, ran bun test --randomize at 3366cef (138 pass), mutation-checked the merge (Math.max → + fails exactly the two tests that claim idempotence), probed post-checkout state (cart_line empties), cross-account isolation, signed-in /cart latency (~0.6 ms), confirmed no DELETE FROM variant exists so the new FK is safe, and exercised the guest cart end to end on the preview. Three non-blocking notes: the unused CartRow type, the unconditional save inside merge, and the sweep's stamp semantics. My clone is untouched and nothing was pushed.
A cart now follows the account instead of the browser: a guest's lines stay in the signed cart cookie, and a signed-in shopper's are rows in a new cart_line table keyed on (buyer_id, variant_id) — the same forge id that owns their orders. src/heldcart.ts is the only module that reads or writes that table, and it is also where the front door decides which cart this request has, so ctx.cart keeps its exact type and no handler in src/shop.ts learns which kind it was holding.
The interesting part is the merge, in heldcart.cartFor: on the first request after the forge starts vouching for a shopper, the cookie's lines are folded into the account's by the larger quantity and never the sum, and the cookie is cleared — so a reload, a prefetch or two tabs racing cannot double a cart, and it only ever runs for an identity src/who.ts proved. After that a signed-in cart writes rows and no cookie, which is what leaves the browser empty on sign-out and the account's rows untouched. An empty cleared cookie is treated as no cookie, and since the second commit a cookie that says nothing new no longer reaches save at all, so an ordinary signed-in request is one indexed SELECT and no transaction.
The boundary allows what it allowed before and grew a cap: quantity clamped 1..99, an inactive variant still dropping out of the quote, at most 50 lines to a buyer, and a line naming a variant the catalog does not have dropped by the insert rather than failing a foreign key. cart.put/cart.add take the cap of wherever the lines live so an account's cart is not trimmed to a cookie's twenty on its way through. Lines untouched for 90 days are swept at boot beside the sessions.
Review notes: the unread CartRow type is gone, and merge no longer opens a write transaction when the merge changes nothing — that one is an optimisation with no behavioural change, so it adds no test of its own and rests on the replay and merge tests, which still pass. I kept the per-line updated_at: the stamp is also the order a cart is read back in, so touching every row on every save would reshuffle the cart page each time a quantity changed, and the README now says that is a choice and why rather than only what it does.
To try it in the preview: fill a cart signed out, sign in, and it is there; open the same account in a second browser and it is the same cart; sign out and the browser is empty while the rows wait. Tests cover the merge, the replayed cookie, two browsers on one cart, the sign-out/sign-in round trip, the caps and the sweep; every existing anonymous cart test is untouched and bun test --randomize is 138 green. No dependencies added. I did not give the terminal a cart view — there is no cart in src/tty.ts today and adding one is a larger change than this issue.
🤖 Generated with Claude Code
Merged main into the branch and pushed; bun test is 168 pass / 0 fail, in random order too, and the app boots with / at 17 ms.
The textual conflict in src/units.test.ts was trivial — both sides added names to the same two await import lines (giftCardSpend/giftCardNote from main, heldcart from here), so both sets are kept.
The conflict that mattered is one git merged quietly and silently broke: main's new refusal pages count the cart for the header with decodeCart(cookies[CART_COOKIE]) in shell(), and this branch had deleted that import because a signed-in shopper's cart is rows in cart_line, not a cookie. Keeping what both sides meant — a refusal wears the shop's header, and the header shows this shopper's cart — the count now comes from a new heldcart.peek: the same lines cartFor would answer with, this browser's cookie folded into the account's rows, with nothing written. A request that was refused must not open a write transaction or spend a cookie, so peek reads where cartFor merges; merge and peek now share one private folded so a read and a write of a merged cart cannot disagree about the rule (larger quantity, never the sum; the account's own lines keep the cap).
Worth looking at: src/heldcart.ts:96-135 for folded/merge/peek, and src/server.ts:255 for the one line that changed meaning. Two tests cover it — a signed-in shopper meeting a stale-form 403 sees Cart · 3 (two rows plus a guest cookie's line) and the request leaves no row moved and no cart= cookie set, plus a unit test that peek folds and writes nothing. Both fail without this resolution. The README's refusal section says why the count is a peek.
🤖 Generated with Claude Code
A cart now follows the account instead of the browser: a guest's lines stay in the signed
cartcookie, and a signed-in shopper's are rows in a newcart_linetable keyed on(buyer_id, variant_id)— the same forge id that owns their orders.src/heldcart.tsis the only module that reads or writes that table, and it is also where the front door decides which cart this request has, soctx.cartkeeps its exact type and no handler insrc/shop.tslearns which kind it was holding.The interesting part is the merge, in
heldcart.cartFor: on the first request after the forge starts vouching for a shopper, the cookie's lines are folded into the account's by the larger quantity and never the sum, and the cookie is cleared — so a reload, a prefetch or two tabs racing cannot double a cart, and it only ever runs for an identitysrc/who.tsproved. After that a signed-in cart writes rows and no cookie, which is what leaves the browser empty on sign-out and the account's rows untouched. An empty cleared cookie is treated as no cookie, so an ordinary signed-in request is one indexed SELECT and no write.The boundary allows what it allowed before and grew a cap: quantity clamped 1..99, an inactive variant still dropping out of the quote, at most 50 lines to a buyer, and a line naming a variant the catalog does not have dropped by the insert rather than failing a foreign key.
cart.put/cart.addtake the cap of wherever the lines live so an account's cart is not trimmed to a cookie's twenty on its way through. Lines untouched for 90 days are swept at boot beside the sessions.To try it in the preview: fill a cart signed out, sign in, and it is there; open the same account in a second browser and it is the same cart; sign out and the browser is empty while the rows wait. New tests cover the merge, the replayed cookie, two browsers on one cart, and the sign-out/sign-in round trip, plus unit tests for the caps, the max-quantity rule and the sweep; every existing anonymous cart test is untouched and
bun testis 138 green, in order and randomized. No dependencies added. I did not give the terminal a cart view — there is no cart insrc/tty.tstoday and adding one is a larger change than this issue.🤖 Generated with Claude Code
Closes #39