cursedbelt-server 4.20.0 → 4.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,6 +36,7 @@ actually keeps.
36
36
  | `./guard/revocations` | — | `bun:sqlite` |
37
37
  | `./sqlite` | — | `bun:sqlite` |
38
38
  | `./engagement` | — | `bun:sqlite` |
39
+ | `./request-log` | — | `bun:sqlite` |
39
40
 
40
41
  `sharp` and `@node-rs/argon2` are loaded lazily (`await import()`), so a missing one breaks only
41
42
  the feature that asks for it, not the build.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `cursedbelt-server/request-log` — the request log, "is this app healthy": one row per request
3
+ * served, in `<APP_DATA_DIR>/metrics.sqlite`, the file station's Metrics page reads for every app on
4
+ * this Mac (`apps/station/src/server/fleetMetrics.ts`, `METRICS_DB`).
5
+ *
6
+ * ── Why it is here (4.21.0) ─────────────────────────────────────────────────
7
+ * `apps/family` and `apps/roms` held this module byte-identically (task 265's rollout), and
8
+ * `check-copies` counted it as five copy groups. The API, the header marker and the migration name
9
+ * are exactly the apps' — a live corpus is recognised by `METRICS_APPLICATION_ID` and by the
10
+ * `0001_request_metrics` row in its migrations table, so changing either would make every
11
+ * existing `metrics.sqlite` look inherited and archive it. Bun-only (`bun:sqlite`, `node:fs`): under
12
+ * Node the export map routes this subpath to `_bunOnly.js`, the way `./engagement` does; it is NOT
13
+ * part of `./telemetry`, which is Worker-safe (`telemetryIsWorkerSafe.spec.ts`).
14
+ *
15
+ * ── Why it came back, 2026-09-23 (task 265) ─────────────────────────────────
16
+ * `serve.ts` once recorded that request metrics were GONE, on the grounds that the
17
+ * console that read them was in the retired generation. Station reads them now, and
18
+ * a measurement found that all eight apps recorded nothing while their
19
+ * `metrics.sqlite` files — the RETIRED instances' history, inherited at graduation —
20
+ * rendered as a corpus that had simply gone quiet. This mounts `cursedbelt-server`'s
21
+ * own seam (`requestLogger` through `createTelemetrySink`, the modules the Worker-safe
22
+ * `cursedbelt-server/telemetry` leaf re-exports).
23
+ *
24
+ * ── 🔴 The inherited file is ARCHIVED, never written ────────────────────────
25
+ * The file already at `metrics.sqlite` on the live machine is a previous
26
+ * generation's history under a different schema (`id INTEGER AUTOINCREMENT` plus
27
+ * `ip`/`country`/`city`/`device`/`form`; this writer inserts `id TEXT` plus
28
+ * `user_id`). Appending to it would make every future measurement unreadable, so
29
+ * {@link retireInheritedCorpus} runs at every boot, BEFORE the database is opened:
30
+ *
31
+ * - a file that carries {@link METRICS_APPLICATION_ID} in its header is this
32
+ * writer's own, and is left alone — so a second boot is a no-op;
33
+ * - anything else is RENAMED, with its `-wal`/`-shm` sidecars, to
34
+ * `metrics.retired.sqlite` — or `metrics.retired-2.sqlite`, `-3`, … when that
35
+ * name is taken, so two inherited corpora are both kept and nothing is ever
36
+ * overwritten. A rename touches no byte of the file, which is the point.
37
+ *
38
+ * The marker lives in the SQLite header (`PRAGMA application_id`, offset 68) so it
39
+ * can be read without opening the database — opening an inherited WAL database
40
+ * read-write would checkpoint into it on close, which IS writing into it. The
41
+ * migration checkpoints after stamping it, so the marker is on disk before the
42
+ * first request is recorded and a crash cannot leave a fresh corpus that the next
43
+ * boot mistakes for an inherited one.
44
+ *
45
+ * ── 🔴 No user id, ever ─────────────────────────────────────────────────────
46
+ * `cursedbelt-server/engagement`'s header states the split: `metrics/` promises it
47
+ * holds NO user id, in every app; `engagement.sqlite` is the user-keyed store. The
48
+ * writer's schema has a `user_id` column and `requestLogger` fills it from
49
+ * `c.get("userId")`, so the sink here nulls it on every event — whatever any future
50
+ * middleware puts on the context — and the logger is given no database of its own,
51
+ * which means it writes no `event_logs` rows either (those carry a `user_id` too,
52
+ * and nothing in this generation reads them).
53
+ *
54
+ * ── Never takes the app down ────────────────────────────────────────────────
55
+ * {@link openRequestMetricsFromEnv} catches everything and says so on stderr: an
56
+ * app that cannot record its traffic must still SERVE it. What catches an app that
57
+ * silently STOPS recording is `requestMetrics.test.ts`, which drives `/healthz`
58
+ * through a real server and fails when no row lands — `requestMetrics.spec.ts` here, and each
59
+ * app's own test through its own `createServer`.
60
+ */
61
+ import { Database } from "bun:sqlite";
62
+ import type { MiddlewareHandler } from "hono";
63
+ import { type TelemetrySink } from "../metrics/telemetrySink.js";
64
+ /** The file station reads, inside the app's data directory. */
65
+ export declare const METRICS_DB = "metrics.sqlite";
66
+ /** Where an inherited corpus is moved to — the first free of this and `metrics.retired-<n>.sqlite`. */
67
+ export declare const RETIRED_METRICS_DB = "metrics.retired.sqlite";
68
+ /**
69
+ * Move an inherited `metrics.sqlite` out of the way, or do nothing. Returns the
70
+ * path it was archived to, or null when there was nothing to archive. See the
71
+ * header: never writes a byte of the file, never overwrites anything, idempotent.
72
+ */
73
+ export declare function retireInheritedCorpus(dir: string): string | null;
74
+ export interface RequestMetrics {
75
+ db: Database;
76
+ /** Pending rows are flushed on a 1.5 s timer; `flush()` forces it (tests, shutdown). */
77
+ sink: TelemetrySink;
78
+ /** Register FIRST — ahead of every route it must see. */
79
+ middleware: MiddlewareHandler;
80
+ /** Where an inherited corpus went at this boot, if one was found. */
81
+ archived: string | null;
82
+ }
83
+ /** Open the corpus in `dir` and build the logger that writes into it. */
84
+ export declare function openRequestMetrics(dir: string): RequestMetrics;
85
+ /**
86
+ * The request log for a DEPLOYED instance, or null. Deployed means `APP_DATA_DIR`
87
+ * is set (the launchd plist sets it) and no test runner is in charge. A dev shell
88
+ * records nothing rather than appending a developer's clicks to the live corpus.
89
+ * Never throws: a failure is one stderr line and an app that serves unmeasured.
90
+ */
91
+ export declare function openRequestMetricsFromEnv(name: string, env: NodeJS.ProcessEnv): RequestMetrics | null;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `cursedbelt-server/request-log` — the request log, "is this app healthy": one row per request
3
+ * served, in `<APP_DATA_DIR>/metrics.sqlite`, the file station's Metrics page reads for every app on
4
+ * this Mac (`apps/station/src/server/fleetMetrics.ts`, `METRICS_DB`).
5
+ *
6
+ * ── Why it is here (4.21.0) ─────────────────────────────────────────────────
7
+ * `apps/family` and `apps/roms` held this module byte-identically (task 265's rollout), and
8
+ * `check-copies` counted it as five copy groups. The API, the header marker and the migration name
9
+ * are exactly the apps' — a live corpus is recognised by `METRICS_APPLICATION_ID` and by the
10
+ * `0001_request_metrics` row in its migrations table, so changing either would make every
11
+ * existing `metrics.sqlite` look inherited and archive it. Bun-only (`bun:sqlite`, `node:fs`): under
12
+ * Node the export map routes this subpath to `_bunOnly.js`, the way `./engagement` does; it is NOT
13
+ * part of `./telemetry`, which is Worker-safe (`telemetryIsWorkerSafe.spec.ts`).
14
+ *
15
+ * ── Why it came back, 2026-09-23 (task 265) ─────────────────────────────────
16
+ * `serve.ts` once recorded that request metrics were GONE, on the grounds that the
17
+ * console that read them was in the retired generation. Station reads them now, and
18
+ * a measurement found that all eight apps recorded nothing while their
19
+ * `metrics.sqlite` files — the RETIRED instances' history, inherited at graduation —
20
+ * rendered as a corpus that had simply gone quiet. This mounts `cursedbelt-server`'s
21
+ * own seam (`requestLogger` through `createTelemetrySink`, the modules the Worker-safe
22
+ * `cursedbelt-server/telemetry` leaf re-exports).
23
+ *
24
+ * ── 🔴 The inherited file is ARCHIVED, never written ────────────────────────
25
+ * The file already at `metrics.sqlite` on the live machine is a previous
26
+ * generation's history under a different schema (`id INTEGER AUTOINCREMENT` plus
27
+ * `ip`/`country`/`city`/`device`/`form`; this writer inserts `id TEXT` plus
28
+ * `user_id`). Appending to it would make every future measurement unreadable, so
29
+ * {@link retireInheritedCorpus} runs at every boot, BEFORE the database is opened:
30
+ *
31
+ * - a file that carries {@link METRICS_APPLICATION_ID} in its header is this
32
+ * writer's own, and is left alone — so a second boot is a no-op;
33
+ * - anything else is RENAMED, with its `-wal`/`-shm` sidecars, to
34
+ * `metrics.retired.sqlite` — or `metrics.retired-2.sqlite`, `-3`, … when that
35
+ * name is taken, so two inherited corpora are both kept and nothing is ever
36
+ * overwritten. A rename touches no byte of the file, which is the point.
37
+ *
38
+ * The marker lives in the SQLite header (`PRAGMA application_id`, offset 68) so it
39
+ * can be read without opening the database — opening an inherited WAL database
40
+ * read-write would checkpoint into it on close, which IS writing into it. The
41
+ * migration checkpoints after stamping it, so the marker is on disk before the
42
+ * first request is recorded and a crash cannot leave a fresh corpus that the next
43
+ * boot mistakes for an inherited one.
44
+ *
45
+ * ── 🔴 No user id, ever ─────────────────────────────────────────────────────
46
+ * `cursedbelt-server/engagement`'s header states the split: `metrics/` promises it
47
+ * holds NO user id, in every app; `engagement.sqlite` is the user-keyed store. The
48
+ * writer's schema has a `user_id` column and `requestLogger` fills it from
49
+ * `c.get("userId")`, so the sink here nulls it on every event — whatever any future
50
+ * middleware puts on the context — and the logger is given no database of its own,
51
+ * which means it writes no `event_logs` rows either (those carry a `user_id` too,
52
+ * and nothing in this generation reads them).
53
+ *
54
+ * ── Never takes the app down ────────────────────────────────────────────────
55
+ * {@link openRequestMetricsFromEnv} catches everything and says so on stderr: an
56
+ * app that cannot record its traffic must still SERVE it. What catches an app that
57
+ * silently STOPS recording is `requestMetrics.test.ts`, which drives `/healthz`
58
+ * through a real server and fails when no row lands — `requestMetrics.spec.ts` here, and each
59
+ * app's own test through its own `createServer`.
60
+ */
61
+ import { Database } from "bun:sqlite";
62
+ import { closeSync, existsSync, mkdirSync, openSync, readSync, renameSync, statSync } from "node:fs";
63
+ import { join } from "node:path";
64
+ import { applyCcPragmas } from "cwip/sqlite";
65
+ import { createTelemetrySink } from "../metrics/telemetrySink.js";
66
+ import { requestLogger } from "../middleware/requestLogger.js";
67
+ import { runMigrations } from "../migration/runner.js";
68
+ import { isTestRuntime } from "../satellite/door.js";
69
+ /** The file station reads, inside the app's data directory. */
70
+ export const METRICS_DB = "metrics.sqlite";
71
+ /** Where an inherited corpus is moved to — the first free of this and `metrics.retired-<n>.sqlite`. */
72
+ export const RETIRED_METRICS_DB = "metrics.retired.sqlite";
73
+ /** `PRAGMA application_id` of a corpus THIS writer created: "CFRM", cursedforge request metrics. */
74
+ const METRICS_APPLICATION_ID = 0x4346524d;
75
+ const SIDECARS = ["-wal", "-shm", "-journal"];
76
+ /**
77
+ * The writer's own schema — the columns `cursedbelt-server`'s `metricsBuffer`
78
+ * inserts, and the ones station's reader asks for (`ts`, `method`, `route`,
79
+ * `status`, `duration_ms`, `bytes_out`). Append-only: a migration, once applied,
80
+ * never changes.
81
+ */
82
+ const REQUEST_METRICS_MIGRATIONS = [
83
+ {
84
+ name: "0001_request_metrics",
85
+ up(db) {
86
+ db.run(`PRAGMA application_id = ${METRICS_APPLICATION_ID}`);
87
+ db.run(`CREATE TABLE request_metrics (
88
+ id TEXT PRIMARY KEY, ts INTEGER NOT NULL, method TEXT, route TEXT,
89
+ status INTEGER, duration_ms REAL, bytes_out INTEGER, user_id TEXT)`);
90
+ db.run("CREATE INDEX idx_request_metrics_ts ON request_metrics (ts)");
91
+ },
92
+ },
93
+ ];
94
+ /** The `application_id` stamped in a SQLite file's header, or null when it has no header yet. */
95
+ function headerApplicationId(path) {
96
+ const fd = openSync(path, "r");
97
+ try {
98
+ const header = Buffer.alloc(72);
99
+ if (readSync(fd, header, 0, 72, 0) < 72)
100
+ return null;
101
+ return header.readUInt32BE(68);
102
+ }
103
+ finally {
104
+ closeSync(fd);
105
+ }
106
+ }
107
+ const sizeOf = (path) => (existsSync(path) ? statSync(path).size : 0);
108
+ /**
109
+ * Move an inherited `metrics.sqlite` out of the way, or do nothing. Returns the
110
+ * path it was archived to, or null when there was nothing to archive. See the
111
+ * header: never writes a byte of the file, never overwrites anything, idempotent.
112
+ */
113
+ export function retireInheritedCorpus(dir) {
114
+ const live = join(dir, METRICS_DB);
115
+ if (!existsSync(live))
116
+ return null;
117
+ // An empty file with no journal holds nothing — SQLite simply initialises it.
118
+ if (sizeOf(live) === 0 && SIDECARS.every((s) => sizeOf(live + s) === 0))
119
+ return null;
120
+ if (headerApplicationId(live) === METRICS_APPLICATION_ID)
121
+ return null;
122
+ const present = SIDECARS.filter((s) => existsSync(live + s));
123
+ let target = join(dir, RETIRED_METRICS_DB);
124
+ for (let n = 2; existsSync(target) || present.some((s) => existsSync(target + s)); n++) {
125
+ target = join(dir, RETIRED_METRICS_DB.replace(/\.sqlite$/, `-${n}.sqlite`));
126
+ }
127
+ // Sidecars FIRST, then the database: a crash between the two leaves the database
128
+ // where the next boot finds it again, and — because a moved sidecar is no longer
129
+ // at the source — the loop above picks this same `target`, so the pair is rejoined.
130
+ for (const s of present)
131
+ renameSync(live + s, target + s);
132
+ renameSync(live, target);
133
+ return target;
134
+ }
135
+ /** Retire any inherited corpus, then open (creating if need be) this writer's own. */
136
+ function openMetricsDb(dir) {
137
+ mkdirSync(dir, { recursive: true });
138
+ const archived = retireInheritedCorpus(dir);
139
+ const db = new Database(join(dir, METRICS_DB), { create: true });
140
+ applyCcPragmas(db);
141
+ if (runMigrations(db, REQUEST_METRICS_MIGRATIONS).length > 0) {
142
+ // Put the header marker in the main file now, not at some later checkpoint.
143
+ db.run("PRAGMA wal_checkpoint(TRUNCATE)");
144
+ }
145
+ return { db, archived };
146
+ }
147
+ /** Open the corpus in `dir` and build the logger that writes into it. */
148
+ export function openRequestMetrics(dir) {
149
+ const { db, archived } = openMetricsDb(dir);
150
+ const inner = createTelemetrySink({ db });
151
+ // 🔴 No user id — see the header. Nulled here so no context value can reach the row.
152
+ const sink = {
153
+ record: (event) => inner.record({ ...event, userId: null }),
154
+ flush: () => inner.flush(),
155
+ stop: () => inner.stop(),
156
+ get size() {
157
+ return inner.size;
158
+ },
159
+ };
160
+ // `null` database: the logger writes through `sink` only, and no `event_logs`.
161
+ const middleware = requestLogger(null, { sink });
162
+ return { db, sink, middleware, archived };
163
+ }
164
+ /**
165
+ * The request log for a DEPLOYED instance, or null. Deployed means `APP_DATA_DIR`
166
+ * is set (the launchd plist sets it) and no test runner is in charge. A dev shell
167
+ * records nothing rather than appending a developer's clicks to the live corpus.
168
+ * Never throws: a failure is one stderr line and an app that serves unmeasured.
169
+ */
170
+ export function openRequestMetricsFromEnv(name, env) {
171
+ const dir = env.APP_DATA_DIR?.trim();
172
+ if (!dir || isTestRuntime(env))
173
+ return null;
174
+ try {
175
+ const metrics = openRequestMetrics(dir);
176
+ if (metrics.archived) {
177
+ console.log(`[${name}] request metrics: archived the inherited corpus to ${metrics.archived}`);
178
+ }
179
+ return metrics;
180
+ }
181
+ catch (error) {
182
+ console.error(`[${name}] request metrics disabled: ${error instanceof Error ? error.message : String(error)}`);
183
+ return null;
184
+ }
185
+ }
@@ -116,8 +116,13 @@ export function createWsHub(opts) {
116
116
  const client = clientFromWs(ws);
117
117
  if (!client)
118
118
  return;
119
- channelOf(client.channel)?.onDisconnect?.(client, hub);
119
+ // 🔴 Remove FIRST, then tell the channel. The recipe every presence app follows is
120
+ // "broadcast hub.getPresence(channel) from onDisconnect" (cursedbelt's usePresence doc),
121
+ // and until 4.20.1 this called onDisconnect while the leaver was still registered — so
122
+ // the roster it broadcast still listed the person who had just left. apps/roms deferred a
123
+ // microtask to work around it (src/server/presence.ts). hub.spec.ts pins the order.
120
124
  clients.delete(client.id);
125
+ channelOf(client.channel)?.onDisconnect?.(client, hub);
121
126
  },
122
127
  },
123
128
  broadcast(channel, event, data) {
@@ -61,7 +61,8 @@ export interface WsChannel<TMsg = WsMessage> {
61
61
  onConnect?(client: WsClient, hub: WsHub): void;
62
62
  /** Fired once per inbound frame (the parsed `{ event, data }` envelope). */
63
63
  onMessage(client: WsClient, msg: TMsg, hub: WsHub): void;
64
- /** Fired when the socket closes (explicit, stale-culled, or peer-dropped). */
64
+ /** Fired when the socket closes (explicit, stale-culled, or peer-dropped) — AFTER the client is
65
+ * removed, so `hub.getPresence()` here is the roster without it (4.20.1). */
65
66
  onDisconnect?(client: WsClient, hub: WsHub): void;
66
67
  /** Whether this channel contributes to {@link WsHub.getPresence}. Default: false. */
67
68
  presence?: boolean;
@@ -55,4 +55,6 @@ export const MAY_NEED_BUN = {
55
55
  // The engagement store IS `engagement.sqlite` on the Mac (4.19.0, task 280) — the recorder
56
56
  // runs where the app's own database is. A Worker app records nothing until it has a D1 store.
57
57
  './engagement': ['bun:sqlite'],
58
+ // The request log IS `metrics.sqlite` beside the app's data (4.21.0) — a Mac-hosted server's.
59
+ './request-log': ['bun:sqlite'],
58
60
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.20.0",
3
+ "version": "4.21.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -215,6 +215,12 @@
215
215
  "source": "./src/server/auth/passwordCost.ts",
216
216
  "import": "./dist/server/_bunOnly.js"
217
217
  },
218
+ "./request-log": {
219
+ "types": "./dist/server/requestLog/requestMetrics.d.ts",
220
+ "bun": "./src/server/requestLog/requestMetrics.ts",
221
+ "source": "./src/server/requestLog/requestMetrics.ts",
222
+ "import": "./dist/server/_bunOnly.js"
223
+ },
218
224
  "./notifications": {
219
225
  "types": "./dist/server/notifications/index.d.ts",
220
226
  "bun": "./src/server/notifications/index.ts",
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The request log's CHECK (task 265, step 4), lifted from apps/family + apps/roms with the module
3
+ * (4.21.0): a request through a real Hono app with the middleware mounted FIRST lands a row in
4
+ * `metrics.sqlite` — and the same app without it lands none, so the assertion can red. Each app
5
+ * keeps its own test through its own `createServer`; this one proves the library's half.
6
+ *
7
+ * Plus the two promises `requestMetrics.ts` makes: the inherited corpus is archived
8
+ * and never written, and no user id reaches a row.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+ import { Database } from "bun:sqlite";
12
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import {
16
+ METRICS_DB,
17
+ openRequestMetrics,
18
+ openRequestMetricsFromEnv,
19
+ RETIRED_METRICS_DB,
20
+ type RequestMetrics,
21
+ retireInheritedCorpus,
22
+ } from "./requestMetrics.js";
23
+ import { Hono } from "hono";
24
+
25
+ let dir: string;
26
+ const opened: RequestMetrics[] = [];
27
+
28
+ beforeEach(() => {
29
+ dir = mkdtempSync(join(tmpdir(), "cbs-request-metrics-"));
30
+ });
31
+ afterEach(() => {
32
+ for (const m of opened.splice(0)) {
33
+ m.sink.stop();
34
+ m.db.close();
35
+ }
36
+ rmSync(dir, { recursive: true });
37
+ });
38
+
39
+ const track = (m: RequestMetrics | null): RequestMetrics => {
40
+ if (!m) throw new Error("expected a request log");
41
+ opened.push(m);
42
+ return m;
43
+ };
44
+
45
+ /** What station reads: every row, oldest first. */
46
+ function rows(path = join(dir, METRICS_DB)): Array<Record<string, unknown>> {
47
+ const db = new Database(path, { readonly: true });
48
+ try {
49
+ return db.query("SELECT method, route, status, bytes_out, user_id FROM request_metrics ORDER BY ts").all() as Array<
50
+ Record<string, unknown>
51
+ >;
52
+ } finally {
53
+ db.close();
54
+ }
55
+ }
56
+
57
+ /** The retired generation's schema, as `apps/station`'s fixture spells it. */
58
+ function writeInheritedCorpus(path: string): void {
59
+ const db = new Database(path, { create: true });
60
+ db.run(`CREATE TABLE request_metrics (
61
+ id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, method TEXT NOT NULL,
62
+ route TEXT NOT NULL, status INTEGER NOT NULL, duration_ms REAL NOT NULL,
63
+ bytes_out INTEGER NOT NULL DEFAULT 0, ip TEXT NOT NULL DEFAULT '', country TEXT NOT NULL DEFAULT '')`);
64
+ db.run("INSERT INTO request_metrics (ts, method, route, status, duration_ms) VALUES (1, 'GET', '/old', 200, 3)");
65
+ db.close();
66
+ }
67
+
68
+ /** A real Hono app: the logger mounted FIRST (as an app does), then its routes and a catch-all miss. */
69
+ function appOver(metrics: RequestMetrics | null, mount?: (app: Hono) => void): Hono {
70
+ const app = new Hono();
71
+ if (metrics) app.use("*", metrics.middleware);
72
+ app.get("/healthz", (c) => c.json({ ok: true }));
73
+ mount?.(app);
74
+ app.all("*", (c) => c.text("not found", 404));
75
+ return app;
76
+ }
77
+
78
+ describe("the check: a request through a real app lands a row", () => {
79
+ test("GET /healthz lands one row in metrics.sqlite", async () => {
80
+ const metrics = track(openRequestMetrics(dir));
81
+ const app = appOver(metrics);
82
+ expect((await app.fetch(new Request("http://app.test/healthz"))).status).toBe(200);
83
+ metrics.sink.flush();
84
+
85
+ expect(rows()).toEqual([expect.objectContaining({ method: "GET", route: "/healthz", status: 200, user_id: null })]);
86
+ });
87
+
88
+ test("FAILURE PATH: without the logger the same request lands nothing — the check above can red", async () => {
89
+ const metrics = track(openRequestMetrics(dir));
90
+ const app = appOver(null);
91
+ expect((await app.fetch(new Request("http://app.test/healthz"))).status).toBe(200);
92
+ metrics.sink.flush();
93
+
94
+ expect(rows()).toEqual([]);
95
+ });
96
+
97
+ test("it sees every route: a parameterised route by its PATTERN, and a miss", async () => {
98
+ const metrics = track(openRequestMetrics(dir));
99
+ const app = appOver(metrics, (a) => a.get("/api/v1/thing/:id", (c) => c.json({ ok: true })));
100
+ await app.fetch(new Request("http://app.test/api/v1/thing/42"));
101
+ await app.fetch(new Request("http://app.test/assets/missing.js"));
102
+ metrics.sink.flush();
103
+
104
+ expect(rows().map((r) => `${r.route} ${r.status}`)).toEqual([
105
+ "/api/v1/thing/:id 200", // the PATTERN, never the id
106
+ "/assets/missing.js 404",
107
+ ]);
108
+ });
109
+
110
+ test("🔴 no user id reaches a row, whatever a middleware puts on the context", async () => {
111
+ const metrics = track(openRequestMetrics(dir));
112
+ const app = appOver(metrics, (a) =>
113
+ a.get("/api/v1/me", (c) => {
114
+ c.set("userId" as never, "user-1" as never);
115
+ return c.json({ ok: true });
116
+ }),
117
+ );
118
+ await app.fetch(new Request("http://app.test/api/v1/me"));
119
+ metrics.sink.flush();
120
+
121
+ expect(rows()).toEqual([expect.objectContaining({ route: "/api/v1/me", user_id: null })]);
122
+ });
123
+ });
124
+
125
+ describe("openRequestMetricsFromEnv — what a deployed server calls at boot", () => {
126
+ test("a deployed instance (APP_DATA_DIR, no test runner) records; the inherited corpus is archived first", async () => {
127
+ writeInheritedCorpus(join(dir, METRICS_DB));
128
+ const metrics = track(openRequestMetricsFromEnv("app", { APP_DATA_DIR: dir }));
129
+ await appOver(metrics).fetch(new Request("http://app.test/healthz"));
130
+ metrics.sink.flush();
131
+
132
+ expect(metrics.archived).toBe(join(dir, RETIRED_METRICS_DB));
133
+ expect(rows()).toEqual([expect.objectContaining({ route: "/healthz", status: 200 })]);
134
+ // The retired history is intact and was not appended to.
135
+ const retired = new Database(join(dir, RETIRED_METRICS_DB), { readonly: true });
136
+ expect(retired.query("SELECT route FROM request_metrics").all()).toEqual([{ route: "/old" }]);
137
+ retired.close();
138
+ });
139
+
140
+ test("a test runner, or no APP_DATA_DIR, records nothing and creates no file", () => {
141
+ expect(openRequestMetricsFromEnv("app", { APP_DATA_DIR: dir, BUN_TEST: "1" })).toBeNull();
142
+ expect(openRequestMetricsFromEnv("app", {})).toBeNull();
143
+ expect(existsSync(join(dir, METRICS_DB))).toBe(false);
144
+ });
145
+
146
+ test("a directory it cannot open serves unmeasured instead of throwing", () => {
147
+ writeFileSync(join(dir, "not-a-dir"), "");
148
+ expect(openRequestMetricsFromEnv("app", { APP_DATA_DIR: join(dir, "not-a-dir") })).toBeNull();
149
+ });
150
+ });
151
+
152
+ describe("🔴 the live-corpus markers are the apps' own, byte for byte", () => {
153
+ test("application_id 0x4346524d and the 0001_request_metrics migration — or every live corpus reads as inherited", () => {
154
+ const m = track(openRequestMetrics(dir));
155
+ expect((m.db.query("PRAGMA application_id").get() as { application_id: number }).application_id).toBe(0x4346524d);
156
+ const names = JSON.stringify(m.db.query("SELECT * FROM sqlite_master WHERE type = 'table'").all());
157
+ expect(names).toContain("request_metrics");
158
+ const tables = (m.db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all() as { name: string }[]).map((r) => r.name);
159
+ const migrationsTable = tables.find((n) => n !== "request_metrics" && n.toLowerCase().includes("migration"));
160
+ expect(migrationsTable, `no migrations table among ${tables.join(", ")}`).toBeDefined();
161
+ expect(JSON.stringify(m.db.query(`SELECT * FROM ${migrationsTable}`).all())).toContain("0001_request_metrics");
162
+ });
163
+ });
164
+
165
+ describe("🔴 the inherited corpus is archived, never written", () => {
166
+ test("a second boot leaves the writer's own corpus alone — even after a crash", () => {
167
+ const first = track(openRequestMetrics(dir));
168
+ expect(first.archived).toBeNull();
169
+ // Still OPEN, as if the process died here: the marker must already be on disk,
170
+ // or the next boot would archive this generation's own corpus.
171
+ expect(retireInheritedCorpus(dir)).toBeNull();
172
+ expect(existsSync(join(dir, RETIRED_METRICS_DB))).toBe(false);
173
+ });
174
+
175
+ test("the file moves byte-for-byte, WITH its sidecars", () => {
176
+ const live = join(dir, METRICS_DB);
177
+ writeInheritedCorpus(live);
178
+ writeFileSync(`${live}-wal`, "pending frames");
179
+ const before = readFileSync(live);
180
+
181
+ const target = retireInheritedCorpus(dir);
182
+ expect(target).toBe(join(dir, RETIRED_METRICS_DB));
183
+ expect(readFileSync(join(dir, RETIRED_METRICS_DB)).equals(before)).toBe(true);
184
+ expect(readFileSync(`${target}-wal`, "utf8")).toBe("pending frames");
185
+ expect(existsSync(live)).toBe(false);
186
+ expect(existsSync(`${live}-wal`)).toBe(false);
187
+ });
188
+
189
+ test("an archive already there is KEPT: the next inherited corpus takes the next free name", () => {
190
+ writeFileSync(join(dir, RETIRED_METRICS_DB), "the first archive");
191
+ writeInheritedCorpus(join(dir, METRICS_DB));
192
+
193
+ expect(retireInheritedCorpus(dir)).toBe(join(dir, "metrics.retired-2.sqlite"));
194
+ expect(readFileSync(join(dir, RETIRED_METRICS_DB), "utf8")).toBe("the first archive");
195
+ });
196
+
197
+ test("a crash between the sidecar and the database rename rejoins the pair on the next boot", () => {
198
+ const live = join(dir, METRICS_DB);
199
+ writeInheritedCorpus(live);
200
+ // As if the previous boot moved the sidecar and died before moving the database.
201
+ writeFileSync(join(dir, `${RETIRED_METRICS_DB}-wal`), "moved already");
202
+
203
+ expect(retireInheritedCorpus(dir)).toBe(join(dir, RETIRED_METRICS_DB));
204
+ expect(readFileSync(join(dir, `${RETIRED_METRICS_DB}-wal`), "utf8")).toBe("moved already");
205
+ });
206
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * `cursedbelt-server/request-log` — the request log, "is this app healthy": one row per request
3
+ * served, in `<APP_DATA_DIR>/metrics.sqlite`, the file station's Metrics page reads for every app on
4
+ * this Mac (`apps/station/src/server/fleetMetrics.ts`, `METRICS_DB`).
5
+ *
6
+ * ── Why it is here (4.21.0) ─────────────────────────────────────────────────
7
+ * `apps/family` and `apps/roms` held this module byte-identically (task 265's rollout), and
8
+ * `check-copies` counted it as five copy groups. The API, the header marker and the migration name
9
+ * are exactly the apps' — a live corpus is recognised by `METRICS_APPLICATION_ID` and by the
10
+ * `0001_request_metrics` row in its migrations table, so changing either would make every
11
+ * existing `metrics.sqlite` look inherited and archive it. Bun-only (`bun:sqlite`, `node:fs`): under
12
+ * Node the export map routes this subpath to `_bunOnly.js`, the way `./engagement` does; it is NOT
13
+ * part of `./telemetry`, which is Worker-safe (`telemetryIsWorkerSafe.spec.ts`).
14
+ *
15
+ * ── Why it came back, 2026-09-23 (task 265) ─────────────────────────────────
16
+ * `serve.ts` once recorded that request metrics were GONE, on the grounds that the
17
+ * console that read them was in the retired generation. Station reads them now, and
18
+ * a measurement found that all eight apps recorded nothing while their
19
+ * `metrics.sqlite` files — the RETIRED instances' history, inherited at graduation —
20
+ * rendered as a corpus that had simply gone quiet. This mounts `cursedbelt-server`'s
21
+ * own seam (`requestLogger` through `createTelemetrySink`, the modules the Worker-safe
22
+ * `cursedbelt-server/telemetry` leaf re-exports).
23
+ *
24
+ * ── 🔴 The inherited file is ARCHIVED, never written ────────────────────────
25
+ * The file already at `metrics.sqlite` on the live machine is a previous
26
+ * generation's history under a different schema (`id INTEGER AUTOINCREMENT` plus
27
+ * `ip`/`country`/`city`/`device`/`form`; this writer inserts `id TEXT` plus
28
+ * `user_id`). Appending to it would make every future measurement unreadable, so
29
+ * {@link retireInheritedCorpus} runs at every boot, BEFORE the database is opened:
30
+ *
31
+ * - a file that carries {@link METRICS_APPLICATION_ID} in its header is this
32
+ * writer's own, and is left alone — so a second boot is a no-op;
33
+ * - anything else is RENAMED, with its `-wal`/`-shm` sidecars, to
34
+ * `metrics.retired.sqlite` — or `metrics.retired-2.sqlite`, `-3`, … when that
35
+ * name is taken, so two inherited corpora are both kept and nothing is ever
36
+ * overwritten. A rename touches no byte of the file, which is the point.
37
+ *
38
+ * The marker lives in the SQLite header (`PRAGMA application_id`, offset 68) so it
39
+ * can be read without opening the database — opening an inherited WAL database
40
+ * read-write would checkpoint into it on close, which IS writing into it. The
41
+ * migration checkpoints after stamping it, so the marker is on disk before the
42
+ * first request is recorded and a crash cannot leave a fresh corpus that the next
43
+ * boot mistakes for an inherited one.
44
+ *
45
+ * ── 🔴 No user id, ever ─────────────────────────────────────────────────────
46
+ * `cursedbelt-server/engagement`'s header states the split: `metrics/` promises it
47
+ * holds NO user id, in every app; `engagement.sqlite` is the user-keyed store. The
48
+ * writer's schema has a `user_id` column and `requestLogger` fills it from
49
+ * `c.get("userId")`, so the sink here nulls it on every event — whatever any future
50
+ * middleware puts on the context — and the logger is given no database of its own,
51
+ * which means it writes no `event_logs` rows either (those carry a `user_id` too,
52
+ * and nothing in this generation reads them).
53
+ *
54
+ * ── Never takes the app down ────────────────────────────────────────────────
55
+ * {@link openRequestMetricsFromEnv} catches everything and says so on stderr: an
56
+ * app that cannot record its traffic must still SERVE it. What catches an app that
57
+ * silently STOPS recording is `requestMetrics.test.ts`, which drives `/healthz`
58
+ * through a real server and fails when no row lands — `requestMetrics.spec.ts` here, and each
59
+ * app's own test through its own `createServer`.
60
+ */
61
+ import { Database } from "bun:sqlite";
62
+ import { closeSync, existsSync, mkdirSync, openSync, readSync, renameSync, statSync } from "node:fs";
63
+ import { join } from "node:path";
64
+ import { applyCcPragmas } from "cwip/sqlite";
65
+ import type { MiddlewareHandler } from "hono";
66
+ import { createTelemetrySink, type TelemetrySink } from "../metrics/telemetrySink.js";
67
+ import { requestLogger } from "../middleware/requestLogger.js";
68
+ import { runMigrations } from "../migration/runner.js";
69
+ import { isTestRuntime } from "../satellite/door.js";
70
+
71
+ /** The file station reads, inside the app's data directory. */
72
+ export const METRICS_DB = "metrics.sqlite";
73
+ /** Where an inherited corpus is moved to — the first free of this and `metrics.retired-<n>.sqlite`. */
74
+ export const RETIRED_METRICS_DB = "metrics.retired.sqlite";
75
+ /** `PRAGMA application_id` of a corpus THIS writer created: "CFRM", cursedforge request metrics. */
76
+ const METRICS_APPLICATION_ID = 0x4346524d;
77
+
78
+ const SIDECARS = ["-wal", "-shm", "-journal"] as const;
79
+
80
+ /** `{ name, up(db) }` — the runner's own element type; the package exports the runner, not the type. */
81
+ type Migration = Parameters<typeof runMigrations>[1][number];
82
+
83
+ /**
84
+ * The writer's own schema — the columns `cursedbelt-server`'s `metricsBuffer`
85
+ * inserts, and the ones station's reader asks for (`ts`, `method`, `route`,
86
+ * `status`, `duration_ms`, `bytes_out`). Append-only: a migration, once applied,
87
+ * never changes.
88
+ */
89
+ const REQUEST_METRICS_MIGRATIONS: readonly Migration[] = [
90
+ {
91
+ name: "0001_request_metrics",
92
+ up(db) {
93
+ db.run(`PRAGMA application_id = ${METRICS_APPLICATION_ID}`);
94
+ db.run(`CREATE TABLE request_metrics (
95
+ id TEXT PRIMARY KEY, ts INTEGER NOT NULL, method TEXT, route TEXT,
96
+ status INTEGER, duration_ms REAL, bytes_out INTEGER, user_id TEXT)`);
97
+ db.run("CREATE INDEX idx_request_metrics_ts ON request_metrics (ts)");
98
+ },
99
+ },
100
+ ];
101
+
102
+ /** The `application_id` stamped in a SQLite file's header, or null when it has no header yet. */
103
+ function headerApplicationId(path: string): number | null {
104
+ const fd = openSync(path, "r");
105
+ try {
106
+ const header = Buffer.alloc(72);
107
+ if (readSync(fd, header, 0, 72, 0) < 72) return null;
108
+ return header.readUInt32BE(68);
109
+ } finally {
110
+ closeSync(fd);
111
+ }
112
+ }
113
+
114
+ const sizeOf = (path: string): number => (existsSync(path) ? statSync(path).size : 0);
115
+
116
+ /**
117
+ * Move an inherited `metrics.sqlite` out of the way, or do nothing. Returns the
118
+ * path it was archived to, or null when there was nothing to archive. See the
119
+ * header: never writes a byte of the file, never overwrites anything, idempotent.
120
+ */
121
+ export function retireInheritedCorpus(dir: string): string | null {
122
+ const live = join(dir, METRICS_DB);
123
+ if (!existsSync(live)) return null;
124
+ // An empty file with no journal holds nothing — SQLite simply initialises it.
125
+ if (sizeOf(live) === 0 && SIDECARS.every((s) => sizeOf(live + s) === 0)) return null;
126
+ if (headerApplicationId(live) === METRICS_APPLICATION_ID) return null;
127
+
128
+ const present = SIDECARS.filter((s) => existsSync(live + s));
129
+ let target = join(dir, RETIRED_METRICS_DB);
130
+ for (let n = 2; existsSync(target) || present.some((s) => existsSync(target + s)); n++) {
131
+ target = join(dir, RETIRED_METRICS_DB.replace(/\.sqlite$/, `-${n}.sqlite`));
132
+ }
133
+ // Sidecars FIRST, then the database: a crash between the two leaves the database
134
+ // where the next boot finds it again, and — because a moved sidecar is no longer
135
+ // at the source — the loop above picks this same `target`, so the pair is rejoined.
136
+ for (const s of present) renameSync(live + s, target + s);
137
+ renameSync(live, target);
138
+ return target;
139
+ }
140
+
141
+ /** Retire any inherited corpus, then open (creating if need be) this writer's own. */
142
+ function openMetricsDb(dir: string): { db: Database; archived: string | null } {
143
+ mkdirSync(dir, { recursive: true });
144
+ const archived = retireInheritedCorpus(dir);
145
+ const db = new Database(join(dir, METRICS_DB), { create: true });
146
+ applyCcPragmas(db);
147
+ if (runMigrations(db, REQUEST_METRICS_MIGRATIONS).length > 0) {
148
+ // Put the header marker in the main file now, not at some later checkpoint.
149
+ db.run("PRAGMA wal_checkpoint(TRUNCATE)");
150
+ }
151
+ return { db, archived };
152
+ }
153
+
154
+ export interface RequestMetrics {
155
+ db: Database;
156
+ /** Pending rows are flushed on a 1.5 s timer; `flush()` forces it (tests, shutdown). */
157
+ sink: TelemetrySink;
158
+ /** Register FIRST — ahead of every route it must see. */
159
+ middleware: MiddlewareHandler;
160
+ /** Where an inherited corpus went at this boot, if one was found. */
161
+ archived: string | null;
162
+ }
163
+
164
+ /** Open the corpus in `dir` and build the logger that writes into it. */
165
+ export function openRequestMetrics(dir: string): RequestMetrics {
166
+ const { db, archived } = openMetricsDb(dir);
167
+ const inner = createTelemetrySink({ db });
168
+ // 🔴 No user id — see the header. Nulled here so no context value can reach the row.
169
+ const sink: TelemetrySink = {
170
+ record: (event) => inner.record({ ...event, userId: null }),
171
+ flush: () => inner.flush(),
172
+ stop: () => inner.stop(),
173
+ get size() {
174
+ return inner.size;
175
+ },
176
+ };
177
+ // `null` database: the logger writes through `sink` only, and no `event_logs`.
178
+ const middleware = requestLogger(null, { sink }) as unknown as MiddlewareHandler;
179
+ return { db, sink, middleware, archived };
180
+ }
181
+
182
+ /**
183
+ * The request log for a DEPLOYED instance, or null. Deployed means `APP_DATA_DIR`
184
+ * is set (the launchd plist sets it) and no test runner is in charge. A dev shell
185
+ * records nothing rather than appending a developer's clicks to the live corpus.
186
+ * Never throws: a failure is one stderr line and an app that serves unmeasured.
187
+ */
188
+ export function openRequestMetricsFromEnv(name: string, env: NodeJS.ProcessEnv): RequestMetrics | null {
189
+ const dir = env.APP_DATA_DIR?.trim();
190
+ if (!dir || isTestRuntime(env)) return null;
191
+ try {
192
+ const metrics = openRequestMetrics(dir);
193
+ if (metrics.archived) {
194
+ console.log(`[${name}] request metrics: archived the inherited corpus to ${metrics.archived}`);
195
+ }
196
+ return metrics;
197
+ } catch (error) {
198
+ console.error(
199
+ `[${name}] request metrics disabled: ${error instanceof Error ? error.message : String(error)}`,
200
+ );
201
+ return null;
202
+ }
203
+ }
@@ -109,6 +109,34 @@ describe('createWsHub — message routing & lifecycle', () => {
109
109
  });
110
110
  });
111
111
 
112
+ describe('🔴 onDisconnect sees the hub WITHOUT the client that left', () => {
113
+ test('a presence roster read inside onDisconnect no longer lists the leaver', () => {
114
+ const rosters: string[][] = [];
115
+ let stillRegistered: boolean | undefined;
116
+ const hub = createWsHub({
117
+ channels: {
118
+ test: {
119
+ name: 'test',
120
+ presence: true,
121
+ onMessage: () => {},
122
+ onDisconnect: (client, h) => {
123
+ stillRegistered = h.getClient(client.id) !== undefined;
124
+ rosters.push(h.getPresence('test').map((row) => row.userId));
125
+ },
126
+ },
127
+ },
128
+ authenticate: async () => testUser,
129
+ });
130
+ connect(hub, data({ clientId: 'stays', user: { ...testUser, id: 'u-stays' } }));
131
+ const leaver = connect(hub, data({ clientId: 'leaves', user: { ...testUser, id: 'u-leaves' } }));
132
+ hub.handlers.close?.(leaver, 1000, 'bye');
133
+ // Before 4.20.1 this was ['u-stays', 'u-leaves'] — the stale roster the recipe broadcast.
134
+ expect(rosters).toEqual([['u-stays']]);
135
+ expect(stillRegistered).toBe(false);
136
+ hub.close();
137
+ });
138
+ });
139
+
112
140
  describe('createWsHub — broadcast / send / room', () => {
113
141
  const hubWith = () =>
114
142
  createWsHub({
@@ -130,8 +130,13 @@ export function createWsHub<T extends Record<string, WsChannel<any>>>(opts: WsHu
130
130
  close(ws) {
131
131
  const client = clientFromWs(ws);
132
132
  if (!client) return;
133
- channelOf(client.channel)?.onDisconnect?.(client, hub);
133
+ // 🔴 Remove FIRST, then tell the channel. The recipe every presence app follows is
134
+ // "broadcast hub.getPresence(channel) from onDisconnect" (cursedbelt's usePresence doc),
135
+ // and until 4.20.1 this called onDisconnect while the leaver was still registered — so
136
+ // the roster it broadcast still listed the person who had just left. apps/roms deferred a
137
+ // microtask to work around it (src/server/presence.ts). hub.spec.ts pins the order.
134
138
  clients.delete(client.id);
139
+ channelOf(client.channel)?.onDisconnect?.(client, hub);
135
140
  },
136
141
  },
137
142
  broadcast(channel, event, data) {
@@ -67,7 +67,8 @@ export interface WsChannel<TMsg = WsMessage> {
67
67
  onConnect?(client: WsClient, hub: WsHub): void;
68
68
  /** Fired once per inbound frame (the parsed `{ event, data }` envelope). */
69
69
  onMessage(client: WsClient, msg: TMsg, hub: WsHub): void;
70
- /** Fired when the socket closes (explicit, stale-culled, or peer-dropped). */
70
+ /** Fired when the socket closes (explicit, stale-culled, or peer-dropped) — AFTER the client is
71
+ * removed, so `hub.getPresence()` here is the roster without it (4.20.1). */
71
72
  onDisconnect?(client: WsClient, hub: WsHub): void;
72
73
  /** Whether this channel contributes to {@link WsHub.getPresence}. Default: false. */
73
74
  presence?: boolean;
@@ -57,5 +57,7 @@ export const MAY_NEED_BUN: Record<string, readonly string[]> = {
57
57
  // The engagement store IS `engagement.sqlite` on the Mac (4.19.0, task 280) — the recorder
58
58
  // runs where the app's own database is. A Worker app records nothing until it has a D1 store.
59
59
  './engagement': ['bun:sqlite'],
60
+ // The request log IS `metrics.sqlite` beside the app's data (4.21.0) — a Mac-hosted server's.
61
+ './request-log': ['bun:sqlite'],
60
62
  };
61
63