@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.4",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -107,7 +107,7 @@
107
107
  "@electric-sql/pglite": "^0.4.5",
108
108
  "@exodus/stasis": "^1.0.0-alpha.1",
109
109
  "@noble/ciphers": "^2.2.0",
110
- "@preventive/terminal": "^1.2.2",
110
+ "@preventive/terminal": "^1.3.0",
111
111
  "@rray/frontend": "^1.0.0",
112
112
  "@stylistic/stylelint-plugin": "^5.1.0",
113
113
  "@types/node": "^25.6.2",
@@ -69,13 +69,13 @@ export function createBusReceiver(deps: BusReceiverDeps): (msg: BusMessage) => P
69
69
  if (debug) console.warn(`pubsub: objput ${msg.res.slice(0, 8)}… missing for ${msg.tag.slice(0, 12)}…`)
70
70
  return
71
71
  }
72
- // Note: `getLive` returns the CURRENT live row, which may be a
73
- // strictly NEWER version than the NOTIFY referred to (two
74
- // closely-spaced puts on one resourceTag arrive at the receiver
75
- // with the DB already showing v2 for both). Broadcasting v2 twice
76
- // is sound — client `putHandlers` already absorb same-instance
77
- // PUT echoes (rest.ts uses `except: null`), so handlers must be
78
- // idempotent on (resourceTag, version) anyway.
72
+ // `getLive` returns the CURRENT live row, which may be a strictly
73
+ // NEWER version than the NOTIFY referred to (two closely-spaced
74
+ // puts on one resourceTag arrive with the DB already showing v2
75
+ // for both). Broadcasting v2 twice is sound — client `putHandlers`
76
+ // already absorb same-instance PUT echoes (rest.ts uses
77
+ // `except: null`), so handlers must be idempotent on
78
+ // (resourceTag, version) anyway.
79
79
  broadcastLocalRaw(msg.tag, JSON.stringify({
80
80
  type: 'objstore-put',
81
81
  workspaceTag: msg.tag,
package/server/config.ts CHANGED
@@ -156,8 +156,8 @@ export function loadConfig(): Config {
156
156
  // 0 = OS-assigned ephemeral port (the test harness boots with PORT=0).
157
157
  const port = intEnv('PORT', 8765, 0, 65535)
158
158
  const host = env['HOST'] ?? '127.0.0.1'
159
- // `fileURLToPath` decodes percent-escapes / non-ASCII path segments
160
- // correctly (the older `new URL(...).pathname` left `%20` raw).
159
+ // `fileURLToPath` decodes percent-escapes / non-ASCII path segments;
160
+ // `new URL(...).pathname` would leave `%20` raw.
161
161
  const dbPath = env['DB_PATH'] ?? fileURLToPath(new URL('./data/data.db', import.meta.url))
162
162
  // `path.join` so a Windows DB_PATH doesn't get a mixed-separator child.
163
163
  const objstoreDir = env['OBJSTORE_DIR'] ?? join(dirname(dbPath), 'objstore')
@@ -174,8 +174,8 @@ export function loadConfig(): Config {
174
174
  const password = rawPassword ?? null
175
175
  // Upper bound 65_536 — bounds memory under hostile load; a deployer
176
176
  // passing MAX_SAFE_INTEGER would silently defeat the cap. Validated
177
- // here (after the config.json / password parse) to preserve the
178
- // pre-split error-precedence order.
177
+ // here, after the config.json / password parse, to keep the
178
+ // error-precedence order.
179
179
  const maxInflightPerSocket = intEnv('MAX_INFLIGHT_PER_SOCKET', 64, 1, 65_536)
180
180
 
181
181
  if (argv.includes('--help') || argv.includes('-h')) {
@@ -1,9 +1,7 @@
1
1
  // Shared SQL + row-mapping for the `workspace_revision` chain, used by
2
2
  // BOTH backends — `./db.ts` (SQLite) and `./db-neon.ts` (Neon/Postgres).
3
- // The two backends previously carried byte-for-byte-equal query strings
4
- // (modulo `?`↔`$N` placeholders) and a copy of the same row mapper; that
5
- // duplication is collapsed here so a query edit can't silently drift
6
- // between backends.
3
+ // Single source of truth (modulo `?`↔`$N` placeholders) so a query edit
4
+ // can't silently drift between backends.
7
5
  //
8
6
  // Single source of truth, in `$N` (Postgres) form:
9
7
  // • the read queries (`headFor`, `seqOfId`, `lastKeyframeSeq`, the
@@ -60,11 +58,10 @@ export function numOrNull(v: unknown): number | null {
60
58
  // `Record<string, unknown>` rows whose `keyframe` may be a number OR (on
61
59
  // a future driver change) a string; `node:sqlite` hands back native
62
60
  // numbers. The `num`/`numOrNull` coercion is safe over both — a native
63
- // `0`/`1` integer passes through unchanged, so SQLite rows round-trip
64
- // identically to the bespoke pass-through they had before, while Neon
65
- // rows keep their defensive string→number coercion. `base` is the only
66
- // nullable column (first revision); `keyframe` collapses to a strict
67
- // 0 / 1 via the `=== 1` check the chain-broadcast contract relies on.
61
+ // `0`/`1` integer passes through unchanged, while Neon rows keep their
62
+ // defensive string→number coercion. `base` is the only nullable column
63
+ // (first revision); `keyframe` collapses to a strict 0 / 1 via the
64
+ // `=== 1` check the chain-broadcast contract relies on.
68
65
  export function mapRevisionRow(r: Record<string, unknown>): RevisionRow {
69
66
  return {
70
67
  base: (r['base'] as string | null) ?? null,
package/server/db.ts CHANGED
@@ -10,49 +10,30 @@
10
10
  // `base` points at the previous revision's `id` (or null for the
11
11
  // first revision in a workspace).
12
12
  //
13
- // `keyframe` is `1` for a revision the client emits with the full
14
- // state baked in (rather than just a delta). The wire-level flag
15
- // is also covered by the signature, so the column value MUST match
16
- // what the signed canonical bytes claim — `canonicalSave` in
17
- // `server/sign.ts` (called from `handleSave` in `server/index.ts`)
18
- // encodes `keyframe ? '1' : ''` into the bytes that `verifyEd25519`
19
- // then checks against the wire-supplied signature, so a wire flag
20
- // that doesn't match what the signer hashed fails verify and never
21
- // reaches this column. Client-driven: the server only stores what
22
- // the client sent and treats keyframes as catch-up roots when a
23
- // from=null subscriber arrives.
13
+ // `keyframe` is `1` for a revision the client emits with full state
14
+ // baked in (rather than a delta). The wire flag is covered by the
15
+ // signature, so the column value MUST match the signed canonical
16
+ // bytes: `canonicalSave` (server/sign.ts, via `handleSave`) encodes
17
+ // `keyframe ? '1' : ''` into the bytes `verifyEd25519` checks, so a
18
+ // mismatched wire flag fails verify and never reaches this column.
19
+ // Client-driven: the server stores what the client sent and treats
20
+ // keyframes as catch-up roots when a from=null subscriber arrives.
24
21
  //
25
22
  // `node:sqlite` is the built-in driver (Node ≥ 22 experimental,
26
- // stable in 24+). The driver is synchronous under the hood; the
27
- // Handle wraps each prepared statement so call sites `await`
28
- // uniformly. This is async-ready surface for a future async DB
29
- // backend — every operation today resolves in the current microtask
30
- // off a sync `node:sqlite` call.
23
+ // stable in 24+), synchronous under the hood; the Handle wraps each
24
+ // prepared statement so call sites `await` uniformly — async-ready
25
+ // surface for a future async DB backend (every op resolves in the
26
+ // current microtask off a sync call).
31
27
  //
32
- // Because operations are now async, two handlers can interleave
33
- // across an `await`. `commitRevision` (below) does NOT take an
34
- // in-process lock — it folds the dup recheck, base-equality check,
35
- // MAX(seq) and INSERT into ONE gated INSERT statement
36
- // (`commitRevisionSqlite` below):
37
- // INSERT … SELECT COALESCE(MAX(seq),0)+1 … WHERE NOT EXISTS(dup)
38
- // AND head IS base RETURNING seq
39
- // `node:sqlite` is synchronous, so that single statement runs to
40
- // completion without yielding the event loop — no concurrent commit
41
- // can interleave mid-statement, and the head-check + MAX(seq) read
42
- // from ONE consistent snapshot. That single-snapshot property is
43
- // what makes a per-tag lock redundant: the lock formerly existed
44
- // only to stop a chain fork where a racer read `head` from one
45
- // snapshot but `MAX(seq)` from a LATER one (after a sibling
46
- // committed) and inserted (seq=N+2, base=X) alongside the winner's
47
- // (seq=N+1, base=X) — same base, different seq, no PK conflict. With
48
- // both reads inside one statement that interleaving is impossible:
49
- // a racer's snapshot is either before the winner's commit (→ same
50
- // seq=N+1 → the UNIQUE(workspace_tag, seq) PK rejects the second →
51
- // recovery → stale-base) or after it (→ head ≠ base → no insert →
52
- // stale-base). Exactly one commits; the loser gets stale-base.
53
- // SQLite also serialises writers internally, and the PK backstops
54
- // the unsupported multi-connection case. See `commitRevisionSqlite`
55
- // for the full fork-safety argument.
28
+ // Operations being async, two handlers can interleave across an
29
+ // `await`. `commitRevision` (below) takes NO in-process lock — it
30
+ // folds the dup recheck, base-equality check, MAX(seq) and INSERT
31
+ // into ONE gated INSERT (`commitRevisionSqlite`). `node:sqlite` runs
32
+ // that statement to completion without yielding, so its head-check +
33
+ // MAX(seq) read ONE snapshot, which is what makes a per-tag lock
34
+ // redundant. SQLite also serialises writers internally, and the PK
35
+ // backstops the unsupported multi-connection case. See
36
+ // `commitRevisionSqlite` for the full fork-safety argument.
56
37
 
57
38
  import { DatabaseSync } from 'node:sqlite'
58
39
  import { mkdirSync } from 'node:fs'
@@ -64,24 +45,20 @@ import {
64
45
  mapRevisionRow, toSqlitePlaceholders,
65
46
  } from './db-revision-sql.ts'
66
47
 
67
- // `CHECK (keyframe IN (0, 1))` is the value-domain guard on the
68
- // keyframe column. STRICT (the table marker) enforces the column's
69
- // TYPE — an INTEGER stays an INTEGER — but NOT its value range:
70
- // `keyframe = 2` is a perfectly valid integer that STRICT accepts,
71
- // which `mapRevisionRow`'s `=== 1` check then silently coerces back to
72
- // 0. That divergence between the stored row and the signed canonical
73
- // (which only ever encodes 0 / 1) poisons chain-replay verifies for
74
- // any peer who recomputes — the same operator-with-direct-DB-write
75
- // attack vector the STRICT guard in `openDbInner` catches for the
76
- // column TYPE. The CHECK closes the value-domain half, giving SQLite
77
- // the protection the Neon schema's identical `CHECK (keyframe IN
78
- // (0, 1))` carries (see `db-neon.ts`).
48
+ // `CHECK (keyframe IN (0, 1))` is the value-domain guard. STRICT
49
+ // (the table marker) enforces the column TYPE (an INTEGER stays an
50
+ // INTEGER) but NOT its value range: `keyframe = 2` is a valid integer
51
+ // STRICT accepts, which `mapRevisionRow`'s `=== 1` check then coerces
52
+ // back to 0. That divergence from the signed canonical (only ever
53
+ // 0 / 1) poisons chain-replay verifies for any recomputing peer — the
54
+ // same operator-with-direct-DB-write vector the STRICT guard in
55
+ // `openDbInner` catches for TYPE. The CHECK closes the value-domain
56
+ // half, matching the Neon schema's identical CHECK (see `db-neon.ts`).
79
57
  //
80
- // `WORKSPACE_REVISION_DEF` is the parenthesised column + constraint
81
- // body (plus the STRICT marker), shared by the initial `CREATE TABLE`
82
- // and the `migrateAddKeyframeCheck` rebuild below — so a table the
83
- // rebuild produces is byte-identical in shape to a freshly-created
84
- // one, and a future column edit can't drift the two apart.
58
+ // Parenthesised column + constraint body (plus STRICT marker), shared
59
+ // by the initial `CREATE TABLE` and the `migrateAddKeyframeCheck`
60
+ // rebuild below — so a rebuilt table is byte-identical in shape to a
61
+ // fresh one and a future column edit can't drift the two apart.
85
62
  const WORKSPACE_REVISION_DEF = `(
86
63
  workspace_tag TEXT NOT NULL,
87
64
  seq INTEGER NOT NULL,
@@ -105,10 +82,10 @@ const SCHEMA = `
105
82
  ${WORKSPACE_REVISION_TAG_ID_INDEX};
106
83
  `
107
84
 
108
- // Row shape returned by the chain queries. SQLite stores `keyframe`
109
- // as INTEGER (0 / 1); `chainForWire` in server/index.ts normalises
110
- // to a strict boolean before broadcasting, but the raw row carries
111
- // the integer. `base` is nullable on the very first revision.
85
+ // Row shape from the chain queries. `keyframe` is stored as INTEGER
86
+ // (0 / 1); the raw row carries the integer — `chainForWire` in
87
+ // server/index.ts normalises to a strict boolean before broadcasting.
88
+ // `base` is nullable on the very first revision.
112
89
  export type RevisionRow = {
113
90
  base: string | null
114
91
  id: string
@@ -119,9 +96,8 @@ export type RevisionRow = {
119
96
  }
120
97
 
121
98
  // Input to `commitRevision`. `keyframe` is a strict boolean here —
122
- // the canonical-payload contract uses `=== true`, and the storage
123
- // path coerces to 0 / 1 via `keyframe ? 1 : 0` before hitting the
124
- // STRICT INTEGER column.
99
+ // the canonical-payload contract uses `=== true`; the storage path
100
+ // coerces to 0 / 1 before hitting the STRICT INTEGER column.
125
101
  export type RevisionInsert = {
126
102
  tag: string
127
103
  id: string
@@ -142,37 +118,31 @@ export type CommitResult =
142
118
  | { kind: 'duplicate' }
143
119
  | { kind: 'stale-base'; head: string | null }
144
120
 
145
- // Bag of pre-prepared statements + the underlying connection.
146
- // Held for the process lifetime; `close()` runs from `shutdown()`.
121
+ // Pre-prepared statements + the underlying connection, held for the
122
+ // process lifetime; `close()` runs from `shutdown()`.
147
123
  //
148
- // `db` is the raw `DatabaseSync` and is SQLite-only. The Neon
149
- // backend (`./db-neon.ts`) constructs a Handle with `db` unset.
150
- // Callers that reach into `db` directly (e.g. `openObjstore`,
151
- // test-only fixture SQL) are SQLite-coupled by construction —
152
- // passing them a Neon-backed Handle is the operator's mistake to
153
- // catch at the `if (DATABASE_URL)` switch in `server/index.ts`.
124
+ // `db` is the raw `DatabaseSync`, SQLite-only — the Neon backend
125
+ // (`./db-neon.ts`) constructs a Handle with `db` unset. Callers that
126
+ // reach into `db` directly (e.g. `openObjstore`, test-only fixture
127
+ // SQL) are SQLite-coupled by construction; passing them a Neon-backed
128
+ // Handle is the operator's mistake to catch at the `if (DATABASE_URL)`
129
+ // switch in `server/index.ts`.
154
130
  //
155
- // `tryCommit` is the backend-specific atomic-commit primitive that
131
+ // `tryCommit` is the backend-specific atomic-commit primitive
156
132
  // `commitRevision` dispatches through. SQLite runs one synchronous
157
- // gated INSERT (no in-process lock — `node:sqlite` doesn't yield
158
- // mid-statement, so the head-check + MAX(seq) read one snapshot;
159
- // see `commitRevisionSqlite`). Neon wraps the dup-check + head-check
160
- // + gated INSERT in a pipelined transaction; it relies on Postgres'
161
- // READ-COMMITTED single-statement snapshot (the gated INSERT's
162
- // head-check and MAX(seq) read one snapshot) plus the
163
- // `UNIQUE(workspace_tag, seq)` PK to keep cross-replica racers from
164
- // forking the chain — see `db-neon.ts`'s `tryCommitNeon`.
133
+ // gated INSERT (see `commitRevisionSqlite`); Neon wraps it in a
134
+ // pipelined transaction (see `db-neon.ts`'s `tryCommitNeon`). Both
135
+ // rely on a single-statement snapshot + the `UNIQUE(workspace_tag,
136
+ // seq)` PK for fork-safety; see those functions for the argument.
165
137
  //
166
138
  // `gatedInsert` is SQLite-only (like `db`): it backs
167
- // `commitRevisionSqlite`'s single gated INSERT (the dup-gate +
168
- // head-equals-base-gate + server-assigned seq folded into one
169
- // statement, mirroring the Neon path's gated INSERT). The Neon
170
- // backend leaves it unset — its gated INSERT lives inside the
171
- // pipelined `sql.transaction([...])`, not a standalone statement
172
- // object. Kept on the Handle (rather than a module-private closure)
173
- // so the SQLite white-box tests can wrap `.get` to inject a
174
- // unique-violation / non-unique failure into the commit, the same
175
- // recovery paths the Neon suite stages via `failNextCommit`.
139
+ // `commitRevisionSqlite`'s single gated INSERT. The Neon backend
140
+ // leaves it unset — its gated INSERT lives inside the pipelined
141
+ // `sql.transaction([...])`, not a standalone statement object. Kept
142
+ // on the Handle (not a module-private closure) so SQLite white-box
143
+ // tests can wrap `.get` to inject a unique-violation / non-unique
144
+ // failure into the commit, exercising the same recovery paths the
145
+ // Neon suite stages via `failNextCommit`.
176
146
  export type Handle = {
177
147
  db?: DatabaseSync
178
148
  headFor: GetStmt<[string], { id: string }>
@@ -192,23 +162,22 @@ export type Handle = {
192
162
  }
193
163
 
194
164
  // Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
195
- // to be set. `openDb` returns this so call sites that need direct
165
+ // set. `openDb` returns this so call sites needing direct
196
166
  // `DatabaseSync` access (e.g. `openObjstore(handle.db, …)` in
197
- // `server/index.ts`'s SQLite branch) can reach `handle.db` without
198
- // an optional-chain or non-null assertion. A Neon-backed Handle
199
- // (`openNeonDb`) keeps the wider `db?: DatabaseSync` shape; routing
200
- // a Neon Handle into a SQLite-coupled call site is a compile-time
201
- // error. Mirrors the same pattern in `server/objstore/store.ts`.
167
+ // `server/index.ts`'s SQLite branch) reach `handle.db` without an
168
+ // optional-chain or non-null assertion. A Neon-backed Handle
169
+ // (`openNeonDb`) keeps the wider `db?: DatabaseSync` shape, so routing
170
+ // one into a SQLite-coupled call site is a compile-time error. Mirrors
171
+ // `server/objstore/store.ts`.
202
172
  export type SqliteHandle = Handle & { db: DatabaseSync }
203
173
 
204
174
  export function openDb(path: string): SqliteHandle {
205
175
  mkdirSync(dirname(path), { recursive: true })
206
176
  const db = new DatabaseSync(path)
207
- // Any throw between the DatabaseSync constructor and the return
208
- // would otherwise leak the underlying file / WAL / shm locks until
209
- // process exit — close before re-raising so the operator can fix
210
- // the underlying issue (failed STRICT check, ALTER TABLE error,
211
- // …) and re-run without a stale lock pinning the file.
177
+ // A throw between the DatabaseSync constructor and the return would
178
+ // leak the file / WAL / shm locks until process exit — close before
179
+ // re-raising so the operator can fix the cause (failed STRICT check,
180
+ // ALTER TABLE error, …) and re-run without a stale lock on the file.
212
181
  try {
213
182
  return openDbInner(db)
214
183
  } catch (err) {
@@ -218,35 +187,30 @@ export function openDb(path: string): SqliteHandle {
218
187
  }
219
188
 
220
189
  function openDbInner(db: DatabaseSync): SqliteHandle {
221
- // WAL gives concurrent readers + faster writes and survives
222
- // crashes between commits without corrupting the file. Foreign
223
- // keys aren't strictly needed here (single-table schema) but
224
- // turning them on preserves the option to add referential
225
- // tables later without revisiting init.
190
+ // WAL gives concurrent readers + faster writes and survives crashes
191
+ // between commits without corrupting the file. Foreign keys aren't
192
+ // needed here (single-table schema) but turning them on keeps the
193
+ // option to add referential tables later without revisiting init.
226
194
  db.exec('PRAGMA journal_mode = WAL;')
227
195
  // FULL (not NORMAL): the server emits `workspace-save-ack` BEFORE
228
- // returning to the event loop after `commitRevision`. With NORMAL,
229
- // SQLite only fsyncs at WAL checkpoint, so a power loss between
230
- // ack and the next checkpoint loses the row even though the
231
- // originator and broadcast peers were told the revision committed.
232
- // FULL fsyncs per commit; durability matches the contract the
233
- // ack implies. Trade-off is per-commit fsync latency, acceptable
234
- // for the protocol's edit-driven write pattern (triage edits, not
235
- // streaming throughput). Audit round-9 M1.
196
+ // returning to the event loop after `commitRevision`. NORMAL only
197
+ // fsyncs at WAL checkpoint, so a power loss between ack and the next
198
+ // checkpoint loses a row the originator + peers were told committed.
199
+ // FULL fsyncs per commit, matching the durability the ack implies.
200
+ // Trade-off is per-commit fsync latency, acceptable for the edit-
201
+ // driven write pattern (triage edits, not streaming). Audit round-9 M1.
236
202
  db.exec('PRAGMA synchronous = FULL;')
237
203
  db.exec('PRAGMA foreign_keys = ON;')
238
204
  db.exec(SCHEMA)
239
205
  // Fail-loud on a pre-existing non-STRICT table — `CREATE TABLE IF
240
- // NOT EXISTS … STRICT` is a no-op when the table already exists,
241
- // so a deployment that predates the STRICT marker would silently
242
- // keep its non-STRICT shape. Without STRICT, an operator with
243
- // direct DB write access could insert mis-typed rows (e.g. a
244
- // `keyframe = "1\nfoo"` text value in the INTEGER column) and
245
- // poison the chain — the signed canonical the client originally
246
- // hashed says `keyframe = 1`, but the stored `keyframe = "1\nfoo"`
247
- // round-trips back into the canonical as a different string,
248
- // making every subsequent verify fail. Operator must migrate
249
- // before this server boots.
206
+ // NOT EXISTS … STRICT` is a no-op when the table exists, so a
207
+ // deployment predating the STRICT marker keeps its non-STRICT shape.
208
+ // Without STRICT, an operator with direct DB write access could
209
+ // insert mis-typed rows (e.g. `keyframe = "1\nfoo"` text in the
210
+ // INTEGER column) and poison the chain: the signed canonical says
211
+ // `keyframe = 1`, but the stored text round-trips into the canonical
212
+ // as a different string, failing every subsequent verify. Operator
213
+ // must migrate before this server boots.
250
214
  const meta = db.prepare(
251
215
  `SELECT strict FROM pragma_table_list WHERE schema = 'main' AND name = 'workspace_revision'`,
252
216
  ).get() as { strict: number } | undefined
@@ -254,13 +218,11 @@ function openDbInner(db: DatabaseSync): SqliteHandle {
254
218
  throw new Error('workspace_revision is non-STRICT — migrate via rename+create+copy before booting')
255
219
  }
256
220
  // Idempotent migration for DBs created before the keyframe column
257
- // existed. Inspect the column list rather than catching every
258
- // ALTER error — the previous shape swallowed `try { ALTER } catch
259
- // {}` for ANY failure (lock contention, disk full, corrupt page),
260
- // masking real problems as "column already exists". Now we only
261
- // ALTER when the column is genuinely missing, and any failure of
262
- // the ALTER itself bubbles up as an open-time crash where the
263
- // operator can act on it.
221
+ // existed. Inspect the column list rather than `try { ALTER } catch
222
+ // {}`: a blanket catch swallows ANY failure (lock contention, disk
223
+ // full, corrupt page) as "column already exists". ALTER only when
224
+ // the column is genuinely missing, so an ALTER failure bubbles up as
225
+ // an open-time crash the operator can act on.
264
226
  const columns = db.prepare(`PRAGMA table_info(workspace_revision)`).all() as Array<{ name: string }>
265
227
  if (!columns.some((c) => c.name === 'keyframe')) {
266
228
  // ADD COLUMN carries the CHECK so a legacy DB migrating up lands
@@ -507,9 +469,8 @@ export function commitRevision(handle: Handle, input: RevisionInsert): Promise<C
507
469
  // base gate fails → `stale-base`.
508
470
  // • Two retransmits with the same id: the second's dup gate fails →
509
471
  // `duplicate`.
510
- // These are PROVEN green, unchanged, by the no-fork concurrency tests
511
- // in `tests/server-db.test.js` (two/N concurrent same-base, mixed,
512
- // chainFrom-during-commits) which now pass with no lock present.
472
+ // Covered by the no-fork concurrency tests in `tests/server-db.test.js`
473
+ // (two/N concurrent same-base, mixed, chainFrom-during-commits).
513
474
  //
514
475
  // SQLite serialises writers internally even ACROSS connections, but a
515
476
  // multi-connection deployment is unsupported regardless. The
package/server/hub.ts CHANGED
@@ -102,12 +102,11 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
102
102
  }
103
103
 
104
104
  function fanOut(set: Set<WebSocket>, payload: string, except: WebSocket | null): void {
105
- // Snapshot before iterating — `send`'s try/catch swallows
106
- // socket.send errors, but a socket transitioning to CLOSED
105
+ // Snapshot before iterating — a socket transitioning to CLOSED
107
106
  // mid-broadcast triggers `unsubscribeAll` from the 'close' handler,
108
- // which mutates `set` while we're walking it. The snapshot keeps a
109
- // future refactor (different collection, async send) from silently
110
- // skipping subscribers. Audit M4 round-3.
107
+ // mutating `set` while we walk it. The snapshot also keeps a future
108
+ // refactor (different collection, async send) from silently skipping
109
+ // subscribers. Audit M4 round-3.
111
110
  for (const s of [...set]) {
112
111
  if (s === except) continue
113
112
  sendRaw(s, payload)
package/server/index.ts CHANGED
@@ -97,8 +97,8 @@ import {
97
97
  import { createBusReceiver } from './bus-receiver.ts'
98
98
 
99
99
  // All external inputs (env vars + optional config.json) are parsed
100
- // and validated in ./config.ts. Destructure into the existing
101
- // uppercase names so the rest of this module reads unchanged.
100
+ // and validated in ./config.ts; destructure into the uppercase names
101
+ // the rest of this module uses.
102
102
  const config = loadConfig()
103
103
  const {
104
104
  port: PORT, host: HOST, dbPath: DB_PATH, objstoreDir: OBJSTORE_DIR,
@@ -109,8 +109,8 @@ const {
109
109
  } = config
110
110
 
111
111
  // Same-origin gate for the WS upgrade and REST data plane (see
112
- // ./origin.ts). `TRUST_PROXY_ENV` (from config) also feeds the
113
- // boot-time misconfiguration fail-fast below.
112
+ // ./origin.ts). `TRUST_PROXY_ENV` also feeds the boot-time
113
+ // misconfiguration fail-fast below.
114
114
  const { trustProxy: TRUST_PROXY, isOriginAllowed } = createOriginGate(HOST, TRUST_PROXY_ENV)
115
115
 
116
116
  // Per-socket buffered-bytes cap. `socket.send` returns synchronously
@@ -131,10 +131,9 @@ const MAX_BUFFERED_BYTES = 16 * 1024 * 1024
131
131
 
132
132
  // Per-connection state registry. One `Peer` per accepted socket holds
133
133
  // the challenge nonce, auth flag, heartbeat liveness, in-flight count,
134
- // and subscribed tags (see ./peer.ts) — replacing what were five
135
- // parallel per-socket WeakMaps. The connection handler holds the Peer
136
- // in a closure for the hot paths; cross-function call sites resolve it
137
- // via `peers.get(socket)`.
134
+ // and subscribed tags (see ./peer.ts). The connection handler holds
135
+ // the Peer in a closure for the hot paths; cross-function call sites
136
+ // resolve it via `peers.get(socket)`.
138
137
  const peers: PeerRegistry = new WeakMap()
139
138
 
140
139
  // REST PUT idle-body timeout. A slow-loris client trickling bytes
@@ -163,8 +162,8 @@ const REST_PUT_IDLE_TIMEOUT_MS = 30_000
163
162
  const HEARTBEAT_INTERVAL_MS = 30_000
164
163
 
165
164
  // Backend selection. Both planes (workspace_revision DB + the
166
- // v1.objstore byte store) are picked from config at boot. Two supported
167
- // pairings:
165
+ // v1.objstore byte store) are picked from config at boot. Two
166
+ // supported pairings:
168
167
  // 1. DATABASE_URL set → Neon (workspace_revision + objstore
169
168
  // tables) + Vercel Blob Private Storage (bytes). Requires
170
169
  // BLOB_READ_WRITE_TOKEN — fail fast at boot if missing, since
@@ -174,11 +173,10 @@ const HEARTBEAT_INTERVAL_MS = 30_000
174
173
  // process; the only pairing the SQLite plane supports.
175
174
  // The Neon / Vercel files import their peer deps lazily inside the
176
175
  // open functions, so static imports here are safe even on a SQLite-
177
- // only install where the optional peer deps aren't present. Branch
178
- // out explicitly (rather than via a ternary) so the SQLite path
179
- // keeps its `SqliteHandle` narrowing — `sqliteHandle.db` is typed
180
- // as a non-optional `DatabaseSync` and `openObjstore` accepts it
181
- // without a non-null assertion.
176
+ // only install where the optional peer deps aren't present. Explicit
177
+ // branch (not a ternary) so the SQLite path keeps its `SqliteHandle`
178
+ // narrowing — `sqliteHandle.db` is a non-optional `DatabaseSync` that
179
+ // `openObjstore` accepts without a non-null assertion.
182
180
  let handle: Handle
183
181
  let objstoreHandle: ObjstoreHandle
184
182
  let objstoreBanner: string
@@ -238,8 +236,7 @@ async function workspaceExists(tag: string): Promise<boolean> {
238
236
  }
239
237
 
240
238
  // WS fan-out hub: subscriber registry + backpressure-aware send /
241
- // broadcast (see ./hub.ts). Destructure into the existing names so the
242
- // handlers / dispatcher / objstore wiring below read unchanged.
239
+ // broadcast (see ./hub.ts).
243
240
  const hub = createHub({ peers, maxBufferedBytes: MAX_BUFFERED_BYTES, debug: DEBUG })
244
241
  const { send, broadcast, subscribe, unsubscribeAll, broadcastLocalRaw } = hub
245
242
 
@@ -276,7 +273,7 @@ if (NEON_URL) {
276
273
  }
277
274
 
278
275
  // Password gate (see ./auth.ts) — HMAC derivation + the `authenticate`
279
- // handshake. Destructure into the existing names for the wiring below.
276
+ // handshake.
280
277
  const auth = createAuth({ peers, password: CONFIG_PASSWORD, send, debug: DEBUG })
281
278
  const { requiresAuth, handleAuthenticate, sendUnauthorized } = auth
282
279
 
@@ -320,8 +317,8 @@ const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper
320
317
  sendUnauthorized,
321
318
  // `tokenSecret` is set only when OBJSTORE_TOKEN_SECRET was
322
319
  // provided in env (see TOKEN_SECRET resolution above). Omitted
323
- // → initObjstore mints a fresh per-process secret (the pre-PR
324
- // behaviour, fine for single-replica).
320
+ // → initObjstore mints a fresh per-process secret (fine for
321
+ // single-replica).
325
322
  ...(TOKEN_SECRET ? { tokenSecret: TOKEN_SECRET } : {}),
326
323
  })
327
324
 
@@ -458,12 +455,11 @@ installLifecycle({
458
455
  // needed here.
459
456
  await startupReap
460
457
 
461
- // Start serving: bind the HTTP/WS plane on the configured PORT/HOST.
462
- // Exported as `start()` so a launcher (server/cli.js — the triage-server
463
- // bin) or any consumer that `import`ed this module can begin serving once
464
- // the top-level `await startupReap` above has settled (the server is fully
465
- // ready — DB open, DDL bootstrapped, objstore reaper swept — by the time
466
- // the import resolves).
458
+ // Bind the HTTP/WS plane on the configured PORT/HOST. Exported so a
459
+ // launcher (server/cli.js — the triage-server bin) or any `import`er can
460
+ // start serving. The top-level `await startupReap` above means the server
461
+ // is fully ready — DB open, DDL bootstrapped, objstore reaper swept — by
462
+ // the time the import resolves.
467
463
  export function start(): void {
468
464
  httpServer.listen(PORT, HOST)
469
465
  }
@@ -48,13 +48,13 @@ async function fsOpenLiveReader(dir: string, tag: string, contentHash: string):
48
48
  const path = liveFilePath(dir, tag, contentHash)
49
49
  let fh
50
50
  try { fh = await open(path, 'r') } catch (err: unknown) {
51
- if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, reason: 'unavailable' }
51
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, reason: 'unavailable', detail: 'fs-enoent' }
52
52
  throw err
53
53
  }
54
54
  let size: number
55
55
  try { size = (await fh.stat()).size } catch {
56
56
  await fh.close().catch(() => {})
57
- return { ok: false, reason: 'unavailable' }
57
+ return { ok: false, reason: 'unavailable', detail: 'fs-stat-failed' }
58
58
  }
59
59
  const stream = fh.createReadStream()
60
60
  let closed = false
@@ -90,12 +90,10 @@ export function openFsBlobBackend(dir: string): BlobBackend {
90
90
  // closed by Node's stream machinery on 'finish'.
91
91
  finalize: async () => {},
92
92
  // `destroy(err)` synchronously starts tearing the stream
93
- // down; the WriteStream emits 'close' on the next tick.
94
- // For the FS backend there's no remote upload to wait for,
95
- // so we resolve immediately — the REST layer awaits but
96
- // doesn't block on anything real here. eslint-disable for
97
- // the no-await-in-async — the function signature is
98
- // dictated by the BlobBackend contract.
93
+ // down; the WriteStream emits 'close' on the next tick. No
94
+ // remote upload to wait for on FS, so we resolve immediately
95
+ // — the REST layer awaits but doesn't block on anything real.
96
+ // Async signature is dictated by the BlobBackend contract.
99
97
  // eslint-disable-next-line require-await
100
98
  abort: async (err) => { writable.destroy(err as Error) },
101
99
  }