cursedbelt-server 4.19.2 → 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 +1 -0
- package/dist/server/requestLog/requestMetrics.d.ts +91 -0
- package/dist/server/requestLog/requestMetrics.js +185 -0
- package/dist/server/ws/hub.js +6 -1
- package/dist/server/ws/types.d.ts +2 -1
- package/dist/subpathReach.js +2 -0
- package/package.json +38 -5
- package/src/leafSubpathsImportNothing.spec.ts +10 -0
- package/src/server/requestLog/requestMetrics.spec.ts +206 -0
- package/src/server/requestLog/requestMetrics.ts +203 -0
- package/src/server/ws/hub.spec.ts +28 -0
- package/src/server/ws/hub.ts +6 -1
- package/src/server/ws/types.ts +2 -1
- package/src/subpathReach.ts +2 -0
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
|
+
}
|
package/dist/server/ws/hub.js
CHANGED
|
@@ -116,8 +116,13 @@ export function createWsHub(opts) {
|
|
|
116
116
|
const client = clientFromWs(ws);
|
|
117
117
|
if (!client)
|
|
118
118
|
return;
|
|
119
|
-
|
|
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;
|
package/dist/subpathReach.js
CHANGED
|
@@ -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.
|
|
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.",
|
|
@@ -18,13 +18,14 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"clean": "rm -rf dist",
|
|
20
20
|
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
21
|
-
"test": "NODE_ENV=development bun test src",
|
|
21
|
+
"test": "NODE_ENV=development bun test --timeout 60000 --parallel=6 src",
|
|
22
22
|
"build": "bun run clean && tsc -p tsconfig.build.json",
|
|
23
23
|
"paths": "bun run scripts/paths.ts",
|
|
24
24
|
"surface": "public-surface",
|
|
25
|
-
"verify": "bun run
|
|
26
|
-
"prepublishOnly": "bun run
|
|
27
|
-
"test:pg": "bun run scripts/testPg.ts"
|
|
25
|
+
"verify": "bun run gate",
|
|
26
|
+
"prepublishOnly": "bun run gate --force",
|
|
27
|
+
"test:pg": "bun run scripts/testPg.ts",
|
|
28
|
+
"gate": "bun run scripts/paths.ts --only gate.ts"
|
|
28
29
|
},
|
|
29
30
|
"files": [
|
|
30
31
|
"dist",
|
|
@@ -58,6 +59,12 @@
|
|
|
58
59
|
"source": "./src/server/analytics/index.ts",
|
|
59
60
|
"import": "./dist/server/analytics/index.js"
|
|
60
61
|
},
|
|
62
|
+
"./app-data-home": {
|
|
63
|
+
"types": "./dist/server/paths/appDataHome.d.ts",
|
|
64
|
+
"bun": "./src/server/paths/appDataHome.ts",
|
|
65
|
+
"source": "./src/server/paths/appDataHome.ts",
|
|
66
|
+
"import": "./dist/server/paths/appDataHome.js"
|
|
67
|
+
},
|
|
61
68
|
"./bench": {
|
|
62
69
|
"types": "./dist/server/bench/index.d.ts",
|
|
63
70
|
"bun": "./src/server/bench/index.ts",
|
|
@@ -208,6 +215,12 @@
|
|
|
208
215
|
"source": "./src/server/auth/passwordCost.ts",
|
|
209
216
|
"import": "./dist/server/_bunOnly.js"
|
|
210
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
|
+
},
|
|
211
224
|
"./notifications": {
|
|
212
225
|
"types": "./dist/server/notifications/index.d.ts",
|
|
213
226
|
"bun": "./src/server/notifications/index.ts",
|
|
@@ -339,5 +352,25 @@
|
|
|
339
352
|
"sharp": "^0.35.2",
|
|
340
353
|
"typescript": "^6.0.3",
|
|
341
354
|
"zod": "4.4.3"
|
|
355
|
+
},
|
|
356
|
+
"gate": {
|
|
357
|
+
"stages": [
|
|
358
|
+
{
|
|
359
|
+
"script": "paths"
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
"script": "typecheck"
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
"script": "build"
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
"script": "test",
|
|
369
|
+
"needs": [
|
|
370
|
+
"build"
|
|
371
|
+
],
|
|
372
|
+
"why": "build starts with `rm -rf dist`, and barrelsReachNoOptionalPeer / the publish-shape specs read dist/; racing it they read a directory that is not there yet"
|
|
373
|
+
}
|
|
374
|
+
]
|
|
342
375
|
}
|
|
343
376
|
}
|
|
@@ -104,6 +104,16 @@ const REPO = fileURLToPath(new URL('..', import.meta.url));
|
|
|
104
104
|
* follows it to the new file rather than going quietly green against the old one.
|
|
105
105
|
*/
|
|
106
106
|
const LEAVES = [
|
|
107
|
+
{
|
|
108
|
+
subpath: './app-data-home',
|
|
109
|
+
/**
|
|
110
|
+
* Where a PUBLISHED package keeps per-user data: `APP_DATA_HOME`, then XDG, then the OS
|
|
111
|
+
* convention (task 2117). `cursedbelt-cc`'s local host needs this one function, and until
|
|
112
|
+
* this leaf it could reach it only through the root barrel — which made every cc install
|
|
113
|
+
* an `otplib`/`kysely` install. `node:os` and `node:path` only.
|
|
114
|
+
*/
|
|
115
|
+
evaluates: 'resolveAppDataHome',
|
|
116
|
+
},
|
|
107
117
|
{
|
|
108
118
|
subpath: './storage/types',
|
|
109
119
|
/** Claim/config shapes + the keyspace helpers. apps/collections wants `FileTokenClaims`. */
|
|
@@ -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({
|
package/src/server/ws/hub.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/server/ws/types.ts
CHANGED
|
@@ -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;
|
package/src/subpathReach.ts
CHANGED
|
@@ -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
|
|