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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

ledger/bazaar:ledger/30-admin-sees-stock-and-orders-change-live → main 2 commits fork 1 thread · 1 unresolved

  • Merged. ledger/30-admin-sees-stock-and-orders-change-live was merged into main as 516c8ac.

Review

Sign in to review this change.
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
2626
2727 It has no dependencies: Bun's `bun:sqlite`, `node:crypto` and the standard
2828 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
3030 [The admin sees it happen](#the-admin-sees-it-happen).
3131
3232 ## What is real and what is simulated
@@ -415,16 +415,22 @@ what it wrote, and the page listens.
415415 marked `data-live` by their `id`. One refetch is in flight at a time and
416416 the next event coalesces into it, so a busy shop is not a poll. A region
417417 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.
418422
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.
428434
429435 The bus is a table, so the terminal (`bun run src/tty.ts`, or `ssh`) is not a
430436 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 {
365365 if (!order) return notFound(ctx);
366366 return render(
367367 ctx,
368 { title: order.number, admin: true },
368 { title: order.number, admin: true, live: true },
369369 pages.order(
370370 order,
371371 orders.itemsOf(order.id),
src/pages/admin.ts +22 −9
@@ -3,9 +3,10 @@
33 * and the outbox. Everything an admin can do is a form with a CSRF token and
44 * a POST; nothing changes on a GET.
55 *
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
910 * markup is the markup either way — with the script off, the page is what it
1011 * always was, one reload behind.
1112 */
@@ -32,9 +33,16 @@ const STOCK_EVENTS = "stock.moved";
3233 /**
3334 * A region the stream keeps current. The id is what static/live.js swaps by,
3435 * 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.
3541 */
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>`;
3846
3947 export const nav = (current: string): string =>
4048 `<nav>${[
@@ -165,9 +173,11 @@ export function order(
165173 message?: { text: string; bad?: boolean },
166174 short: Shortfall | null = null,
167175 ): 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>
171181 <p class="lede">${escape(found.name)} · ${escape(found.email)} · ${account(found)} · ${escape(found.gateway)}${
172182 found.payment_ref ? ` · ${escape(found.payment_ref)}` : ""
173183 }</p>
@@ -241,7 +251,10 @@ ${orderTotals(found, refunded)}
241251 }
242252 </div>
243253 </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)}
245258 `;
246259 }
247260
src/server.test.ts +49
@@ -1898,6 +1898,55 @@ test("the admin's live regions are marked with stable ids", async () => {
18981898 expect(await admin.page("/admin/inventory")).toContain('<table class="steady">');
18991899 });
19001900
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
19011950 test("this file's rows are this file's own", async () => {
19021951 // The isolation every test file depends on: the database the app modules
19031952 // are querying is the one this file opened, not one another file chose.
static/live.js +31 −7
@@ -2,7 +2,9 @@
22 // of what changed in the ledger; each event becomes `live` and `live:<type>`
33 // on `document`, and any element with data-live="<types>" is refetched from
44 // 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.
68 //
79 // One refetch is in flight at a time; another event while one is running
810 // coalesces into a single run after it. Without this file the page is the
@@ -42,10 +44,24 @@ function connect() {
4244 let refreshing = false;
4345 let again = false;
4446
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) {

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.

  • The suite at this head: 133 pass, and again under --randomize. Then I reverted src/admin.ts, src/pages/admin.ts and static/live.js to main and kept the tests — both new ones fail (no <script type="module"> on the order page, and ReferenceError: document is not defined where the file is imported). They are behaviours, not decoration.
  • The whole path against a local server with FORGE_APP_IDENTITY_KEY set and a signed identity: placed an order as a signed-in user, held GET /admin/events open as the admin, paid at /pay/<n>/mock. The stream carried {"type":"order.paid","data":{"id":1,"number":"BZ-CA7Y7ZD4","cents":2662}} — number is in the payload, which is exactly what wants matches the region key against, and all four of order.placed|paid|refunded|cancelled carry it. The refetch the script would make goes from Awaiting payment to Paid with the refund form in place.
  • The doors on the preview: /admin, /admin/orders/<n> and /admin/events are all 403 to an unsigned caller, so the region — and the customer access token in the link inside it — stays behind the same guard(ctx) it always was. /static/live.dc63eb5b.js is the repo byte for byte (4183 bytes, immutable, script-src 'self', no inline), and a stale hash 404s.
  • The narrowing: the key is the order's own generated number, escaped into the attribute; the bus still emits {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. Exporting wants behind typeof 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 under desk.canRead and 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.

  1. The page now promises liveness, which makes the bus's gap visible: a short payment, a mock decline and refund-failed all 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.
  2. Everything below the nav is one region, so a refund amount someone typed survives only while the caret is in it: blur, then somebody else's event lands, and it is replaced by the freshly rendered figure. Same as the tables that were already live, and the safer direction (acting on a stale refundable amount is the worse failure), but worth knowing.

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:.

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));
4965 if (regions.length === 0) return;
5066 if (refreshing) {
5167 again = true;
@@ -75,5 +91,13 @@ async function refresh(types) {
7591 }
7692 }
7793
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 };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs