Public: anyone can read this; the crew can change it.
The admin sees orders, payments and stock change live #47
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/11-the-admin-sees-orders-payments-and-stock → main 5 commits fork 2 threads · 2 unresolved
README.md +91 −8
| @@ -23,7 +23,9 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 23 | 23 | |
| 24 | 24 | |
| 25 | 25 | It has no dependencies: Bun's `bun:sqlite`, `node:crypto` and the standard |
| 26 | library are all of it. There is no client-side JavaScript. | |
| 26 | library are all of it. The shop has no client-side JavaScript at all; the | |
| 27 | admin's three live pages load one small file — see | |
| 28 | [](#the-admin-sees-it-happen). | |
| 27 | 29 | |
| 28 | 30 | ## What is real and what is simulated |
| 29 | 31 | |
| @@ -270,6 +272,81 @@ for why, and for the one declaration that changes it. Reading a gift card's | ||
| 270 | 272 | balance gets a bucket of its own there, separate from typing a code at the |
| 271 | 273 | till: someone reading their card over and over must still be able to spend it. |
| 272 | 274 | |
| 275 | ## The admin sees it happen | |
| 276 | ||
| 277 | An admin page is true at the instant it was rendered, and an order placed by | |
| 278 | a stranger a second later would leave it quietly wrong. So the ledger says | |
| 279 | what it wrote, and the page listens. | |
| 280 | ||
| 281 | - `src/live.ts` is one bus: `emit(type, data)` writes a `live_event` row, the | |
| 282 | last hundred of which are kept, and `GET /admin/events` is a server-sent | |
| 283 | event stream with a `retry:`, a heartbeat every 25 seconds, and a replay of | |
| 284 | everything after the `Last-Event-ID` (or `?since=`) a reconnecting browser | |
| 285 | brings with it — row ids, so they still mean something after a restart. The | |
| 286 | stream is admin-only, and it is the only route this adds. A listener is | |
| 287 | dropped when the request aborts or the stream is cancelled, so a reload | |
| 288 | leaves nothing behind, and a connection that has stopped reading is dropped | |
| 289 | once a couple of hundred frames have queued on it rather than held. | |
| 290 | - **A stream has a life.** Every other door here proves who is asking on | |
| 291 | every request; a stream is one request that lasts, so it would otherwise be | |
| 292 | checked once and then trusted for as long as the socket stayed open — an | |
| 293 | admin the forge has demoted, or an identity that has expired, going on | |
| 294 | hearing every order and every figure in cents. So `/admin/events` closes | |
| 295 | the connection at the earlier of four minutes and the expiry the forge | |
| 296 | signed into `x-forge-identity`. The browser reconnects by itself after the | |
| 297 | `retry:` and is checked again on the way back in; `Last-Event-ID` replays | |
| 298 | the two seconds in between, so a page that is still an admin's loses | |
| 299 | nothing and one that is not is answered `403` and backs off. | |
| 300 | - An event is a row and not a message in memory because **the app is two | |
| 301 | processes over one database**: the web server and the terminal. `emit` | |
| 302 | writes the row; whichever process is serving streams hands out the rows it | |
| 303 | has not handed out yet — its own at once, the other process's within a | |
| 304 | second, both through one path in id order. A connection also remembers the | |
| 305 | highest id it has been given, so a replay that runs ahead of that cursor — | |
| 306 | rows the terminal wrote a moment ago — is not said a second time by the | |
| 307 | poll catching up behind it: an event reaches a page exactly once, and never | |
| 308 | out of order. The poll runs only while somebody is | |
| 309 | listening. So stock moved with `s` in the terminal, or an order refunded | |
| 310 | with `r`, shows up on an open `/admin` in about a second, the same as if it | |
| 311 | had been done in the browser. | |
| 312 | - `emit` is called where a ledger row is written and nowhere else: an order | |
| 313 | placed, an order paid — through the gateway, through a gift card that | |
| 314 | covered the lot, or through Stripe's webhook, which all end in one | |
| 315 | `settleFromGateway` — a refund, a cancellation, and a stock movement. Only | |
| 316 | the branch that actually moves the row to `paid` says `order.paid`: a | |
| 317 | charge that falls short of what the order owes is written to the ledger and | |
| 318 | leaves the order awaiting payment, and the admin's screen is told nothing, | |
| 319 | because a screen that said paid there would be lying about the money. Every call | |
| 320 | happens **after** the transaction commits. A stock movement made inside a | |
| 321 | bigger transaction (an order taking its stock) is announced by whoever | |
| 322 | commits that transaction, because a rollback would otherwise have told an | |
| 323 | admin about an order that does not exist. | |
| 324 | - An event says an id, an order number, a figure in cents and a SKU. It | |
| 325 | never says an address, an email, a gift card code or a gateway reference: | |
| 326 | the page is where the private things are, and the page is behind the | |
| 327 | admin's door. | |
| 328 | - `static/live.js` turns the stream into `live` and `live:<type>` events on | |
| 329 | `document` and refetches the page it is already on, replacing the regions | |
| 330 | marked `data-live` by their `id`. One refetch is in flight at a time and | |
| 331 | the next event coalesces into it, so a busy shop is not a poll. A region | |
| 332 | someone is typing in is left alone until the caret moves on. | |
| 333 | ||
| 334 | Live pages: **`/admin`** (the totals and the latest orders), **`/admin/orders`** | |
| 335 | and **`/admin/inventory`** (both tables — stock on hand and the ledger). | |
| 336 | Their tables have fixed columns, so a row arriving cannot move the rows | |
| 337 | around it. No other page loads a script, and none of them has an inline one: | |
| 338 | the floor's `script-src` is `'self'`, with no `unsafe-inline` and no | |
| 339 | `unsafe-eval`, and `static/live.js` is served under a name made from its own | |
| 340 | contents (`/static/live.<hash>.js`) with a year-long cache, the same trick | |
| 341 | the stylesheet plays. With JavaScript off, every one of those pages is the | |
| 342 | page it always was — one reload behind. | |
| 343 | ||
| 344 | The bus is a table, so the terminal (`bun run src/tty.ts`, or `ssh`) is not a | |
| 345 | blind spot: it is a second process on the same database, its `emit` writes a | |
| 346 | row like any other, and the web process notices it on its next pass — about a | |
| 347 | second. Nothing in the app can move stock or money without an open `/admin` | |
| 348 | saying so. | |
| 349 | ||
| 273 | 350 | ## Shape |
| 274 | 351 | |
| 275 | 352 | | File | One concept | |
| @@ -289,7 +366,9 @@ till: someone reading their card over and over must still be able to spend it. | ||
| 289 | 366 | | `src/gateway.ts` `src/mail.ts` | Stripe or the mock, the outbox | |
| 290 | 367 | | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` | |
| 291 | 368 | | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents | |
| 292 | | `src/units.test.ts` `src/server.test.ts` `src/stripe.test.ts` `src/proxy.test.ts` `src/https.test.ts` | The rules on their own, the shop over HTTP, the Stripe path against a fake Stripe, the app booted behind a declared proxy, the app booted on an https public address | | |
| 369 | | `src/live.ts` `static/live.js` | The bus: a `live_event` row, and the stream that hands rows out across both processes; the one script, which refetches the `data-live` regions of the page it is on | | |
| 370 | | `src/asset.ts` | A file under `static/`, addressed by its contents: the hash in the path has to be the hash of the bytes | | |
| 371 | | `src/units.test.ts` `src/live.test.ts` `src/server.test.ts` `src/stripe.test.ts` `src/proxy.test.ts` `src/https.test.ts` | The rules on their own, the bus on its own, the shop over HTTP, the Stripe path against a fake Stripe, the app booted behind a declared proxy, the app booted on an https public address | | |
| 293 | 372 | |
| 294 | 373 | ### What the door refuses |
| 295 | 374 | |
| @@ -305,7 +384,8 @@ is not a form is `415`, one that is not same-origin or is missing the session's | ||
| 305 | 384 | CSRF token is `403`. |
| 306 | 385 | |
| 307 | 386 | The security floor on every response is in `src/server.ts`: a content security |
| 308 | policy with `script-src 'none'` and `form-action 'self'` (widened for one | |
| 387 | policy with `script-src 'self'` — the one loosening this app has made, for the | |
| 388 | one module under `static/` — and `form-action 'self'` (widened for one | |
| 309 | 389 | response only when a form hands the shopper to Stripe), `nosniff`, |
| 310 | 390 | `strict-origin-when-cross-origin`, a permissions policy, and — only when |
| 311 | 391 | `FORGE_APP_URL` is https, as it is on the forge — `strict-transport-security: |
| @@ -325,11 +405,14 @@ the suite passes in any order — `bun test`, `bun test --isolate`, | ||
| 325 | 405 | |
| 326 | 406 | ## The terminal |
| 327 | 407 | |
| 328 | The same shop over the same data. The exact command is in `FORGE_APP_SSH` | |
| 329 | when the app runs; the footer of every page prints it verbatim, and says the | |
| 330 | terminal is off when it is unset. `1`–`7` switch between stock, orders, the | |
| 331 | outbox, discount codes, gift cards, the tax table and the changes an admin | |
| 332 | made to any of it — everything `/admin` | |
| 408 | The same shop over the same data, in a second process. The exact command is | |
| 409 | in `FORGE_APP_SSH` when the app runs; the footer of every page prints it | |
| 410 | verbatim, and says the terminal is off when it is unset. What is typed here | |
| 411 | is written to the same database, and — because an event is a row in it — a | |
| 412 | stock move or a refund made here reaches an admin's open `/admin` over the | |
| 413 | live stream within about a second, not on that page's next load. `1`–`7` | |
| 414 | switch between stock, orders, the outbox, discount codes, gift cards, the tax | |
| 415 | table and the changes an admin made to any of it — everything `/admin` | |
| 333 | 416 | can see — and `j`/`k` move. A tab whose rows are the shop's own business is an |
| 334 | 417 | admin's to open: a non-admin gets stock and tax, the other five are marked |
| 335 | 418 | with a `·` in the tab bar and answer "That is an admin's to do." without |
src/admin.ts +28 −5
| @@ -14,15 +14,16 @@ import * as giftcards from "./giftcards.ts"; | ||
| 14 | 14 | import * as tax from "./tax.ts"; |
| 15 | 15 | import * as gateway from "./gateway.ts"; |
| 16 | 16 | import * as inventory from "./inventory.ts"; |
| 17 | import * as live from "./live.ts"; | |
| 17 | 18 | import * as orders from "./orders.ts"; |
| 18 | 19 | import * as pages from "./pages/admin.ts"; |
| 19 | 20 | import { db, type Variant } from "./db.ts"; |
| 20 | import { field, intField, redirect } from "./http.ts"; | |
| 21 | import { field, intField, redirect, text } from "./http.ts"; | |
| 21 | 22 | import { forOrder, outbox } from "./mail.ts"; |
| 22 | 23 | import { formatCents, parseCents } from "./money.ts"; |
| 23 | 24 | import { render, type Ctx } from "./context.ts"; |
| 24 | 25 | import { notFound } from "./shop.ts"; |
| 25 | import { identityConfigured, returnTo } from "./who.ts"; | |
| 26 | import { expiresAt, identityConfigured, returnTo } from "./who.ts"; | |
| 26 | 27 | |
| 27 | 28 | const MESSAGES: Record<string, { text: string; bad?: boolean }> = { |
| 28 | 29 | saved: { text: "Saved." }, |
| @@ -100,7 +101,29 @@ export function dashboard(ctx: Ctx): Response { | ||
| 100 | 101 | (SELECT COUNT(*) FROM product) AS products`, |
| 101 | 102 | ) |
| 102 | 103 | .get()!; |
| 103 | return render(ctx, { title: "Admin", admin: true }, pages.overview({ ...numbers, ...counts }, orders.recent(10))); | |
| 104 | return render( | |
| 105 | ctx, | |
| 106 | { title: "Admin", admin: true, live: true }, | |
| 107 | pages.overview({ ...numbers, ...counts }, orders.recent(10)), | |
| 108 | ); | |
| 109 | } | |
| 110 | ||
| 111 | /** | |
| 112 | * The one stream. It is admin-only like every page here, and it answers in | |
| 113 | * plain text rather than a page: whatever opened it is a script, not a | |
| 114 | * reader. Everything it carries was emitted where a ledger row was written. | |
| 115 | * | |
| 116 | * A page is checked on every request; a stream would be checked once and then | |
| 117 | * trusted for as long as the socket lived, which is how an admin the forge | |
| 118 | * has demoted or an identity that has expired would go on hearing every order | |
| 119 | * and every figure in cents. So the connection is given an end — the earlier | |
| 120 | * of a few minutes and the expiry the forge signed — and the browser comes | |
| 121 | * back through this very line to be checked again, replaying the gap with | |
| 122 | * `Last-Event-ID` so nothing is missed on the way. | |
| 123 | */ | |
| 124 | export function events(ctx: Ctx): Response { | |
| 125 | if (ctx.who.role !== "admin") return text("forbidden", 403); | |
| 126 | return live.events(ctx.request, Math.min(Date.now() + live.MAX_LIFE_MS, expiresAt(ctx.request.headers))); | |
| 104 | 127 | } |
| 105 | 128 | |
| 106 | 129 | // --- products --------------------------------------------------------------- |
| @@ -269,7 +292,7 @@ export function inventoryList(ctx: Ctx): Response { | ||
| 269 | 292 | .all(); |
| 270 | 293 | return render( |
| 271 | 294 | ctx, |
| 272 | { title: "Inventory", admin: true }, | |
| 295 | { title: "Inventory", admin: true, live: true }, | |
| 273 | 296 | pages.inventory(ctx.session, rows, inventory.history(40), messageFrom(ctx)), |
| 274 | 297 | ); |
| 275 | 298 | } |
| @@ -294,7 +317,7 @@ export function orderList(ctx: Ctx): Response { | ||
| 294 | 317 | const blocked = guard(ctx); |
| 295 | 318 | if (blocked) return blocked; |
| 296 | 319 | const query = (ctx.url.searchParams.get("q") ?? "").slice(0, 80); |
| 297 | return render(ctx, { title: "Orders", admin: true }, pages.orders(orders.recent(100, query), query)); | |
| 320 | return render(ctx, { title: "Orders", admin: true, live: true }, pages.orders(orders.recent(100, query), query)); | |
| 298 | 321 | } |
| 299 | 322 | |
| 300 | 323 | export function orderDetail(ctx: Ctx): Response { |
src/asset.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** | |
| 2 | * A file under static/, addressed by its own contents. | |
| 3 | * | |
| 4 | * `pathOf("live.js")` is `/static/live.<hash>.js`, where the hash is of the | |
| 5 | * bytes: change the file and the address changes, so the year the browser | |
| 6 | * keeps it for is never a year of staleness. It is the trick src/style.ts | |
| 7 | * plays on the stylesheet, for the things that are files on disk. | |
| 8 | * | |
| 9 | * `fileOf` is the reverse, and it is strict on purpose: a path whose hash is | |
| 10 | * not the one the file has now is not found at all, rather than answered with | |
| 11 | * bytes a browser would then hold for a year under the wrong name. | |
| 12 | */ | |
| 13 | ||
| 14 | import { createHash } from "node:crypto"; | |
| 15 | import { existsSync, readFileSync } from "node:fs"; | |
| 16 | import { join } from "node:path"; | |
| 17 | ||
| 18 | const DIR = join(import.meta.dir, "..", "static"); | |
| 19 | ||
| 20 | /** `live.<hash>.js` and nothing else: no directory, no `..`, no third dot. */ | |
| 21 | const ADDRESS = /^\/static\/([a-z0-9-]{1,40})\.([0-9a-f]{8})\.(css|js)$/; | |
| 22 | ||
| 23 | /** Files do not change under a running process, so each is hashed once. */ | |
| 24 | const hashes = new Map<string, string>(); | |
| 25 | ||
| 26 | function hashOf(name: string): string | null { | |
| 27 | const known = hashes.get(name); | |
| 28 | if (known !== undefined) return known; | |
| 29 | const where = join(DIR, name); | |
| 30 | if (!existsSync(where)) return null; | |
| 31 | const digest = createHash("sha256").update(readFileSync(where)).digest("hex").slice(0, 8); | |
| 32 | hashes.set(name, digest); | |
| 33 | return digest; | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * The address a page links. A name with no file behind it links the plain | |
| 38 | * name — that is a missing file in the repository, and it should show up as | |
| 39 | * one 404 rather than as a crash at boot. | |
| 40 | */ | |
| 41 | export function pathOf(name: string): string { | |
| 42 | const hash = hashOf(name); | |
| 43 | if (hash === null) return `/static/${name}`; | |
| 44 | const dot = name.lastIndexOf("."); | |
| 45 | return `/static/${name.slice(0, dot)}.${hash}${name.slice(dot)}`; | |
| 46 | } | |
| 47 | ||
| 48 | /** The file a `/static/` path means, or null when it means nothing of ours. */ | |
| 49 | export function fileOf(pathname: string): string | null { | |
| 50 | const parts = ADDRESS.exec(pathname); | |
| 51 | if (!parts) return null; | |
| 52 | const name = `${parts[1]}.${parts[3]}`; | |
| 53 | return hashOf(name) === parts[2] ? join(DIR, name) : null; | |
| 54 | } |
src/context.ts +2 −1
| @@ -47,7 +47,7 @@ export const cartCount = (cart: CartLine[]): number => cart.reduce((n, line) => | ||
| 47 | 47 | |
| 48 | 48 | export function render( |
| 49 | 49 | ctx: Ctx, |
| 50 | options: { title: string; current?: string; query?: string; admin?: boolean; status?: number }, | |
| 50 | options: { title: string; current?: string; query?: string; admin?: boolean; live?: boolean; status?: number }, | |
| 51 | 51 | body: string, |
| 52 | 52 | ): Response { |
| 53 | 53 | return html( |
| @@ -60,6 +60,7 @@ export function render( | ||
| 60 | 60 | current: options.current, |
| 61 | 61 | query: options.query, |
| 62 | 62 | admin: options.admin, |
| 63 | live: options.live, | |
| 63 | 64 | body, |
| 64 | 65 | }), |
| 65 | 66 | options.status ?? 200, |
src/db.ts +16 −1
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | * balance or an order leaves a row behind saying why (stock_entry, payment, |
| 7 | 7 | * gift_card_entry, discount_redemption, order_event). A product's stock and a |
| 8 | 8 | * gift card's balance are the running totals of their ledgers, never a bare |
| 9 | * edit. | |
| 9 | * edit. `live_event` is the youngest of those rows: what changed, said once, | |
| 10 | * so the admin pages can follow it across both of the app's processes. | |
| 10 | 11 | * |
| 11 | 12 | * `open(dir)` is the whole of it: a directory in, a usable database out. The |
| 12 | 13 | * app keeps one of those — `db`, under FORGE_APP_DATA — and a test keeps its |
| @@ -232,6 +233,20 @@ CREATE TABLE IF NOT EXISTS tax_rate ( | ||
| 232 | 233 | active INTEGER NOT NULL DEFAULT 1 |
| 233 | 234 | ); |
| 234 | 235 | CREATE UNIQUE INDEX IF NOT EXISTS tax_rate_place ON tax_rate(country, region); |
| 236 | ||
| 237 | -- What changed, for the admin pages that follow it: one row per event, the | |
| 238 | -- last hundred kept. It is a table and not a queue in memory because the app | |
| 239 | -- is two processes over one database — the web server and the terminal — and | |
| 240 | -- a stock move typed at the ssh door has to reach a page the web server is | |
| 241 | -- streaming to. The id is the event id a browser reconnects with, so it | |
| 242 | -- outlives a restart. The data column is small JSON: see src/live.ts for what | |
| 243 | -- may be in it, which is never anything a customer owns. | |
| 244 | CREATE TABLE IF NOT EXISTS live_event ( | |
| 245 | id INTEGER PRIMARY KEY, | |
| 246 | type TEXT NOT NULL, | |
| 247 | data TEXT NOT NULL DEFAULT 'null', | |
| 248 | at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 249 | ); | |
| 235 | 250 | `; |
| 236 | 251 | |
| 237 | 252 | /** |
src/html.ts +12 −4
| @@ -2,12 +2,14 @@ | ||
| 2 | 2 | * HTML: escaping, and the shell every page is poured into. |
| 3 | 3 | * |
| 4 | 4 | * Nothing here interpolates a value that has not been through `escape`, and |
| 5 | * every form carries the session's CSRF token. There is no client-side | |
| 6 | * JavaScript in this store — the shell links a single stylesheet and the | |
| 7 | * browser does the rest. | |
| 5 | * every form carries the session's CSRF token. The shop has no client-side | |
| 6 | * JavaScript at all — the shell links a single stylesheet and the browser | |
| 7 | * does the rest. The admin pages with live regions on them link one file, | |
| 8 | * static/live.js, and nothing anywhere is an inline script. | |
| 8 | 9 | */ |
| 9 | 10 | |
| 10 | 11 | import { CSS_PATH } from "./style.ts"; |
| 12 | import { pathOf } from "./asset.ts"; | |
| 11 | 13 | import { FORGE_LINK_ATTRS, SIGN_OUT_HREF, signInHref } from "./who.ts"; |
| 12 | 14 | import type { Session } from "./session.ts"; |
| 13 | 15 | import type { Who } from "./who.ts"; |
| @@ -50,6 +52,8 @@ export type Chrome = { | ||
| 50 | 52 | current?: string; |
| 51 | 53 | query?: string; |
| 52 | 54 | admin?: boolean; |
| 55 | /** Set on the admin pages that have `data-live` regions, and nowhere else. */ | |
| 56 | live?: boolean; | |
| 53 | 57 | body: string; |
| 54 | 58 | }; |
| 55 | 59 | |
| @@ -70,7 +74,11 @@ export function layout(chrome: Chrome): string { | ||
| 70 | 74 | <meta charset="utf-8"> |
| 71 | 75 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 72 | 76 | <title>${escape(chrome.title)} · bazaar</title> |
| 73 | <link rel="stylesheet" href="${CSS_PATH}"> | |
| 77 | <link rel="stylesheet" href="${CSS_PATH}">${ | |
| 78 | chrome.live | |
| 79 | ? `\n<script type="module" src="${escape(pathOf("live.js"))}"></script>` | |
| 80 | : "" | |
| 81 | } | |
| 74 | 82 | <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>🛍️</text></svg>"> |
| 75 | 83 | <body class="${chrome.admin ? "admin" : ""}"> |
| 76 | 84 | <header class="top"><div class="wrap"> |
src/inventory.ts +23 −7
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { db } from "./db.ts"; |
| 9 | import { emit } from "./live.ts"; | |
| 9 | 10 | |
| 10 | 11 | export type StockEntry = { |
| 11 | 12 | id: number; |
| @@ -28,6 +29,18 @@ const record = (variantId: number, delta: number, reason: string, orderId?: numb | ||
| 28 | 29 | ); |
| 29 | 30 | }; |
| 30 | 31 | |
| 32 | /** | |
| 33 | * A move that has happened tells the admin's stream about it — the SKU and | |
| 34 | * nothing else. Inside a bigger transaction, which is how an order takes its | |
| 35 | * stock, there is nothing to announce yet: that row is not real until the | |
| 36 | * transaction around it commits, so whoever commits it does the announcing. | |
| 37 | */ | |
| 38 | const announce = (variantId: number): void => { | |
| 39 | if (db.inTransaction) return; | |
| 40 | const row = db.query<{ sku: string }, [number]>("SELECT sku FROM variant WHERE id = ?1").get(variantId); | |
| 41 | if (row) emit("stock.moved", { id: variantId, sku: row.sku }); | |
| 42 | }; | |
| 43 | ||
| 31 | 44 | /** Add stock (a delivery, a correction, a restock after a refund). */ |
| 32 | 45 | export function addStock(variantId: number, quantity: number, reason: string, orderId?: number): void { |
| 33 | 46 | if (quantity <= 0) return; |
| @@ -35,6 +48,7 @@ export function addStock(variantId: number, quantity: number, reason: string, or | ||
| 35 | 48 | db.query("UPDATE variant SET stock = stock + ?2 WHERE id = ?1").run(variantId, quantity); |
| 36 | 49 | record(variantId, quantity, reason, orderId); |
| 37 | 50 | })(); |
| 51 | announce(variantId); | |
| 38 | 52 | } |
| 39 | 53 | |
| 40 | 54 | /** |
| @@ -43,7 +57,7 @@ export function addStock(variantId: number, quantity: number, reason: string, or | ||
| 43 | 57 | */ |
| 44 | 58 | export function takeStock(variantId: number, quantity: number, reason: string, orderId?: number): boolean { |
| 45 | 59 | if (quantity <= 0) return true; |
| 46 | return db.transaction(() => { | |
| 60 | const took = db.transaction(() => { | |
| 47 | 61 | const changes = db |
| 48 | 62 | .query("UPDATE variant SET stock = stock - ?2 WHERE id = ?1 AND stock >= ?2") |
| 49 | 63 | .run(variantId, quantity).changes; |
| @@ -51,6 +65,8 @@ export function takeStock(variantId: number, quantity: number, reason: string, o | ||
| 51 | 65 | record(variantId, -quantity, reason, orderId); |
| 52 | 66 | return true; |
| 53 | 67 | })(); |
| 68 | if (took) announce(variantId); | |
| 69 | return took; | |
| 54 | 70 | } |
| 55 | 71 | |
| 56 | 72 | /** Set stock to a number by writing the difference as a correction. */ |
| @@ -59,12 +75,12 @@ export function setStock(variantId: number, target: number, reason: string): voi | ||
| 59 | 75 | if (!current) return; |
| 60 | 76 | const delta = target - current.stock; |
| 61 | 77 | if (delta === 0) return; |
| 62 | if (delta > 0) addStock(variantId, delta, reason); | |
| 63 | else | |
| 64 | db.transaction(() => { | |
| 65 | db.query("UPDATE variant SET stock = stock + ?2 WHERE id = ?1").run(variantId, delta); | |
| 66 | record(variantId, delta, reason); | |
| 67 | })(); | |
| 78 | if (delta > 0) return addStock(variantId, delta, reason); | |
| 79 | db.transaction(() => { | |
| 80 | db.query("UPDATE variant SET stock = stock + ?2 WHERE id = ?1").run(variantId, delta); | |
| 81 | record(variantId, delta, reason); | |
| 82 | })(); | |
| 83 | announce(variantId); | |
| 68 | 84 | } |
| 69 | 85 | |
| 70 | 86 | export const history = (limit = 50): StockEntry[] => |
src/live.test.ts added +235
| @@ -0,0 +1,235 @@ | ||
| 1 | /** | |
| 2 | * The bus, on its own: numbering, the replay, the listeners, and the row in | |
| 3 | * the database that lets a second process be heard. | |
| 4 | * | |
| 5 | * The ids in these tests are the ids `emit` hands back, never numbers written | |
| 6 | * down here. | |
| 7 | */ | |
| 8 | ||
| 9 | import { expect, test } from "bun:test"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { freshData } from "./fresh.ts"; | |
| 12 | ||
| 13 | // A database of this file's own: an event is a row, so this file has rows. | |
| 14 | const dataDir = await freshData("live"); | |
| 15 | ||
| 16 | const { emit, events, subscribe } = await import("./live.ts"); | |
| 17 | const { db } = await import("./db.ts"); | |
| 18 | ||
| 19 | /** | |
| 20 | * The row another process's `emit` leaves behind, without the process: the | |
| 21 | * table is the whole of what one writes, and the spawn test below proves the | |
| 22 | * real thing. Written this way, a test can be sure the poll has not reached | |
| 23 | * it yet. | |
| 24 | */ | |
| 25 | const elsewhere = (type: string, data: unknown): number => | |
| 26 | db | |
| 27 | .query<{ id: number }, [string, string]>("INSERT INTO live_event (type, data) VALUES (?1, ?2) RETURNING id") | |
| 28 | .get(type, JSON.stringify(data))!.id; | |
| 29 | ||
| 30 | /** A reader over an open stream, one `\n\n` frame at a time. */ | |
| 31 | function open(response: Response) { | |
| 32 | const reader = response.body!.getReader(); | |
| 33 | const decoder = new TextDecoder(); | |
| 34 | // The pending read is kept across the waits: a read that loses the race is | |
| 35 | // still the read the next chunk goes to, so dropping it would drop data. | |
| 36 | let pending: Promise<ReadableStreamReadResult<Uint8Array>> | null = null; | |
| 37 | let buffer = ""; | |
| 38 | let ended = false; | |
| 39 | return { | |
| 40 | /** The next frame, or null when nothing comes within `ms`. */ | |
| 41 | async next(ms = 300): Promise<string | null> { | |
| 42 | for (;;) { | |
| 43 | const end = buffer.indexOf("\n\n"); | |
| 44 | if (end >= 0) { | |
| 45 | const frame = buffer.slice(0, end); | |
| 46 | buffer = buffer.slice(end + 2); | |
| 47 | return frame; | |
| 48 | } | |
| 49 | if (ended) return null; | |
| 50 | pending ??= reader.read(); | |
| 51 | const step = await Promise.race([pending, Bun.sleep(ms).then(() => "waited" as const)]); | |
| 52 | if (step === "waited") return null; | |
| 53 | pending = null; | |
| 54 | if (step.done) ended = true; | |
| 55 | else buffer += decoder.decode(step.value, { stream: true }); | |
| 56 | } | |
| 57 | }, | |
| 58 | /** True once the stream itself has ended, rather than merely gone quiet. */ | |
| 59 | isEnded: () => ended, | |
| 60 | close: () => reader.cancel(), | |
| 61 | }; | |
| 62 | } | |
| 63 | ||
| 64 | const request = (query = "", headers: Record<string, string> = {}): Request => | |
| 65 | new Request(`http://bazaar.test/admin/events${query}`, { headers }); | |
| 66 | ||
| 67 | /** The `{ type, data }` a frame carries, with the id it was numbered with. */ | |
| 68 | const frame = (text: string): { id: number; type: string; data: any } => { | |
| 69 | const id = Number(/^id: (\d+)$/m.exec(text)![1]); | |
| 70 | return { id, ...JSON.parse(/^data: (.*)$/m.exec(text)![1]!) }; | |
| 71 | }; | |
| 72 | ||
| 73 | test("a stream says how to retry, then follows what is emitted", async () => { | |
| 74 | const before = emit("test.before", { id: 1 }); | |
| 75 | const response = events(request(`?since=${before.id}`)); | |
| 76 | expect(response.headers.get("content-type")).toBe("text/event-stream"); | |
| 77 | expect(response.headers.get("cache-control")).toBe("no-store"); | |
| 78 | const stream = open(response); | |
| 79 | ||
| 80 | // The first thing a browser hears is how long to wait before reconnecting. | |
| 81 | expect(await stream.next()).toBe("retry: 2000"); | |
| 82 | // Nothing from before `since` is replayed. | |
| 83 | expect(await stream.next()).toBe(null); | |
| 84 | ||
| 85 | const after = emit("test.after", { id: 2, sku: "COF-GUJI-250" }); | |
| 86 | const heard = frame((await stream.next())!); | |
| 87 | expect(heard).toEqual({ id: after.id, type: "test.after", data: { id: 2, sku: "COF-GUJI-250" } }); | |
| 88 | expect(await stream.next()).toBe(null); | |
| 89 | await stream.close(); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a reconnect with Last-Event-ID replays what was missed and nothing else", async () => { | |
| 93 | const one = emit("test.one", { id: 1 }); | |
| 94 | const two = emit("test.two", { id: 2 }); | |
| 95 | const three = emit("test.three", { id: 3 }); | |
| 96 | ||
| 97 | const stream = open(events(request("", { "last-event-id": String(one.id) }))); | |
| 98 | expect(await stream.next()).toBe("retry: 2000"); | |
| 99 | expect(frame((await stream.next())!).id).toBe(two.id); | |
| 100 | expect(frame((await stream.next())!).id).toBe(three.id); | |
| 101 | expect(await stream.next()).toBe(null); | |
| 102 | await stream.close(); | |
| 103 | }); | |
| 104 | ||
| 105 | test("the last hundred events are kept, and no more", async () => { | |
| 106 | const mine = []; | |
| 107 | for (let n = 0; n < 120; n++) mine.push(emit("test.many", { id: n })); | |
| 108 | ||
| 109 | const stream = open(events(request("?since=0"))); | |
| 110 | expect(await stream.next()).toBe("retry: 2000"); | |
| 111 | const replayed = []; | |
| 112 | for (;;) { | |
| 113 | const next = await stream.next(); | |
| 114 | if (next === null) break; | |
| 115 | replayed.push(frame(next)); | |
| 116 | } | |
| 117 | expect(replayed.length).toBe(100); | |
| 118 | // The hundred kept are the last hundred: the first twenty are gone. | |
| 119 | expect(replayed[0]!.id).toBe(mine[20]!.id); | |
| 120 | expect(replayed[99]!.id).toBe(mine[119]!.id); | |
| 121 | await stream.close(); | |
| 122 | }); | |
| 123 | ||
| 124 | test("a listener hears what is emitted until it unsubscribes", () => { | |
| 125 | const heard: string[] = []; | |
| 126 | const unsubscribe = subscribe((event) => heard.push(event.type)); | |
| 127 | emit("test.heard", {}); | |
| 128 | unsubscribe(); | |
| 129 | emit("test.unheard", {}); | |
| 130 | expect(heard).toEqual(["test.heard"]); | |
| 131 | }); | |
| 132 | ||
| 133 | test("an aborted request takes its listener with it", async () => { | |
| 134 | const stop = new AbortController(); | |
| 135 | const here = emit("test.here", {}); | |
| 136 | const stream = open( | |
| 137 | events(new Request(`http://bazaar.test/admin/events?since=${here.id}`, { signal: stop.signal })), | |
| 138 | ); | |
| 139 | expect(await stream.next()).toBe("retry: 2000"); | |
| 140 | ||
| 141 | stop.abort(); | |
| 142 | // The stream is closed, so what is emitted next reaches nobody through it | |
| 143 | // — a reload that leaves a listener behind would leak one per reload. | |
| 144 | emit("test.after-abort", {}); | |
| 145 | expect(await stream.next()).toBe(null); | |
| 146 | expect(stream.isEnded()).toBe(true); | |
| 147 | }); | |
| 148 | ||
| 149 | test("a connection that has stopped reading is dropped, not queued for", async () => { | |
| 150 | const here = emit("test.wedged", {}); | |
| 151 | const response = events(request(`?since=${here.id}`)); | |
| 152 | // Subscribed, and never read from: every frame below is queued on it. | |
| 153 | for (let n = 0; n < 400; n++) emit("test.flood", { id: n }); | |
| 154 | ||
| 155 | const stream = open(response); | |
| 156 | let frames = 0; | |
| 157 | while ((await stream.next()) !== null) frames++; | |
| 158 | expect(stream.isEnded()).toBe(true); | |
| 159 | expect(frames).toBeLessThan(400); | |
| 160 | }); | |
| 161 | ||
| 162 | /** | |
| 163 | * The reason an event is a row. `s` and `r` in the terminal move stock and | |
| 164 | * refund orders through the same functions the web handlers use, and they | |
| 165 | * run in another process: its `emit` writes a row here, and the process | |
| 166 | * holding the streams has to notice it. | |
| 167 | */ | |
| 168 | test("a row written by a second process reaches an open stream", async () => { | |
| 169 | const heard: unknown[] = []; | |
| 170 | const unsubscribe = subscribe((event) => heard.push({ type: event.type, data: event.data })); | |
| 171 | try { | |
| 172 | const wrote = Bun.spawnSync( | |
| 173 | ["bun", "-e", 'const { emit } = await import("./src/live.ts"); emit("stock.moved", { id: 7, sku: "FROM-ELSEWHERE" });'], | |
| 174 | { cwd: join(import.meta.dir, ".."), env: { ...process.env, FORGE_APP_DATA: dataDir }, stderr: "pipe" }, | |
| 175 | ); | |
| 176 | expect(wrote.exitCode).toBe(0); | |
| 177 | expect(heard).toEqual([]); // Not ours to hear yet: it is in the table. | |
| 178 | ||
| 179 | const deadline = Date.now() + 5000; | |
| 180 | while (heard.length === 0 && Date.now() < deadline) await Bun.sleep(50); | |
| 181 | expect(heard).toEqual([{ type: "stock.moved", data: { id: 7, sku: "FROM-ELSEWHERE" } }]); | |
| 182 | } finally { | |
| 183 | unsubscribe(); | |
| 184 | } | |
| 185 | }); | |
| 186 | ||
| 187 | /** | |
| 188 | * A stream is the one request in this app that lasts, so it is the one that | |
| 189 | * could go on talking to somebody the forge has stopped vouching for. It is | |
| 190 | * given an end instead; the browser reconnects through the route's role | |
| 191 | * check, which is where an expired or demoted admin is turned away. | |
| 192 | */ | |
| 193 | test("a stream is given an end, and says nothing after it", async () => { | |
| 194 | const here = emit("test.before-the-end", {}); | |
| 195 | const stream = open(events(request(`?since=${here.id}`), Date.now() + 200)); | |
| 196 | expect(await stream.next()).toBe("retry: 2000"); | |
| 197 | ||
| 198 | await Bun.sleep(400); | |
| 199 | emit("test.after-the-end", { id: 1 }); | |
| 200 | expect(await stream.next()).toBe(null); | |
| 201 | expect(stream.isEnded()).toBe(true); | |
| 202 | }); | |
| 203 | ||
| 204 | test("a replay that runs ahead of the poll is not repeated by it", async () => { | |
| 205 | // Somebody is listening, so the poll is running and its cursor is here. | |
| 206 | const watching = open(events(request(`?since=${emit("test.mark", {}).id}`))); | |
| 207 | expect(await watching.next()).toBe("retry: 2000"); | |
| 208 | ||
| 209 | // A row the poll has not reached yet, and a connection opened in the same | |
| 210 | // turn: its replay hands out an id above the cursor the poll will catch up | |
| 211 | // to a second later. | |
| 212 | const row = elsewhere("test.from-elsewhere", { id: 7 }); | |
| 213 | const late = open(events(request(`?since=${row - 1}`))); | |
| 214 | expect(await late.next()).toBe("retry: 2000"); | |
| 215 | expect(frame((await late.next())!).id).toBe(row); | |
| 216 | ||
| 217 | await Bun.sleep(1300); // A pass of the poll: it hands that row to everybody. | |
| 218 | expect(frame((await watching.next())!).id).toBe(row); // The first time, here. | |
| 219 | expect(await late.next()).toBe(null); // The second time, there: not twice. | |
| 220 | await watching.close(); | |
| 221 | await late.close(); | |
| 222 | }); | |
| 223 | ||
| 224 | test("an event this process wrote is delivered once, not again by the poll", async () => { | |
| 225 | const heard: number[] = []; | |
| 226 | const unsubscribe = subscribe((event) => heard.push(event.id)); | |
| 227 | try { | |
| 228 | const one = emit("test.once", {}); | |
| 229 | expect(heard).toEqual([one.id]); // At once: no waiting for our own write. | |
| 230 | await Bun.sleep(1300); // Longer than a pass of the poll. | |
| 231 | expect(heard).toEqual([one.id]); | |
| 232 | } finally { | |
| 233 | unsubscribe(); | |
| 234 | } | |
| 235 | }); |
src/live.ts added +217
| @@ -0,0 +1,217 @@ | ||
| 1 | /** | |
| 2 | * Realtime without a framework. The ledger `emit`s what changed; the stream | |
| 3 | * behind `GET /admin/events` sends it to every open admin page as server-sent | |
| 4 | * events, numbered, with the last hundred kept so a page that reconnects with | |
| 5 | * `Last-Event-ID` (or `?since=`) hears what it missed. `static/live.js` turns | |
| 6 | * the stream into DOM events and refetches the regions marked `data-live`. | |
| 7 | * | |
| 8 | * An event is a row in `live_event`, not a message in one process's memory. | |
| 9 | * This app is two processes over one database — the web server and the | |
| 10 | * terminal — and a stock move typed at the ssh door has to reach an open | |
| 11 | * /admin as surely as one clicked in it. So `emit` writes the row, and | |
| 12 | * whichever process is serving streams hands out the rows it has not handed | |
| 13 | * out yet: its own at once, anybody else's within a second. One path for | |
| 14 | * both, in id order; a connection also remembers the highest id it has been | |
| 15 | * given, so a replay that runs ahead of that cursor is not repeated by the | |
| 16 | * poll behind it, and an event reaches a page exactly once. The row ids are | |
| 17 | * the event ids, so `Last-Event-ID` still means something after a restart. | |
| 18 | * | |
| 19 | * A stream is the one request in this app that lasts, so it is the one that | |
| 20 | * could outlive the identity it was opened with. It cannot: `events` takes | |
| 21 | * the moment it must end, and the route hands it the earlier of a few | |
| 22 | * minutes and the expiry the forge signed. | |
| 23 | * | |
| 24 | * This is the bus and nothing else: it knows no orders, no stock and no | |
| 25 | * pages. What an event may say is decided where it is emitted — an id, an | |
| 26 | * order number, a figure in cents, a SKU, and nothing a stranger owns. | |
| 27 | */ | |
| 28 | ||
| 29 | import { db } from "./db.ts"; | |
| 30 | ||
| 31 | export interface LiveEvent { | |
| 32 | readonly id: number; | |
| 33 | readonly type: string; | |
| 34 | readonly data: unknown; | |
| 35 | } | |
| 36 | ||
| 37 | /** How many rows are kept: the replay a reconnecting page can be given. */ | |
| 38 | const KEEP = 100; | |
| 39 | const HEARTBEAT_MS = 25_000; | |
| 40 | /** | |
| 41 | * The longest a connection lives before the browser has to ask again. Every | |
| 42 | * other door in this app proves who is asking on every request; a stream is | |
| 43 | * one request that would otherwise last all day, so it is given an end. The | |
| 44 | * `retry:` below brings the EventSource back through the route's role check | |
| 45 | * with a fresh `x-forge-identity`, and `Last-Event-ID` replays the two | |
| 46 | * seconds in between, so a page that is still allowed loses nothing. | |
| 47 | */ | |
| 48 | export const MAX_LIFE_MS = 4 * 60_000; | |
| 49 | /** How long another process's row waits to be noticed, at the very worst. */ | |
| 50 | const POLL_MS = 1_000; | |
| 51 | /** Frames a connection may fall behind by before it is dropped. */ | |
| 52 | const MAX_QUEUED = 200; | |
| 53 | ||
| 54 | const listeners = new Set<(event: LiveEvent) => void>(); | |
| 55 | /** The highest id handed to the listeners. Rows above it are theirs to hear. */ | |
| 56 | let handedOut = 0; | |
| 57 | let poll: ReturnType<typeof setInterval> | null = null; | |
| 58 | ||
| 59 | type Row = { id: number; type: string; data: string }; | |
| 60 | ||
| 61 | /** A row's payload. A row we cannot read is an event with no detail, not a crash. */ | |
| 62 | const payload = (data: string): unknown => { | |
| 63 | try { | |
| 64 | return JSON.parse(data); | |
| 65 | } catch { | |
| 66 | return null; | |
| 67 | } | |
| 68 | }; | |
| 69 | ||
| 70 | const newest = (): number => | |
| 71 | db.query<{ id: number }, []>("SELECT COALESCE(MAX(id), 0) AS id FROM live_event").get()!.id; | |
| 72 | ||
| 73 | /** What a page that last heard `id` missed: at most the hundred kept. */ | |
| 74 | const since = (id: number): LiveEvent[] => | |
| 75 | db | |
| 76 | .query<Row, [number]>("SELECT id, type, data FROM live_event WHERE id > ?1 ORDER BY id") | |
| 77 | .all(id) | |
| 78 | .map((row) => ({ id: row.id, type: row.type, data: payload(row.data) })); | |
| 79 | ||
| 80 | /** | |
| 81 | * Say what changed. Called after the transaction that wrote the ledger row | |
| 82 | * committed, never inside it: this is a row of its own, and an order that | |
| 83 | * rolled back must not leave an event behind saying it happened. | |
| 84 | * | |
| 85 | * Saying so is best effort, and deliberately: the thing this is about is | |
| 86 | * already committed, so a row that cannot be written is logged and the admin | |
| 87 | * is left one reload behind — it is not a reason to fail a checkout that | |
| 88 | * has already taken the stock. The returned event carries id 0 then. | |
| 89 | */ | |
| 90 | export function emit(type: string, data: unknown): LiveEvent { | |
| 91 | let id = 0; | |
| 92 | try { | |
| 93 | id = db | |
| 94 | .query<{ id: number }, [string, string]>( | |
| 95 | "INSERT INTO live_event (type, data) VALUES (?1, ?2) RETURNING id", | |
| 96 | ) | |
| 97 | .get(type, JSON.stringify(data) ?? "null")!.id; | |
| 98 | db.query("DELETE FROM live_event WHERE id <= ?1").run(id - KEEP); | |
| 99 | } catch (error) { | |
| 100 | console.error(`live: ${type} could not be written:`, error); | |
| 101 | } | |
| 102 | if (id !== 0 && listeners.size > 0) handOut(); | |
| 103 | return { id, type, data }; | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * Hand out every row written since the last one handed out, in id order. | |
| 108 | * Both kinds of write come through here — this process's own and another | |
| 109 | * process's, noticed by the poll — so nothing is delivered twice and | |
| 110 | * nothing arrives before something older than it. | |
| 111 | */ | |
| 112 | function handOut(): void { | |
| 113 | // A cursor above the newest row is a cursor into a database that is not | |
| 114 | // there any more — a preview reset, or a test file opening its own. Start | |
| 115 | // from what is in front of us rather than never speaking again. | |
| 116 | if (newest() < handedOut) handedOut = newest(); | |
| 117 | for (const event of since(handedOut)) { | |
| 118 | handedOut = event.id; | |
| 119 | for (const listen of listeners) listen(event); | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 123 | export function subscribe(listen: (event: LiveEvent) => void): () => void { | |
| 124 | if (listeners.size === 0) { | |
| 125 | // Nobody was listening, so nothing already written is owed to anybody: | |
| 126 | // a stream replays what it missed from the table itself. The poll runs | |
| 127 | // only while somebody is there, and is unref'd so it holds no process | |
| 128 | // open — a test or the terminal must be able to simply end. | |
| 129 | handedOut = newest(); | |
| 130 | poll = setInterval(handOut, POLL_MS); | |
| 131 | poll.unref?.(); | |
| 132 | } | |
| 133 | listeners.add(listen); | |
outdated
Changes requested — one finding, and it is new in this round. The terminal gap I raised last time is properly fixed and the fix is good work. What I did at High — the stream is authorized once and then trusted for as long as the socket livesEvery other door in this app verifies the forge's signature and its expiry on every request;
Ten seconds past expiry the page door is shut and the stream is still feeding. The same holds for an admin the forge demotes: the pages 403 at once (and Fix this first, and it is small because the replay you already built makes it lossless: give the stream a maximum life — four or five minutes — after which Also needed before this lands
Smaller, not blocking
What is right and should not change under reviewThe row-backed bus: one |
||
| 134 | return () => { | |
| 135 | listeners.delete(listen); | |
| 136 | if (listeners.size === 0 && poll !== null) { | |
| 137 | clearInterval(poll); | |
| 138 | poll = null; | |
| 139 | } | |
| 140 | }; | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * The stream: it replays what `since` missed, then follows. The listener is | |
| 145 | * dropped when the request aborts and when the stream is cancelled, so a | |
| 146 | * reload leaves nothing behind, and a colon every 25 seconds keeps a proxy | |
| 147 | * from closing a quiet one. | |
| 148 | * | |
| 149 | * `endsAt` is when this connection is closed whatever happens: a stamp, so | |
| 150 | * the caller can end it at the earlier of a few minutes and the moment the | |
| 151 | * forge stops vouching for whoever opened it. Closing is all it does — the | |
| 152 | * browser reconnects through the route that checked the role in the first | |
| 153 | * place, and is turned away there if it is no longer an admin. | |
| 154 | */ | |
| 155 | export function events(request: Request, endsAt = Date.now() + MAX_LIFE_MS): Response { | |
| 156 | const asked = Number( | |
| 157 | request.headers.get("last-event-id") ?? new URL(request.url).searchParams.get("since") ?? 0, | |
| 158 | ); | |
| 159 | const from = Number.isFinite(asked) ? asked : 0; | |
| 160 | const encoder = new TextEncoder(); | |
| 161 | let stop = (): void => {}; | |
| 162 | const stream = new ReadableStream<Uint8Array>({ | |
| 163 | start(controller) { | |
| 164 | const write = (text: string): void => { | |
| 165 | try { | |
| 166 | controller.enqueue(encoder.encode(text)); | |
| 167 | // A connection that has stopped reading queues everything we say. | |
| 168 | // Past a few hundred frames it is dropped instead of held: an | |
| 169 | // EventSource reconnects on its own and replays what it missed. | |
| 170 | if ((controller.desiredSize ?? 0) < -MAX_QUEUED) stop(); | |
| 171 | } catch { | |
| 172 | stop(); | |
| 173 | } | |
| 174 | }; | |
| 175 | // What this connection has already been given. The replay below reaches | |
| 176 | // above `handedOut` when another process has just written, and the poll | |
| 177 | // then hands those same rows to every listener; a connection that has | |
| 178 | // seen an id does not see it twice. | |
| 179 | let last = from; | |
| 180 | const send = (event: LiveEvent): void => { | |
| 181 | if (event.id <= last) return; | |
| 182 | last = event.id; | |
| 183 | write(`id: ${event.id}\ndata: ${JSON.stringify({ type: event.type, data: event.data })}\n\n`); | |
| 184 | }; | |
| 185 | const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS); | |
| 186 | heartbeat.unref?.(); | |
| 187 | // Unref'd like the heartbeat: an end for the connection, never a reason | |
| 188 | // for the process to stay up waiting to deliver it. | |
| 189 | const life = setTimeout(() => stop(), Math.max(0, endsAt - Date.now())); | |
| 190 | life.unref?.(); | |
| 191 | const unsubscribe = subscribe(send); | |
| 192 | stop = () => { | |
| 193 | clearTimeout(life); | |
| 194 | clearInterval(heartbeat); | |
| 195 | unsubscribe(); | |
| 196 | try { | |
| 197 | controller.close(); | |
| 198 | } catch {} | |
| 199 | }; | |
| 200 | request.signal.addEventListener("abort", stop); | |
| 201 | write("retry: 2000\n\n"); | |
| 202 | // Nothing can be emitted between the subscribe above and this replay: | |
| 203 | // both run in one turn, so a page hears every event exactly once. | |
| 204 | for (const event of since(from)) send(event); | |
| 205 | }, | |
| 206 | cancel() { | |
| 207 | stop(); | |
| 208 | }, | |
| 209 | }); | |
| 210 | return new Response(stream, { | |
| 211 | headers: { | |
| 212 | "content-type": "text/event-stream", | |
| 213 | "cache-control": "no-store", | |
| 214 | "x-accel-buffering": "no", | |
| 215 | }, | |
| 216 | }); | |
| 217 | } |
src/orders.ts +56 −18
| @@ -9,6 +9,11 @@ | ||
| 9 | 9 | * refunds are rows in `payment`, movements of a gift card are rows in |
| 10 | 10 | * `gift_card_entry`, each with an idempotency key, so a double submit or a |
| 11 | 11 | * repeated webhook records one of them. |
| 12 | * | |
| 13 | * Each of those writes tells the admin's stream what it did — `emit` from | |
| 14 | * src/live.ts, once, after the transaction has committed and never inside it: | |
| 15 | * an order that rolled back must not appear on anybody's screen, and a | |
| 16 | * webhook arriving twice writes one row, so it announces one thing. | |
| 12 | 17 | */ |
| 13 | 18 | |
| 14 | 19 | import * as discounts from "./discounts.ts"; |
| @@ -17,6 +22,7 @@ import { randomBytes } from "node:crypto"; | ||
| 17 | 22 | import { db, type Order, type OrderItem, type Payment } from "./db.ts"; |
| 18 | 23 | import { addStock, takeStock } from "./inventory.ts"; |
| 19 | 24 | import { dueCents, sumLines } from "./money.ts"; |
| 25 | import { emit } from "./live.ts"; | |
| 20 | 26 | import { quote, type CartLine, type Quote } from "./cart.ts"; |
| 21 | 27 | import { writeReceipt } from "./mail.ts"; |
| 22 | 28 | import type { Applied } from "./codes.ts"; |
| @@ -149,8 +155,9 @@ export function place(input: { | ||
| 149 | 155 | if (priced.discount_cents !== input.shown.discount_cents) |
| 150 | 156 | return { ok: false, error: "That code stopped applying while you were checking out." }; |
| 151 | 157 | |
| 158 | let placed: Order; | |
| 152 | 159 | try { |
| 153 | const order = db.transaction(() => { | |
| 160 | placed = db.transaction(() => { | |
| 154 | 161 | let created: Order | null = null; |
| 155 | 162 | for (let attempt = 0; attempt < 5 && created === null; attempt++) { |
| 156 | 163 | try { |
| @@ -229,8 +236,6 @@ export function place(input: { | ||
| 229 | 236 | note(created.id, "placed", `${itemRows.length} line${itemRows.length === 1 ? "" : "s"}, awaiting payment`); |
| 230 | 237 | return created; |
| 231 | 238 | })(); |
| 232 | // A gift card that covered the lot leaves nothing for a gateway to take. | |
| 233 | return { ok: true, order: dueCents(order) === 0 ? payWithGiftCard(order) : order, reused: false }; | |
| 234 | 239 | } catch (error) { |
| 235 | 240 | const message = String(error instanceof Error ? error.message : error); |
| 236 | 241 | if (message.startsWith("OUT_OF_STOCK:")) |
| @@ -238,6 +243,13 @@ export function place(input: { | ||
| 238 | 243 | if (message.startsWith("CODE_GONE:")) return { ok: false, error: message.slice("CODE_GONE:".length) }; |
| 239 | 244 | return { ok: false, error: "The order could not be placed. Nothing was charged." }; |
| 240 | 245 | } |
| 246 | ||
| 247 | // Committed: the order and the stock it took are facts now, so this is | |
| 248 | // where the admin's screen hears about them. | |
| 249 | emit("order.placed", { id: placed.id, number: placed.number, cents: placed.total_cents }); | |
| 250 | for (const item of itemRows) emit("stock.moved", { id: item.variant_id, sku: item.sku }); | |
| 251 | // A gift card that covered the lot leaves nothing for a gateway to take. | |
| 252 | return { ok: true, order: dueCents(placed) === 0 ? payWithGiftCard(placed) : placed, reused: false }; | |
| 241 | 253 | } |
| 242 | 254 | |
| 243 | 255 | export type Charge = { gateway: string; ref: string; amount_cents: number }; |
| @@ -269,6 +281,11 @@ export function shortfall(order: Order): Shortfall | null { | ||
| 269 | 281 | * |
| 270 | 282 | * Idempotent: the charge's key is the order's, so a webhook and a browser |
| 271 | 283 | * returning at the same time write one charge and one event between them. |
| 284 | * | |
| 285 | * The admin's screen hears `order.paid` from the one branch that actually | |
| 286 | * marked the order paid, and only when the UPDATE moved the row. A short | |
| 287 | * payment announces nothing: the ledger says awaiting payment, and a live | |
| 288 | * page that said otherwise would be the screen lying about the money. | |
| 272 | 289 | */ |
| 273 | 290 | const alreadyShort = (orderId: number): boolean => |
| 274 | 291 | db |
| @@ -281,7 +298,7 @@ export function settleFromGateway(order: Order, charge: Charge): Order { | ||
| 281 | 298 | const amount = Number.isFinite(charge.amount_cents) ? Math.trunc(charge.amount_cents) : 0; |
| 282 | 299 | const ref = charge.ref.slice(0, 200); |
| 283 | 300 | |
| 284 | const settled = db.transaction(() => { | |
| 301 | const took = db.transaction(() => { | |
| 285 | 302 | const fresh = byId(order.id)!; |
| 286 | 303 | const inserted = |
| 287 | 304 | amount > 0 |
| @@ -293,16 +310,16 @@ export function settleFromGateway(order: Order, charge: Charge): Order { | ||
| 293 | 310 | .run(fresh.id, amount, charge.gateway, ref, `charge:${fresh.number}`).changes |
| 294 | 311 | : 0; |
| 295 | 312 | // Already recorded: the other arrival settled this order, whichever way. |
| 296 | if (inserted === 0 && amount > 0) return byId(fresh.id)!; | |
| 313 | if (inserted === 0 && amount > 0) return { paid: false, order: byId(fresh.id)! }; | |
| 297 | 314 | |
| 298 | 315 | const due = dueCents(fresh); |
| 299 | 316 | if (amount >= due) { |
| 300 | db.query( | |
| 317 | const changed = db.query( | |
| 301 | 318 | `UPDATE "order" SET status = 'paid', paid_at = datetime('now'), payment_ref = ?2, gateway = ?3 |
| 302 | 319 | WHERE id = ?1 AND status = 'pending_payment'`, |
| 303 | ).run(fresh.id, ref, charge.gateway); | |
| 320 | ).run(fresh.id, ref, charge.gateway).changes; | |
| 304 | 321 | note(fresh.id, "paid", `${charge.gateway} ${ref}`); |
| 305 | return byId(fresh.id)!; | |
| 322 | return { paid: changed === 1, order: byId(fresh.id)! }; | |
| 306 | 323 | } |
| 307 | 324 | // Short. The order stays where it was; the gateway's handle is kept so |
| 308 | 325 | // whoever looks at this can find the charge at the gateway. |
| @@ -313,16 +330,21 @@ export function settleFromGateway(order: Order, charge: Charge): Order { | ||
| 313 | 330 | ); |
| 314 | 331 | // An amount of nothing wrote no row to be idempotent against, so the event |
| 315 | 332 | // guards itself: a repeated delivery of it is the same fact, not a new one. |
| 316 | if (amount <= 0 && alreadyShort(fresh.id)) return byId(fresh.id)!; | |
| 333 | if (amount <= 0 && alreadyShort(fresh.id)) return { paid: false, order: byId(fresh.id)! }; | |
| 317 | 334 | note( |
| 318 | 335 | fresh.id, |
| 319 | 336 | "short-payment", |
| 320 | 337 | `asked ${(due / 100).toFixed(2)} ${fresh.currency}, ${charge.gateway} paid ${(amount / 100).toFixed(2)} — ` + |
| 321 | 338 | `${((due - amount) / 100).toFixed(2)} short${ref ? ` (${ref})` : ""}`, |
| 322 | 339 | ); |
| 323 | return byId(fresh.id)!; | |
| 340 | return { paid: false, order: byId(fresh.id)! }; | |
| 324 | 341 | })(); |
| 325 | return settle(settled); | |
| 342 | const settled = settle(took.order); | |
| 343 | // Only the call that moved the order to paid says so: the browser coming | |
| 344 | // back from Stripe and the webhook both end up here, and one charge is one | |
| 345 | // event. The gateway's reference stays out of it. | |
| 346 | if (took.paid) emit("order.paid", { id: settled.id, number: settled.number, cents: amount }); | |
| 347 | return settled; | |
| 326 | 348 | } |
| 327 | 349 | |
| 328 | 350 | /** |
| @@ -331,7 +353,7 @@ export function settleFromGateway(order: Order, charge: Charge): Order { | ||
| 331 | 353 | * already written. |
| 332 | 354 | */ |
| 333 | 355 | export function payWithGiftCard(order: Order): Order { |
| 334 | const paid = db.transaction(() => { | |
| 356 | const took = db.transaction(() => { | |
| 335 | 357 | const changed = db |
| 336 | 358 | .query( |
| 337 | 359 | `UPDATE "order" SET status = 'paid', paid_at = datetime('now'), gateway = 'gift_card' |
| @@ -339,9 +361,11 @@ export function payWithGiftCard(order: Order): Order { | ||
| 339 | 361 | ) |
| 340 | 362 | .run(order.id).changes; |
| 341 | 363 | if (changed === 1) note(order.id, "paid", `gift card ····${giftcards.cardSpentOn(order.id)?.last4 ?? "????"}`); |
| 342 | return byId(order.id)!; | |
| 364 | return { charged: changed === 1, order: byId(order.id)! }; | |
| 343 | 365 | })(); |
| 344 | return settle(paid); | |
| 366 | const paid = settle(took.order); | |
| 367 | if (took.charged) emit("order.paid", { id: paid.id, number: paid.number, cents: paid.gift_card_cents }); | |
| 368 | return paid; | |
| 345 | 369 | } |
| 346 | 370 | |
| 347 | 371 | /** |
| @@ -383,8 +407,9 @@ export function refund(input: { | ||
| 383 | 407 | .get(input.idempotencyKey); |
| 384 | 408 | if (already) return { ok: true, payment: already, order: byId(order.id)! }; |
| 385 | 409 | |
| 410 | let done: RefundResult; | |
| 386 | 411 | try { |
| 387 | return db.transaction((): RefundResult => { | |
| 412 | done = db.transaction((): RefundResult => { | |
| 388 | 413 | const fresh = byId(order.id)!; |
| 389 | 414 | const left = refundableCents(fresh); |
| 390 | 415 | if (left <= 0) return { ok: false, error: "This order has nothing left to refund." }; |
| @@ -437,6 +462,13 @@ export function refund(input: { | ||
| 437 | 462 | } catch { |
| 438 | 463 | return { ok: false, error: "The refund could not be recorded." }; |
| 439 | 464 | } |
| 465 | ||
| 466 | if (done.ok) { | |
| 467 | emit("order.refunded", { id: done.order.id, number: done.order.number, cents: input.amount_cents }); | |
| 468 | if (input.restock) | |
| 469 | for (const item of itemsOf(done.order.id)) emit("stock.moved", { id: item.variant_id, sku: item.sku }); | |
| 470 | } | |
| 471 | return done; | |
| 440 | 472 | } |
| 441 | 473 | |
| 442 | 474 | /** |
| @@ -444,9 +476,9 @@ export function refund(input: { | ||
| 444 | 476 | * code gets its use back and the gift card gets its balance back. |
| 445 | 477 | */ |
| 446 | 478 | export function cancel(order: Order, reason: string): Order { |
| 447 | return db.transaction(() => { | |
| 479 | const ended = db.transaction(() => { | |
| 448 | 480 | const fresh = byId(order.id)!; |
| 449 | if (fresh.status !== "pending_payment") return fresh; | |
| 481 | if (fresh.status !== "pending_payment") return { cancelled: false, order: fresh, items: [] as OrderItem[] }; | |
| 450 | 482 | db.query('UPDATE "order" SET status = \'cancelled\' WHERE id = ?1').run(fresh.id); |
| 451 | 483 | for (const item of itemsOf(fresh.id)) |
| 452 | 484 | addStock(item.variant_id, item.quantity, `cancelled ${fresh.number}`, fresh.id); |
| @@ -466,6 +498,12 @@ export function cancel(order: Order, reason: string): Order { | ||
| 466 | 498 | ); |
| 467 | 499 | } |
| 468 | 500 | note(fresh.id, "cancelled", reason); |
| 469 | return byId(fresh.id)!; | |
| 501 | return { cancelled: true, order: byId(fresh.id)!, items: itemsOf(fresh.id) }; | |
| 470 | 502 | })(); |
| 503 | ||
| 504 | if (ended.cancelled) { | |
| 505 | emit("order.cancelled", { id: ended.order.id, number: ended.order.number }); | |
| 506 | for (const item of ended.items) emit("stock.moved", { id: item.variant_id, sku: item.sku }); | |
| 507 | } | |
| 508 | return ended.order; | |
| 471 | 509 | } |
src/pages/admin.ts +45 −10
| @@ -2,6 +2,12 @@ | ||
| 2 | 2 | * /admin: the pages behind the session. Products, inventory, orders, refunds |
| 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 | * | |
| 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 | |
| 9 | * markup is the markup either way — with the script off, the page is what it | |
| 10 | * always was, one reload behind. | |
| 5 | 11 | */ |
| 6 | 12 | |
| 7 | 13 | import { csrfField, escape, variantRowStyle } from "../html.ts"; |
| @@ -18,6 +24,17 @@ import type { Email } from "../mail.ts"; | ||
| 18 | 24 | import type { Session } from "../session.ts"; |
| 19 | 25 | import type { Shortfall } from "../orders.ts"; |
| 20 | 26 | |
| 27 | /** What the ledger emits about an order, and about a stock movement. */ | |
| 28 | const ORDER_EVENTS = "order.placed order.paid order.refunded order.cancelled"; | |
| 29 | const STOCK_EVENTS = "stock.moved"; | |
| 30 | ||
| 31 | /** | |
| 32 | * A region the stream keeps current. The id is what static/live.js swaps by, | |
| 33 | * so it is stable across renders; the types are the events worth a refetch. | |
| 34 | */ | |
| 35 | const live = (id: string, types: string, body: string): string => | |
| 36 | `<div id="${escape(id)}" data-live="${escape(types)}">${body}</div>`; | |
| 37 | ||
| 21 | 38 | export const nav = (current: string): string => |
| 22 | 39 | `<nav>${[ |
| 23 | 40 | ["/admin", "Overview", "overview"], |
| @@ -59,7 +76,10 @@ export const overview = ( | ||
| 59 | 76 | ): string => ` |
| 60 | 77 | ${nav("overview")} |
| 61 | 78 | <h1>Overview</h1> |
| 62 | <div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr))"> | |
| 79 | ${live( | |
| 80 | "admin-totals", | |
| 81 | `${ORDER_EVENTS} ${STOCK_EVENTS}`, | |
| 82 | `<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr))"> | |
| 63 | 83 | ${[ |
| 64 | 84 | ["Orders", String(numbers.orders)], |
| 65 | 85 | ["Paid", String(numbers.paid)], |
| @@ -74,14 +94,18 @@ ${nav("overview")} | ||
| 74 | 94 | <div style="font-size:1.4rem; font-weight:600" class="price">${escape(value)}</div></div>`, |
| 75 | 95 | ) |
| 76 | 96 | .join("\n")} |
| 77 | </div> | |
| 97 | </div>`, | |
| 98 | )} | |
| 78 | 99 | <h2>Latest orders</h2> |
| 79 | ${orderRows(latest)} | |
| 100 | ${live("latest-orders", ORDER_EVENTS, orderRows(latest))} | |
| 80 | 101 | `; |
| 81 | 102 | |
| 82 | 103 | export function orderRows(orders: Order[]): string { |
| 83 | 104 | if (orders.length === 0) return '<p class="muted">No orders yet.</p>'; |
| 84 | return `<table> | |
| 105 | // Fixed columns: a row arriving at the top cannot move the ones below it | |
| 106 | // sideways, whatever length the name in it is. | |
| 107 | return `<table class="steady"> | |
| 108 | <colgroup><col style="width:20%"><col style="width:22%"><col style="width:28%"><col style="width:15%"><col style="width:15%"></colgroup> | |
| 85 | 109 | <thead><tr><th>Order</th><th>Placed</th><th>Customer</th><th>Status</th><th class="num">Total</th></tr></thead> |
| 86 | 110 | <tbody>${orders |
| 87 | 111 | .map( |
| @@ -103,7 +127,7 @@ ${nav("orders")} | ||
| 103 | 127 | <input name="q" value="${escape(query)}" placeholder="Number, name or email" maxlength="80" style="max-width:22rem"> |
| 104 | 128 | <button class="btn ghost">Search</button> |
| 105 | 129 | </form> |
| 106 | ${orderRows(list)} | |
| 130 | ${live("order-list", ORDER_EVENTS, orderRows(list))} | |
| 107 | 131 | `; |
| 108 | 132 | |
| 109 | 133 | export function order( |
| @@ -294,8 +318,13 @@ export function inventory( | ||
| 294 | 318 | ${nav("inventory")} |
| 295 | 319 | <h1>Inventory</h1> |
| 296 | 320 | ${message ? `<p class="notice${message.bad ? " bad" : ""}">${escape(message.text)}</p>` : ""} |
| 297 | <p class="lede">Stock moves by writing a reason. Every line below is a row in the ledger.</p> | |
| 298 | <table> | |
| 321 | <p class="lede">Stock moves by writing a reason. Every line below is a row in the ledger, and both tables follow the | |
| 322 | ledger as it is written — from here, from a checkout, or from a refund putting stock back.</p> | |
| 323 | ${live( | |
| 324 | "stock-on-hand", | |
| 325 | STOCK_EVENTS, | |
| 326 | `<table class="steady"> | |
| 327 | <colgroup><col style="width:22%"><col style="width:30%"><col style="width:13%"><col style="width:35%"></colgroup> | |
| 299 | 328 | <thead><tr><th>SKU</th><th>Item</th><th class="num">On hand</th><th>Move</th></tr></thead> |
| 300 | 329 | <tbody>${rows |
| 301 | 330 | .map( |
| @@ -316,9 +345,14 @@ ${message ? `<p class="notice${message.bad ? " bad" : ""}">${escape(message.text | ||
| 316 | 345 | </tr>`, |
| 317 | 346 | ) |
| 318 | 347 | .join("\n")}</tbody> |
| 319 | </table> | |
| 348 | </table>`, | |
| 349 | )} | |
| 320 | 350 | <h2>Ledger</h2> |
| 321 | <table> | |
| 351 | ${live( | |
| 352 | "stock-ledger", | |
| 353 | STOCK_EVENTS, | |
| 354 | `<table class="steady"> | |
| 355 | <colgroup><col style="width:20%"><col style="width:20%"><col style="width:12%"><col style="width:48%"></colgroup> | |
| 322 | 356 | <thead><tr><th>When</th><th>SKU</th><th class="num">Change</th><th>Reason</th></tr></thead> |
| 323 | 357 | <tbody>${ledger |
| 324 | 358 | .map( |
| @@ -326,7 +360,8 @@ ${message ? `<p class="notice${message.bad ? " bad" : ""}">${escape(message.text | ||
| 326 | 360 | <td class="num">${entry.delta > 0 ? "+" : ""}${entry.delta}</td><td>${escape(entry.reason)}</td></tr>`, |
| 327 | 361 | ) |
| 328 | 362 | .join("\n")}</tbody> |
| 329 | </table> | |
| 363 | </table>`, | |
| 364 | )} | |
| 330 | 365 | `; |
| 331 | 366 | } |
| 332 | 367 |
src/routes.ts +1
| @@ -50,6 +50,7 @@ export const routes: Route[] = [ | ||
| 50 | 50 | // Everything under /admin is for an admin the forge signed; the guard in |
| 51 | 51 | // src/admin.ts is on each handler, not on this table. |
| 52 | 52 | { method: "GET", pattern: "/admin", handler: admin.dashboard }, |
| 53 | { method: "GET", pattern: "/admin/events", handler: admin.events }, | |
| 53 | 54 | { method: "GET", pattern: "/admin/products", handler: admin.productList }, |
| 54 | 55 | { method: "POST", pattern: "/admin/products", handler: admin.productCreate }, |
| 55 | 56 | { method: "GET", pattern: "/admin/products/new", handler: admin.productNew }, |
src/server.test.ts +208
| @@ -13,6 +13,7 @@ const { stamp } = await import("./who.ts"); | ||
| 13 | 13 | const { server } = await import("./server.ts"); |
| 14 | 14 | const { db } = await import("./db.ts"); |
| 15 | 15 | const limit = await import("./ratelimit.ts"); |
| 16 | const live = await import("./live.ts"); | |
| 16 | 17 | const giftcards = await import("./giftcards.ts"); |
| 17 | 18 | const discounts = await import("./discounts.ts"); |
| 18 | 19 | const tax = await import("./tax.ts"); |
| @@ -1378,6 +1379,213 @@ test("/admin/changes is an admin's page and nobody else's", async () => { | ||
| 1378 | 1379 | expect(await refused.text()).not.toContain("AUD-KETTLE-1"); |
| 1379 | 1380 | }); |
| 1380 | 1381 | |
| 1382 | // --- live: one bus, one stream ---------------------------------------------- | |
| 1383 | ||
| 1384 | /** | |
| 1385 | * Read an open event stream until its text says `wanted`. The pending read is | |
| 1386 | * held across the waits, or the chunk that loses a race is lost with it. | |
| 1387 | */ | |
| 1388 | async function until(response: Response, wanted: string, ms = 5000): Promise<string> { | |
| 1389 | const reader = response.body!.getReader(); | |
| 1390 | const decoder = new TextDecoder(); | |
| 1391 | let pending: Promise<ReadableStreamReadResult<Uint8Array>> | null = null; | |
| 1392 | let text = ""; | |
| 1393 | const deadline = Date.now() + ms; | |
| 1394 | while (!text.includes(wanted) && Date.now() < deadline) { | |
| 1395 | pending ??= reader.read(); | |
| 1396 | const step = await Promise.race([pending, Bun.sleep(100).then(() => "waited" as const)]); | |
| 1397 | if (step === "waited") continue; | |
| 1398 | pending = null; | |
| 1399 | if (step.done) break; | |
| 1400 | text += decoder.decode(step.value, { stream: true }); | |
| 1401 | } | |
| 1402 | await reader.cancel(); | |
| 1403 | return text; | |
| 1404 | } | |
| 1405 | ||
| 1406 | const ADMIN_HEADERS = (): Record<string, string> => | |
| 1407 | identityHeaders({ id: "u_live", name: "ada", role: "admin" }); | |
| 1408 | ||
| 1409 | /** | |
| 1410 | * A stream that starts here and not at the top of the bus. Every test in | |
| 1411 | * this file shares one process and one bus, so a connection with nothing to | |
| 1412 | * go on replays what the tests before it did: this marks the tail first and | |
| 1413 | * asks for what comes after it. | |
| 1414 | */ | |
| 1415 | const watch = async (stop: AbortController): Promise<Response> => { | |
| 1416 | const here = live.emit("test.mark", {}); | |
| 1417 | return fetch(`${base}/admin/events?since=${here.id}`, { headers: ADMIN_HEADERS(), signal: stop.signal }); | |
| 1418 | }; | |
| 1419 | ||
| 1420 | test("an admin watching the stream hears an order a stranger places", async () => { | |
| 1421 | const stop = new AbortController(); | |
| 1422 | const stream = await watch(stop); | |
| 1423 | expect(stream.status).toBe(200); | |
| 1424 | expect(stream.headers.get("content-type")).toBe("text/event-stream"); | |
| 1425 | ||
| 1426 | const shopper = new Shopper(); | |
| 1427 | await addToCart(shopper, "COF-GUJI-250", 1); | |
| 1428 | const placed = await submitCheckout(shopper, { email: "[email protected]" }); | |
| 1429 | expect(placed.status).toBe(303); | |
| 1430 | const { number } = orderAt(placed.headers.get("location")!); | |
| 1431 | ||
| 1432 | // The stock the order took is announced after the order itself, so waiting | |
| 1433 | // for that waits for both. | |
| 1434 | const heard = await until(stream, "stock.moved"); | |
| 1435 | expect(heard).toContain("retry: 2000"); | |
| 1436 | expect(heard).toContain("order.placed"); | |
| 1437 | expect(heard).toContain("stock.moved"); | |
| 1438 | expect(heard).toContain(number); | |
| 1439 | ||
| 1440 | // Nothing private travels: an id, the order's number, a figure in cents, | |
| 1441 | // a SKU, and not one word of the person who placed it. | |
| 1442 | const events = [...heard.matchAll(/^data: (.*)$/gm)].map((line) => JSON.parse(line[1]!)); | |
| 1443 | const orderPlaced = events.find((event) => event.type === "order.placed")!; | |
| 1444 | expect(Object.keys(orderPlaced.data).sort()).toEqual(["cents", "id", "number"]); | |
| 1445 | expect(orderPlaced.data.number).toBe(number); | |
| 1446 | expect(orderPlaced.data.cents).toBe(orderNamed(number).total_cents); | |
| 1447 | expect(heard).not.toContain("[email protected]"); | |
| 1448 | expect(heard).not.toContain("1 Test Street"); | |
| 1449 | expect(heard).not.toContain("mock_"); | |
| 1450 | stop.abort(); | |
| 1451 | }); | |
| 1452 | ||
| 1453 | test("paying and refunding an order reach the stream, once each", async () => { | |
| 1454 | const shopper = new Shopper(); | |
| 1455 | await addToCart(shopper, "COF-HOUSE-250", 1); | |
| 1456 | const placed = await submitCheckout(shopper, { email: "[email protected]" }); | |
| 1457 | const { number, token } = orderAt(placed.headers.get("location")!); | |
| 1458 | ||
| 1459 | const stop = new AbortController(); | |
| 1460 | const stream = await watch(stop); | |
| 1461 | expect((await payMock(shopper, number, token)).status).toBe(303); | |
| 1462 | ||
| 1463 | const admin = asAdmin(); | |
| 1464 | const page = await admin.page(`/admin/orders/${number}`); | |
| 1465 | const refunded = await admin.post( | |
| 1466 | `/admin/orders/${number}/refund`, | |
| 1467 | { amount: "1.00", reason: "a test", restock: "1" }, | |
| 1468 | Shopper.tokenIn(page), | |
| 1469 | ); | |
| 1470 | expect(refunded.status).toBe(303); | |
| 1471 | ||
| 1472 | const heard = await until(stream, "order.refunded"); | |
| 1473 | const events = [...heard.matchAll(/^data: (.*)$/gm)].map((line) => JSON.parse(line[1]!)); | |
| 1474 | const mine = events.filter((event) => event.data?.number === number); | |
| 1475 | expect(mine.filter((event) => event.type === "order.paid").length).toBe(1); | |
| 1476 | expect(mine.filter((event) => event.type === "order.refunded")).toEqual([ | |
| 1477 | { type: "order.refunded", data: { id: orderNamed(number).id, number, cents: 100 } }, | |
| 1478 | ]); | |
| 1479 | stop.abort(); | |
| 1480 | }); | |
| 1481 | ||
| 1482 | test("the stream is closed to anyone who is not an admin", async () => { | |
| 1483 | for (const person of [undefined, { id: "u_2", name: "bo", role: "user" as const }]) { | |
| 1484 | const response = await fetch(base + "/admin/events", { headers: identityHeaders(person) }); | |
| 1485 | expect(response.status).toBe(403); | |
| 1486 | expect(response.headers.get("content-type")).not.toContain("event-stream"); | |
| 1487 | expect(await response.text()).toBe("forbidden"); | |
| 1488 | } | |
| 1489 | }); | |
| 1490 | ||
| 1491 | /** | |
| 1492 | * Every other door here proves who is asking on every request. A stream is | |
| 1493 | * one request that lasts, so it is given an end: what it must not be is a way | |
| 1494 | * for an identity the forge has stopped vouching for to keep hearing the | |
| 1495 | * shop's orders, in cents, for as long as it holds the socket open. | |
| 1496 | */ | |
| 1497 | test( | |
| 1498 | "a stream does not outlive the identity that opened it", | |
| 1499 | async () => { | |
| 1500 | const brief = { id: "u_brief", name: "ada", role: "admin" as const, expiresMs: Date.now() + 1500 }; | |
| 1501 | const mark = live.emit("test.mark", {}); | |
| 1502 | const stream = await fetch(`${base}/admin/events?since=${mark.id}`, { headers: identityHeaders(brief) }); | |
| 1503 | expect(stream.status).toBe(200); | |
| 1504 | ||
| 1505 | // Read it to its end. It has one: reaching this line is the assertion. | |
| 1506 | const reader = stream.body!.getReader(); | |
| 1507 | const decoder = new TextDecoder(); | |
| 1508 | let text = ""; | |
| 1509 | const drained = (async () => { | |
| 1510 | for (;;) { | |
| 1511 | const step = await reader.read(); | |
| 1512 | if (step.done) return; | |
| 1513 | text += decoder.decode(step.value, { stream: true }); | |
| 1514 | } | |
| 1515 | })(); | |
| 1516 | const ended = await Promise.race([drained.then(() => true), Bun.sleep(10_000).then(() => false)]); | |
| 1517 | expect(ended).toBe(true); | |
| 1518 | expect(Date.now()).toBeGreaterThanOrEqual(brief.expiresMs); | |
| 1519 | expect(text).toContain("retry: 2000"); | |
| 1520 | ||
| 1521 | // Past the expiry, and it hears nothing of this. | |
| 1522 | live.emit("order.paid", { id: 1, number: "BZ-AFTER-EXPIRY", cents: 1234 }); | |
| 1523 | await Bun.sleep(1300); // Longer than a pass of the poll. | |
| 1524 | expect(text).not.toContain("BZ-AFTER-EXPIRY"); | |
| 1525 | ||
| 1526 | // The door it would come back through is the one the pages use, and it | |
| 1527 | // is shut now: the reconnect is where an expired admin is turned away. | |
| 1528 | const again = await fetch(base + "/admin/events", { headers: identityHeaders(brief) }); | |
| 1529 | expect(again.status).toBe(403); | |
| 1530 | expect((await fetch(base + "/admin", { headers: identityHeaders(brief) })).status).toBe(403); | |
| 1531 | }, | |
| 1532 | 15_000, | |
| 1533 | ); | |
| 1534 | ||
| 1535 | test("the floor allows this origin's own script and no inline one", async () => { | |
| 1536 | const policy = (await fetch(base + "/")).headers.get("content-security-policy")!; | |
| 1537 | // 'self' and nothing more on script-src: no host, no nonce, no scheme. | |
| 1538 | expect(policy).toContain("script-src 'self';"); | |
| 1539 | expect(policy).not.toContain("unsafe-eval"); | |
| 1540 | // Inline styles are allowed and inline scripts are not: the only | |
| 1541 | // 'unsafe-inline' in the policy is the one on style-src. | |
| 1542 | expect(policy.match(/'unsafe-inline'/g)!.length).toBe(1); | |
| 1543 | expect(policy).toContain("style-src 'self' 'unsafe-inline'"); | |
| 1544 | }); | |
| 1545 | ||
| 1546 | test("the shop loads no script at all, and the admin's live pages load one file", async () => { | |
| 1547 | for (const path of ["/", "/c/coffee", "/p/ethiopia-guji", "/cart", "/checkout", "/gift-cards", "/orders"]) { | |
| 1548 | const shopper = new Shopper(); | |
| 1549 | await addToCart(shopper, "COF-GUJI-250", 1); // so /checkout is a page and not a redirect | |
| 1550 | expect(await shopper.page(path)).not.toContain("<script"); | |
| 1551 | } | |
| 1552 | ||
| 1553 | const admin = asAdmin(); | |
| 1554 | const dashboard = await admin.page("/admin"); | |
| 1555 | const src = /<script type="module" src="([^"]+)"><\/script>/.exec(dashboard)![1]!; | |
| 1556 | expect(src).toMatch(/^\/static\/live\.[0-9a-f]{8}\.js$/); | |
| 1557 | expect(dashboard.split("<script").length).toBe(2); | |
| 1558 | ||
| 1559 | const script = await fetch(base + src); | |
| 1560 | expect(script.status).toBe(200); | |
| 1561 | expect(script.headers.get("content-type")).toContain("javascript"); | |
| 1562 | expect(script.headers.get("cache-control")).toContain("immutable"); | |
| 1563 | expect(await script.text()).toContain("data-live"); | |
| 1564 | ||
| 1565 | // The name is the contents: a different name is not this file. | |
| 1566 | expect((await fetch(base + "/static/live.00000000.js")).status).toBe(404); | |
| 1567 | expect((await fetch(base + "/static/live.js")).status).toBe(404); | |
| 1568 | ||
| 1569 | // A page with no live region on it stays a page with no script on it. | |
| 1570 | expect(await admin.page("/admin/tax")).not.toContain("<script"); | |
| 1571 | }); | |
| 1572 | ||
| 1573 | test("the admin's live regions are marked with stable ids", async () => { | |
| 1574 | const admin = asAdmin(); | |
| 1575 | const regions = (html: string): Record<string, string> => | |
| 1576 | Object.fromEntries([...html.matchAll(/id="([^"]+)" data-live="([^"]+)"/g)].map((m) => [m[1]!, m[2]!])); | |
| 1577 | ||
| 1578 | const dashboard = regions(await admin.page("/admin")); | |
| 1579 | expect(Object.keys(dashboard)).toEqual(["admin-totals", "latest-orders"]); | |
| 1580 | expect(dashboard["latest-orders"]).toContain("order.placed"); | |
| 1581 | expect(regions(await admin.page("/admin/orders"))["order-list"]).toContain("order.paid"); | |
| 1582 | const inventory = regions(await admin.page("/admin/inventory")); | |
| 1583 | expect(inventory["stock-on-hand"]).toBe("stock.moved"); | |
| 1584 | expect(inventory["stock-ledger"]).toBe("stock.moved"); | |
| 1585 | // Fixed columns, so a row arriving cannot move the rows around it. | |
| 1586 | expect(await admin.page("/admin/inventory")).toContain('<table class="steady">'); | |
| 1587 | }); | |
| 1588 | ||
| 1381 | 1589 | test("this file's rows are this file's own", async () => { |
| 1382 | 1590 | // The isolation every test file depends on: the database the app modules |
| 1383 | 1591 | // are querying is the one this file opened, not one another file chose. |
src/server.ts +22 −9
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { dataDir, db, forgetPasswordSettings } from "./db.ts"; |
| 14 | import { fileOf } from "./asset.ts"; | |
| 14 | 15 | import { decode as decodeCart, COOKIE as CART_COOKIE } from "./cart.ts"; |
| 15 | 16 | import { decode as decodeCodes, COOKIE as CODES_COOKIE } from "./codes.ts"; |
| 16 | 17 | import { CSS, CSS_PATH } from "./style.ts"; |
| @@ -46,12 +47,18 @@ const env = process.env.FORGE_APP_ENV ?? "local"; | ||
| 46 | 47 | // this origin (inline styles allowed, inline scripts not), nothing frames us, |
| 47 | 48 | // browsers never guess a content type, and referrers stay on this site. |
| 48 | 49 | // |
| 50 | // `script-src 'self'` is the one loosening this app has made, and it is a | |
| 51 | // narrow one: the admin's live regions need static/live.js, which is a file | |
| 52 | // on this origin under a name made from its contents. There is no | |
| 53 | // 'unsafe-inline' and no 'unsafe-eval' here and no page carries an inline | |
| 54 | // script, so a value that escaped an escape still has nothing to run. | |
| 55 | // | |
| 49 | 56 | // Forms may only submit to this origin. The one exception is the response that |
| 50 | 57 | // hands a shopper to a hosted payment page: it names that origin with HAND_OFF |
| 51 | 58 | // and gets it added to `form-action` for itself alone, because Chromium checks |
| 52 | 59 | // the redirect a form navigation follows against this policy. |
| 53 | 60 | const csp = (formAction: string): string => |
| 54 | `default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action ${formAction}`; | |
| 61 | `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action ${formAction}`; | |
| 55 | 62 | |
| 56 | 63 | // A browser on another site can make a person's browser POST here; it cannot |
| 57 | 64 | // make that request look same-origin. Browsers say where a request came from |
| @@ -98,6 +105,12 @@ function finish(response: Response, cookies: string[]): Response { | ||
| 98 | 105 | export const server = Bun.serve({ |
| 99 | 106 | port: Number(process.env.PORT ?? 8080), |
| 100 | 107 | hostname: process.env.HOST ?? "127.0.0.1", |
| 108 | // This is the whole server's idle timeout, not the stream's: every | |
| 109 | // connection now gets a minute of silence rather than Bun's ten seconds. | |
| 110 | // The stream is why — it heartbeats every 25 seconds, which the default | |
| 111 | // would cut off — and the cost is that a shopper's idle socket is held six | |
| 112 | // times as long, which the forge's proxy in front of us makes cheap. | |
| 113 | idleTimeout: 60, | |
| 101 | 114 | async fetch(request, self) { |
| 102 | 115 | const url = new URL(request.url); |
| 103 | 116 | const method = request.method === "HEAD" ? "GET" : request.method; |
| @@ -113,14 +126,14 @@ export const server = Bun.serve({ | ||
| 113 | 126 | }), |
| 114 | 127 | [], |
| 115 | 128 | ); |
| 116 | if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) { | |
| 117 | const file = Bun.file(`${import.meta.dir}/../${url.pathname.slice(1)}`); | |
| 118 | if (await file.exists()) | |
| 119 | return finish( | |
| 120 | new Response(file, { headers: { "cache-control": "public, max-age=31536000, immutable" } }), | |
| 121 | [], | |
| 122 | ); | |
| 123 | } | |
| 129 | // The files that are files — static/live.js — the same way: the hash in | |
| 130 | // the path has to be the hash of the bytes, or it is not found here. | |
| 131 | const file = fileOf(url.pathname); | |
| 132 | if (file !== null) | |
| 133 | return finish( | |
| 134 | new Response(Bun.file(file), { headers: { "cache-control": "public, max-age=31536000, immutable" } }), | |
| 135 | [], | |
| 136 | ); | |
| 124 | 137 | |
| 125 | 138 | if (method === "POST" && !sameOrigin(request)) return finish(text("forbidden", 403), []); |
| 126 | 139 |
src/stripe.test.ts +2 −1
| @@ -397,7 +397,8 @@ test("a Checkout Session is built from the order's lines and asks for what is du | ||
| 397 | 397 | // so does not leave. Nothing else in the floor moves. |
| 398 | 398 | const policy = response.headers.get("content-security-policy")!; |
| 399 | 399 | expect(policy).toContain(`form-action 'self' ${fakeBase()}`); |
| 400 | expect(policy).toContain("script-src 'none'"); | |
| 400 | // Scripts still come from this origin only, and inline ones are still out. | |
| 401 | expect(policy).toContain("script-src 'self';"); | |
| 401 | 402 | expect(policy).toContain("frame-ancestors 'none'"); |
| 402 | 403 | expect(response.headers.get("x-hand-off-to")).toBeNull(); |
| 403 | 404 |
src/style.ts +5
| @@ -66,6 +66,11 @@ table { width: 100%; border-collapse: collapse; font-size: .95rem; } | ||
| 66 | 66 | th, td { text-align: left; padding: .55rem .5rem; border-bottom: 1px solid var(--line); vertical-align: top; } |
| 67 | 67 | th { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--dim); } |
| 68 | 68 | td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } |
| 69 | /* A table a row can arrive in while it is being read: the columns are laid | |
| 70 | out from the colgroup, not from the contents, so a longer value in a new | |
| 71 | row cannot move the rows above it — and it fits a phone at any width. */ | |
| 72 | table.steady { table-layout: fixed; } | |
| 73 | table.steady th, table.steady td { overflow-wrap: anywhere; } | |
| 69 | 74 | .totals { margin-left: auto; max-width: 20rem; } |
| 70 | 75 | .totals .row { display: flex; justify-content: space-between; padding: .3rem 0; } |
| 71 | 76 | .totals .row.grand { border-top: 1px solid var(--line); margin-top: .3rem; padding-top: .6rem; font-weight: 700; font-size: 1.1rem; } |
src/units.test.ts +25
| @@ -21,6 +21,7 @@ const changes = await import("./changes.ts"); | ||
| 21 | 21 | const desk = await import("./desk.ts"); |
| 22 | 22 | const discounts = await import("./discounts.ts"); |
| 23 | 23 | const giftcards = await import("./giftcards.ts"); |
| 24 | const live = await import("./live.ts"); | |
| 24 | 25 | const tax = await import("./tax.ts"); |
| 25 | 26 | const orders = await import("./orders.ts"); |
| 26 | 27 | const dbModule = await import("./db.ts"); |
| @@ -703,6 +704,30 @@ test("a gateway's amount is compared to what the order owes before anything is c | ||
| 703 | 704 | expect(orders.eventsOf(twice.id).filter((event) => event.kind === "paid").length).toBe(1); |
| 704 | 705 | }); |
| 705 | 706 | |
| 707 | test("the admin's screen is told about a payment only when the order becomes paid", () => { | |
| 708 | const paidEvents = (): unknown[] => heard.filter((event) => event.type === "order.paid"); | |
| 709 | const heard: { type: string; data: any }[] = []; | |
| 710 | const unsubscribe = live.subscribe((event) => heard.push({ type: event.type, data: event.data })); | |
| 711 | try { | |
| 712 | // Short. The ledger says awaiting payment, so a live page that said paid | |
| 713 | // would be the admin's screen lying about the money. | |
| 714 | const short = pendingOrder("BZ-SHORT-LIVE", 5000); | |
| 715 | orders.settleFromGateway(short, { gateway: "mock", ref: "ref_short_live", amount_cents: 4500 }); | |
| 716 | expect(paidEvents()).toEqual([]); | |
| 717 | ||
| 718 | // Covered: one event, from the branch that moved the row to paid. | |
| 719 | const full = pendingOrder("BZ-FULL-LIVE", 5000); | |
| 720 | orders.settleFromGateway(full, { gateway: "mock", ref: "ref_full_live", amount_cents: 5000 }); | |
| 721 | expect(paidEvents()).toEqual([{ type: "order.paid", data: { id: full.id, number: "BZ-FULL-LIVE", cents: 5000 } }]); | |
| 722 | ||
| 723 | // The webhook arriving behind the browser is the same charge, not another. | |
| 724 | orders.settleFromGateway(full, { gateway: "mock", ref: "ref_full_again", amount_cents: 5000 }); | |
| 725 | expect(paidEvents().length).toBe(1); | |
| 726 | } finally { | |
| 727 | unsubscribe(); | |
| 728 | } | |
| 729 | }); | |
| 730 | ||
| 706 | 731 | test("a test file's database is its own, from the factory", () => { |
| 707 | 732 | // bun test shares one process and one module registry across files, so this |
| 708 | 733 | // is the assertion that keeps every other one honest: the database the app |
src/who.ts +15
| @@ -46,6 +46,21 @@ export function whoIs(headers: Headers, now = Date.now()): Who { | ||
| 46 | 46 | return { role, id, name }; |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | /** | |
| 50 | * When the forge stops vouching for whoever is asking, as a stamp, or 0 when | |
| 51 | * there is nobody verified to stop vouching for. Every ordinary handler can | |
| 52 | * ignore this — it runs once per request, and `whoIs` has already refused an | |
| 53 | * expiry that has passed. It is here for the one thing that outlives a | |
| 54 | * request, the event stream, which has to be told when to end. | |
| 55 | * | |
| 56 | * The field is read only after `whoIs` has proved the signature over the | |
| 57 | * whole identity string, so it is the forge's number and not the caller's. | |
| 58 | */ | |
| 59 | export function expiresAt(headers: Headers, now = Date.now()): number { | |
| 60 | if (whoIs(headers, now).role === "anon") return 0; | |
| 61 | return Number(headers.get(IDENTITY_HEADER)!.split("|")[3]); | |
| 62 | } | |
| 63 | ||
| 49 | 64 | /** False when no FORGE_APP_IDENTITY_KEY is set: then nobody can be an admin here. */ |
| 50 | 65 | export const identityConfigured = (): boolean => Boolean(process.env.FORGE_APP_IDENTITY_KEY); |
| 51 | 66 |
static/live.js added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | // The admin pages stay current without a reload. /admin/events is a stream | |
| 2 | // of what changed in the ledger; each event becomes `live` and `live:<type>` | |
| 3 | // on `document`, and any element with data-live="<types>" is refetched from | |
| 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. | |
| 6 | // | |
| 7 | // One refetch is in flight at a time; another event while one is running | |
| 8 | // coalesces into a single run after it. Without this file the page is the | |
| 9 | // page it is today, one reload behind. | |
| 10 | ||
| 11 | let since = ""; | |
| 12 | let tries = 0; | |
| 13 | let source; | |
| 14 | ||
| 15 | function connect() { | |
| 16 | source = new EventSource(since ? `/admin/events?since=${encodeURIComponent(since)}` : "/admin/events"); | |
| 17 | source.onopen = () => { | |
| 18 | if (tries > 0) refresh(); // Reconnected: whatever was missed, catch up. | |
| 19 | tries = 0; | |
| 20 | }; | |
| 21 | source.onmessage = (message) => { | |
| 22 | since = message.lastEventId; | |
| 23 | let event; | |
| 24 | try { | |
| 25 | event = JSON.parse(message.data); | |
| 26 | } catch { | |
| 27 | return; | |
| 28 | } | |
| 29 | document.dispatchEvent(new CustomEvent("live", { detail: event })); | |
| 30 | document.dispatchEvent(new CustomEvent(`live:${event.type}`, { detail: event.data })); | |
| 31 | }; | |
| 32 | // The browser retries a dropped connection itself; a refused one — no | |
| 33 | // longer an admin, say — it gives up on, and then we try again, later each | |
| 34 | // time, up to half a minute. | |
| 35 | source.onerror = () => { | |
| 36 | if (source.readyState !== EventSource.CLOSED) return; | |
| 37 | source.close(); | |
| 38 | setTimeout(connect, Math.min(30_000, 1000 * 2 ** tries++)); | |
| 39 | }; | |
| 40 | } | |
| 41 | ||
| 42 | let refreshing = false; | |
| 43 | let again = false; | |
| 44 | ||
| 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 | ); | |
| 49 | if (regions.length === 0) return; | |
| 50 | if (refreshing) { | |
| 51 | again = true; | |
| 52 | return; | |
| 53 | } | |
| 54 | refreshing = true; | |
| 55 | try { | |
| 56 | const response = await fetch(location.href, { headers: { accept: "text/html" } }); | |
| 57 | if (!response.ok) return; | |
| 58 | const fresh = new DOMParser().parseFromString(await response.text(), "text/html"); | |
| 59 | for (const element of regions) { | |
| 60 | // A region someone is typing in keeps what they typed: the forms in | |
| 61 | // these tables matter more than a fresh number, and the next event | |
| 62 | // swaps the region once the caret has moved on. | |
| 63 | if (element.contains(document.activeElement)) continue; | |
| 64 | const next = element.id && fresh.getElementById(element.id); | |
| 65 | if (next) element.replaceWith(next); | |
| 66 | } | |
| 67 | } catch { | |
| 68 | // Offline, or the page went away: the next event tries again. | |
| 69 | } finally { | |
| 70 | refreshing = false; | |
| 71 | if (again) { | |
| 72 | again = false; | |
| 73 | refresh(); | |
| 74 | } | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | document.addEventListener("live", (event) => refresh([event.detail.type])); | |
| 79 | connect(); |
Changes requested — one gap. The rest of this is careful work and should stay as it is.
What I did: ran the suite at
30bac3e(96 pass / 0 fail, and green under--randomize --seed 7,--isolateand--parallel=4), booted it locally with a signed admin identity, held a stream open for 75 s (heartbeats at 25 s and 50 s arrive, soidleTimeout: 60holds), and used the preview as a guest:/admin/eventsanswers403 forbiddenin plain text to anon and touser,/adminand/admin/inventoryare 403,/static/live.jsand/static/live.00000000.jsare both 404 and only the content-addressed name answers, and the floor isscript-src 'self'with the policy's single'unsafe-inline'still onstyle-src.High — a write in the terminal reaches nobody, and the page that looks live gives no sign it has gone stale
Locally, with an admin holding
/admin/eventsopen:POST /admin/inventory→id: 1/data: {"type":"stock.moved","data":{"id":12,"sku":"BRW-GRIND-2"}}. Good.addStock(12, 3, "terminal probe"), which is exactly what theskey does insrc/tty.ts— writes its ledger row and the stream stays silent./admin/inventorykeeps showing the old number until a person reloads.src/tty.tsmoves stock (s) and refunds orders (r) through the very functions that nowemit, but it is a second process, so itsemitfans out to a listener set that nobody in the web process is in. The README is honest about this, which I credit, but "the admin sees stock and orders change live as they happen" is the whole pull: today an admin at the ssh door silently desynchronises every open admin page, and a page that refreshes itself is trusted more than a page that does not. That is the failure this feature exists to remove, made worse rather than better for that one door.Fix this first. It keeps one bus, adds no dependency, and it is small: make
emitdurable and let whoever serves the stream read it.live_eventrow —id INTEGER PRIMARY KEY, type TEXT, data TEXT, at TEXT— written byemitin its own small transaction, after the caller's has committed exactly as now;src/live.ts, when the first listener subscribes, asetInterval(1 s is plenty for a shop this size;unrefit sobun testand the tty do not hang on it) that selects rows above the highest id already fanned out and delivers them, cleared when the last listener leaves. Trim the table the waykeptis trimmed.That also makes
Last-Event-ID/?since=row ids that survive a restart, and it puts the event trail in the audit story the rest of the app already tells. Two tests would pin it: a row written by a second process reaches an open stream, and one written by this process is delivered exactly once (not twice — once by the fan-out and again by the poll).Smaller, not blocking
src/live.ts:controller.enqueueis unbounded. A wedged admin connection queues every event until the process gives up. Dropping the connection past a few hundred queued events is enough —EventSourcereconnects and replays.src/server.ts:idleTimeout: 60is the whole server's, not the stream's; every connection now gets six times as long to say nothing. Fine given the stream needs it and the forge is in front, but say that in the comment — it reads today as if it were only about the stream.admin-totals,latest-orders,order-list,stock-on-hand,stock-ledger, fixedcolgroups) and it is sound.What is right and should not change under review
emitafter the commit and only where a ledger row is written, with the in-transaction stock move left to whoever commits it, so a rollback tells nobody. One event per charge throughtook.charged, so a webhook and a browser returning together still say it once. Payloads of id, number, cents and SKU, with a test that asserts the key set and that no email, address or gateway reference travels./admin/eventsgated before a stream is allocated, and a plain-textforbiddenrather than a page. Andsrc/asset.tsrefusing a stale hash instead of serving year-cached bytes under the wrong name is stricter than the/static/branch it replaced.