Public: anyone can read this; the crew can change it.
Admin sees stock and orders change live, and every admin action leaves a trail #78
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/30-admin-sees-stock-and-orders-change-live → main 2 commits fork 1 thread · 1 unresolved
5 files
changed +119
−27
README.md +16 −10
| @@ -26,7 +26,7 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 26 | 26 | |
| 27 | 27 | It has no dependencies: Bun's `bun:sqlite`, `node:crypto` and the standard |
| 28 | 28 | library are all of it. The shop has no client-side JavaScript at all; the |
| 29 | admin's three live pages load one small file — see | |
| 29 | admin's live pages load one small file — see | |
| 30 | 30 | [](#the-admin-sees-it-happen). |
| 31 | 31 | |
| 32 | 32 | ## What is real and what is simulated |
| @@ -415,16 +415,22 @@ what it wrote, and the page listens. | ||
| 415 | 415 | marked `data-live` by their `id`. One refetch is in flight at a time and |
| 416 | 416 | the next event coalesces into it, so a busy shop is not a poll. A region |
| 417 | 417 | someone is typing in is left alone until the caret moves on. |
| 418 | - A region may add `data-live-key="<subject>"` and then it wants only the | |
| 419 | events that name that subject — the page about one order ignores every | |
| 420 | other order's. The key is matched against the values the event carries, so | |
| 421 | the bus stays ignorant of what any page is showing. | |
| 418 | 422 | |
| 419 | Live pages: **`/admin`** (the totals and the latest orders), **`/admin/orders`** | |
| 420 | and **`/admin/inventory`** (both tables — stock on hand and the ledger). | |
| 421 | Their tables have fixed columns, so a row arriving cannot move the rows | |
| 422 | around it. No other page loads a script, and none of them has an inline one: | |
| 423 | the floor's `script-src` is `'self'`, with no `unsafe-inline` and no | |
| 424 | `unsafe-eval`, and `static/live.js` is served under a name made from its own | |
| 425 | contents (`/static/live.<hash>.js`) with a year-long cache, the same trick | |
| 426 | the stylesheet plays. With JavaScript off, every one of those pages is the | |
| 427 | page it always was — one reload behind. | |
| 423 | Live pages: **`/admin`** (the totals and the latest orders), **`/admin/orders`**, | |
| 424 | **`/admin/inventory`** (both tables — stock on hand and the ledger) and | |
| 425 | **`/admin/orders/<number>`** (one order, keyed to its number: the status, the | |
| 426 | totals, what is left to refund, the payments and the history move together as | |
| 427 | a payment settles or a refund goes back). Their tables have fixed columns, so | |
| 428 | a row arriving cannot move the rows around it. No other page loads a script, | |
| 429 | and none of them has an inline one: the floor's `script-src` is `'self'`, with | |
| 430 | no `unsafe-inline` and no `unsafe-eval`, and `static/live.js` is served under a | |
| 431 | name made from its own contents (`/static/live.<hash>.js`) with a year-long | |
| 432 | cache, the same trick the stylesheet plays. With JavaScript off, every one of | |
| 433 | those pages is the page it always was — one reload behind. | |
| 428 | 434 | |
| 429 | 435 | The bus is a table, so the terminal (`bun run src/tty.ts`, or `ssh`) is not a |
| 430 | 436 | blind spot: it is a second process on the same database, its `emit` writes a |
src/admin.ts +1 −1
| @@ -365,7 +365,7 @@ export function orderDetail(ctx: Ctx): Response { | ||
| 365 | 365 | if (!order) return notFound(ctx); |
| 366 | 366 | return render( |
| 367 | 367 | ctx, |
| 368 | { title: order.number, admin: true }, | |
| 368 | { title: order.number, admin: true, live: true }, | |
| 369 | 369 | pages.order( |
| 370 | 370 | order, |
| 371 | 371 | orders.itemsOf(order.id), |
src/pages/admin.ts +22 −9
| @@ -3,9 +3,10 @@ | ||
| 3 | 3 | * and the outbox. Everything an admin can do is a form with a CSRF token and |
| 4 | 4 | * a POST; nothing changes on a GET. |
| 5 | 5 | * |
| 6 | * The overview, the orders list and the inventory tables are wrapped in | |
| 7 | * `data-live` regions: static/live.js refetches this same page when the bus | |
| 8 | * says an order or a stock level moved, and swaps those regions by id. The | |
| 6 | * The overview, the orders list, the inventory tables and the page about one | |
| 7 | * order are wrapped in `data-live` regions: static/live.js refetches this same | |
| 8 | * page when the bus says an order or a stock level moved, and swaps those | |
| 9 | * regions by id — the one about a single order only for its own order. The | |
| 9 | 10 | * markup is the markup either way — with the script off, the page is what it |
| 10 | 11 | * always was, one reload behind. |
| 11 | 12 | */ |
| @@ -32,9 +33,16 @@ const STOCK_EVENTS = "stock.moved"; | ||
| 32 | 33 | /** |
| 33 | 34 | * A region the stream keeps current. The id is what static/live.js swaps by, |
| 34 | 35 | * so it is stable across renders; the types are the events worth a refetch. |
| 36 | * | |
| 37 | * `key` narrows a region to one subject: a list wants every order, but the | |
| 38 | * page about ORD-1234 wants ORD-1234 and is not worth a refetch when somebody | |
| 39 | * else's order is paid. It is matched against the values the event carries, | |
| 40 | * so the bus stays ignorant of what any page is showing. | |
| 35 | 41 | */ |
| 36 | const live = (id: string, types: string, body: string): string => | |
| 37 | `<div id="${escape(id)}" data-live="${escape(types)}">${body}</div>`; | |
| 42 | const live = (id: string, types: string, body: string, key?: string): string => | |
| 43 | `<div id="${escape(id)}" data-live="${escape(types)}"${ | |
| 44 | key === undefined ? "" : ` data-live-key="${escape(key)}"` | |
| 45 | }>${body}</div>`; | |
| 38 | 46 | |
| 39 | 47 | export const nav = (current: string): string => |
| 40 | 48 | `<nav>${[ |
| @@ -165,9 +173,11 @@ export function order( | ||
| 165 | 173 | message?: { text: string; bad?: boolean }, |
| 166 | 174 | short: Shortfall | null = null, |
| 167 | 175 | ): string { |
| 168 | return ` | |
| 169 | ${nav("orders")} | |
| 170 | <h1>${escape(found.number)} ${statusPill(found.status)}</h1> | |
| 176 | // Everything below the nav is one region keyed to this order's number: the | |
| 177 | // status, the totals, what is left to refund, the payments and the history | |
| 178 | // all move together when this order is paid, refunded or cancelled, and a | |
| 179 | // page watching a payment settle does not have to be reloaded to see it. | |
| 180 | const detail = `<h1>${escape(found.number)} ${statusPill(found.status)}</h1> | |
| 171 | 181 | <p class="lede">${escape(found.name)} · ${escape(found.email)} · ${account(found)} · ${escape(found.gateway)}${ |
| 172 | 182 | found.payment_ref ? ` · ${escape(found.payment_ref)}` : "" |
| 173 | 183 | }</p> |
| @@ -241,7 +251,10 @@ ${orderTotals(found, refunded)} | ||
| 241 | 251 | } |
| 242 | 252 | </div> |
| 243 | 253 | </div> |
| 244 | <p><a href="/orders/${escape(found.number)}?token=${escape(found.access_token)}">See what the customer sees</a></p> | |
| 254 | <p><a href="/orders/${escape(found.number)}?token=${escape(found.access_token)}">See what the customer sees</a></p>`; | |
| 255 | return ` | |
| 256 | ${nav("orders")} | |
| 257 | ${live("order-detail", ORDER_EVENTS, detail, found.number)} | |
| 245 | 258 | `; |
| 246 | 259 | } |
| 247 | 260 |
src/server.test.ts +49
| @@ -1898,6 +1898,55 @@ test("the admin's live regions are marked with stable ids", async () => { | ||
| 1898 | 1898 | expect(await admin.page("/admin/inventory")).toContain('<table class="steady">'); |
| 1899 | 1899 | }); |
| 1900 | 1900 | |
| 1901 | test("the admin's page about one order follows that order, and is keyed to it", async () => { | |
| 1902 | const admin = asAdmin(); | |
| 1903 | const shopper = asCustomer(); | |
| 1904 | await addToCart(shopper, "COF-GUJI-250", 1); | |
| 1905 | const placed = await submitCheckout(shopper); | |
| 1906 | const { number, token } = orderAt(placed.headers.get("location")!); | |
| 1907 | ||
| 1908 | const before = await admin.page(`/admin/orders/${number}`); | |
| 1909 | // The one script, the one region, and the order's own number as its key. | |
| 1910 | expect(/<script type="module" src="\/static\/live\.[0-9a-f]{8}\.js"><\/script>/.test(before)).toBe(true); | |
| 1911 | const region = /id="order-detail" data-live="([^"]+)" data-live-key="([^"]+)"/.exec(before)!; | |
| 1912 | expect(region[1]).toContain("order.paid"); | |
| 1913 | expect(region[1]).toContain("order.refunded"); | |
| 1914 | expect(region[2]).toBe(number); | |
| 1915 | ||
| 1916 | // What the region holds is what a payment changes: the same GET the script | |
| 1917 | // makes, after the shopper pays, says paid where it said awaiting payment. | |
| 1918 | const inside = (html: string): string => html.slice(html.indexOf('id="order-detail"')); | |
| 1919 | expect(inside(before)).toContain("Awaiting payment"); | |
| 1920 | expect((await payMock(shopper, number, token)).status).toBe(303); | |
| 1921 | const after = inside(await admin.page(`/admin/orders/${number}`)); | |
| 1922 | expect(after).toContain("Paid"); | |
| 1923 | expect(after).not.toContain("Awaiting payment"); | |
| 1924 | expect(after).toContain("Refund"); | |
| 1925 | }); | |
| 1926 | ||
| 1927 | test("a keyed region wants the events that name its subject and no others", async () => { | |
| 1928 | // The file the browser is served, imported as it is: outside a document it | |
| 1929 | // opens no stream, so this is the shipped predicate and not a copy of it. | |
| 1930 | const { wants } = (await import("../static/live.js")) as { | |
| 1931 | wants: (element: { dataset: { live: string; liveKey?: string } }, event?: { type: string; data: unknown }) => boolean; | |
| 1932 | }; | |
| 1933 | const region = (live: string, liveKey?: string) => ({ dataset: { live, liveKey } }); | |
| 1934 | const one = region("order.paid order.refunded", "BZ-ONE"); | |
| 1935 | ||
| 1936 | expect(wants(one, { type: "order.paid", data: { id: 3, number: "BZ-ONE", cents: 900 } })).toBe(true); | |
| 1937 | expect(wants(one, { type: "order.paid", data: { id: 3, number: "BZ-TWO", cents: 900 } })).toBe(false); | |
| 1938 | expect(wants(one, { type: "order.placed", data: { number: "BZ-ONE" } })).toBe(false); | |
| 1939 | // Nothing in it names the subject, so it is not about the subject. | |
| 1940 | expect(wants(one, { type: "order.refunded", data: null })).toBe(false); | |
| 1941 | // A reconnect asks for everything: whatever was missed, every region catches up. | |
| 1942 | expect(wants(one, undefined)).toBe(true); | |
| 1943 | ||
| 1944 | // A region with no key is every subject's: the lists are all of them. | |
| 1945 | const list = region("order.paid stock.moved"); | |
| 1946 | expect(wants(list, { type: "stock.moved", data: { id: 1, sku: "COF-GUJI-250" } })).toBe(true); | |
| 1947 | expect(wants(list, { type: "order.cancelled", data: { number: "BZ-TWO" } })).toBe(false); | |
| 1948 | }); | |
| 1949 | ||
| 1901 | 1950 | test("this file's rows are this file's own", async () => { |
| 1902 | 1951 | // The isolation every test file depends on: the database the app modules |
| 1903 | 1952 | // are querying is the one this file opened, not one another file chose. |
static/live.js +31 −7
| @@ -2,7 +2,9 @@ | ||
| 2 | 2 | // of what changed in the ledger; each event becomes `live` and `live:<type>` |
| 3 | 3 | // on `document`, and any element with data-live="<types>" is refetched from |
| 4 | 4 | // the page it is already on and swapped by id — server-rendered, so nothing |
| 5 | // is ever rendered twice, in two places, in two ways. | |
| 5 | // is ever rendered twice, in two places, in two ways. A region may also carry | |
| 6 | // data-live-key="<subject>", and then it wants only the events that name that | |
| 7 | // subject: the page about one order ignores every other order's. | |
| 6 | 8 | // |
| 7 | 9 | // One refetch is in flight at a time; another event while one is running |
| 8 | 10 | // coalesces into a single run after it. Without this file the page is the |
| @@ -42,10 +44,24 @@ function connect() { | ||
| 42 | 44 | let refreshing = false; |
| 43 | 45 | let again = false; |
| 44 | 46 | |
| 45 | async function refresh(types) { | |
| 46 | const regions = [...document.querySelectorAll("[data-live]")].filter( | |
| 47 | (element) => types === undefined || element.dataset.live.split(" ").some((type) => types.includes(type)), | |
| 48 | ); | |
| 47 | // True when the event says it is about this subject. An event is flat data — | |
| 48 | // an id, an order number, a SKU — so a value of it equal to the region's key | |
| 49 | // is the whole test, and the bus never has to know what a page is showing. | |
| 50 | const about = (data, key) => | |
| 51 | data !== null && typeof data === "object" && Object.values(data).some((value) => String(value) === key); | |
| 52 | ||
| 53 | // Whether this event is worth refetching this region for: the right type, and | |
| 54 | // the right subject when the region asked for one. An event with no `key` in | |
| 55 | // it reaches a keyed region no more than somebody else's order does. | |
| 56 | function wants(element, event) { | |
| 57 | if (event === undefined) return true; // A reconnect: catch every region up. | |
| 58 | if (!element.dataset.live.split(" ").includes(event.type)) return false; | |
| 59 | const key = element.dataset.liveKey; | |
| 60 | return key === undefined || key === "" || about(event.data, key); | |
| 61 | } | |
| 62 | ||
| 63 | async function refresh(event) { | |
| 64 | const regions = [...document.querySelectorAll("[data-live]")].filter((element) => wants(element, event)); | |
| 49 | 65 | if (regions.length === 0) return; |
| 50 | 66 | if (refreshing) { |
| 51 | 67 | again = true; |
| @@ -75,5 +91,13 @@ async function refresh(types) { | ||
| 75 | 91 | } |
| 76 | 92 | } |
| 77 | 93 | |
| 78 | document.addEventListener("live", (event) => refresh([event.detail.type])); | |
| 79 | connect(); | |
| 94 | // In a browser this is the whole of the file's effect. Outside one — the test | |
| 95 | // that imports this same file to hold `wants` up against a region — there is | |
| 96 | // no document to listen to and no stream to open, and importing it does | |
| 97 | // nothing at all. The bytes a page gets are these bytes; nothing is built. | |
| 98 | if (typeof document !== "undefined") { | |
| 99 | document.addEventListener("live", (event) => refresh(event.detail)); | |
| 100 | connect(); | |
| 101 | } | |
| 102 | ||
| 103 | export { wants }; |
Approved, at
590a0e3. 115 lines, it closes the one admin page #11 left behind, and it touches no money path, no identity check and nothing a stranger sees first.What I checked rather than took on trust.
--randomize. Then I revertedsrc/admin.ts,src/pages/admin.tsandstatic/live.jstomainand kept the tests — both new ones fail (no<script type="module">on the order page, andReferenceError: document is not definedwhere the file is imported). They are behaviours, not decoration.FORGE_APP_IDENTITY_KEYset and a signed identity: placed an order as a signed-in user, heldGET /admin/eventsopen as the admin, paid at/pay/<n>/mock. The stream carried{"type":"order.paid","data":{"id":1,"number":"BZ-CA7Y7ZD4","cents":2662}}—numberis in the payload, which is exactly whatwantsmatches the region key against, and all four oforder.placed|paid|refunded|cancelledcarry it. The refetch the script would make goes fromAwaiting paymenttoPaidwith the refund form in place./admin,/admin/orders/<n>and/admin/eventsare all 403 to an unsigned caller, so the region — and the customer access token in the link inside it — stays behind the sameguard(ctx)it always was./static/live.dc63eb5b.jsis the repo byte for byte (4183 bytes,immutable,script-src 'self', no inline), and a stale hash 404s.{id, number, cents}and learns nothing about what a page shows;about()only stringifies values it was already handed. No new input crosses a boundary, no new dependency, nothing dead. Exportingwantsbehindtypeof document !== "undefined"leaves the browser's behaviour identical and lets the test hold the shipped predicate up instead of a copy — I prefer that to a duplicate. The terminal already reads one order's trail underdesk.canReadand its writes reach open pages through the row-and-poll bus, so no parity is lost here; leaving its input loop unsubscribed is the right call for this pull.Two things to know, neither blocking.
refund-failedall write rows into the payments table and the history this region holds, and emit nothing — so those are the one order change an admin sitting here still has to reload to see. Opened #83 with the shape for it.Nit for whenever the file is next open: the README hunk leaves one very long unwrapped line, the one ending
and none of them has an inline one:.