@preventive/triage 1.0.0-alpha.0 → 1.0.0-alpha.10

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.
Files changed (59) hide show
  1. package/api/reap.ts +79 -0
  2. package/common/save-error-reason.ts +20 -7
  3. package/common/server-info.ts +30 -0
  4. package/out/brotli-fallback.js +1 -1
  5. package/out/client-admin.js +28 -0
  6. package/out/client-managed.js +1 -0
  7. package/out/client-sync.js +17 -10
  8. package/out/graph.js +5 -4
  9. package/out/index.html +43 -38
  10. package/out/prism.js +2 -2
  11. package/out/terminal.js +32 -28
  12. package/out/view.css +1 -1
  13. package/out/view.js +78 -51
  14. package/package.json +70 -49
  15. package/{server → server-common}/origin.ts +5 -5
  16. package/{server → server-e2e}/auth.ts +16 -1
  17. package/server-e2e/bus-receiver.ts +95 -0
  18. package/server-e2e/cli.js +22 -0
  19. package/{server → server-e2e}/config.ts +21 -8
  20. package/{server → server-e2e}/db-neon.ts +41 -25
  21. package/{server → server-e2e}/db-revision-sql.ts +15 -9
  22. package/{server → server-e2e}/db-stmt.ts +2 -2
  23. package/{server → server-e2e}/db.ts +113 -135
  24. package/server-e2e/http.ts +266 -0
  25. package/{server → server-e2e}/hub.ts +27 -8
  26. package/{server → server-e2e}/index.ts +185 -52
  27. package/{server → server-e2e}/lifecycle.ts +36 -5
  28. package/server-e2e/npm-proxy.ts +348 -0
  29. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  30. package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
  31. package/{server → server-e2e}/objstore/blob.ts +24 -9
  32. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  33. package/{server → server-e2e}/objstore/handlers.ts +25 -15
  34. package/{server → server-e2e}/objstore/init.ts +52 -12
  35. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  36. package/server-e2e/objstore/rest-deny.ts +28 -0
  37. package/server-e2e/objstore/rest-mint.ts +224 -0
  38. package/{server → server-e2e}/objstore/rest.ts +119 -84
  39. package/{server → server-e2e}/objstore/sign.ts +105 -0
  40. package/{server → server-e2e}/objstore/store-neon.ts +19 -19
  41. package/{server → server-e2e}/objstore/store.ts +98 -118
  42. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  43. package/{server → server-e2e}/peer.ts +7 -9
  44. package/server-e2e/pubsub.ts +394 -0
  45. package/{server → server-e2e}/sign.ts +12 -14
  46. package/server-e2e/sse-server.ts +384 -0
  47. package/server-e2e/sse-session.ts +216 -0
  48. package/{server → server-e2e}/static.ts +22 -17
  49. package/server-e2e/sync-handlers.ts +382 -0
  50. package/{server → server-e2e}/util.ts +9 -0
  51. package/server-e2e/ws-server.ts +276 -0
  52. package/strip-types-loader.js +94 -0
  53. package/server/http.ts +0 -142
  54. package/server/sync-handlers.ts +0 -311
  55. package/server/ws-server.ts +0 -245
  56. /package/{server → server-e2e}/config.example.json +0 -0
  57. /package/{server → server-e2e}/neon-driver.ts +0 -0
  58. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  59. /package/{server → server-e2e}/validation.ts +0 -0
@@ -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,
@@ -108,6 +105,15 @@ export const CHAIN_FROM_SQL =
108
105
  FROM workspace_revision WHERE workspace_tag = $1 AND seq >= $2 ORDER BY seq ASC`
109
106
  export const REVISION_EXISTS_SQL =
110
107
  `SELECT 1 AS one FROM workspace_revision WHERE workspace_tag = $1 AND id = $2`
108
+ // Fetch a single revision row by content-addressed id. The cross-instance
109
+ // pubsub (server-e2e/pubsub.ts) NOTIFY payload carries only `(tag, revisionId)`
110
+ // because the full `workspace-state` broadcast envelope is bounded by
111
+ // `MAX_CIPHERTEXT_LEN` (2 MiB) and Postgres NOTIFY caps payloads at ~8 KB.
112
+ // The receiver re-fetches the row from this shared table to construct the
113
+ // wire broadcast for its local peers.
114
+ export const REVISION_BY_ID_SQL =
115
+ `SELECT base, id, keyframe, nonce, ciphertext, signature
116
+ FROM workspace_revision WHERE workspace_tag = $1 AND id = $2`
111
117
 
112
118
  // The gated commit INSERT, in `$N` form. One statement folds the
113
119
  // dup-check, the head-equals-base check, the server-assigned seq
@@ -1,5 +1,5 @@
1
- // Shared async-statement primitives. Both `server/db.ts` (workspace_revision
2
- // chain) and `server/objstore/store.ts` (objstore tables) expose Handles
1
+ // Shared async-statement primitives. Both `server-e2e/db.ts` (workspace_revision
2
+ // chain) and `server-e2e/objstore/store.ts` (objstore tables) expose Handles
3
3
  // whose statements look like `{ get(...) → Promise<…>, all(...) → Promise<[…]>,
4
4
  // run(...) → Promise<void> }`. The underlying `node:sqlite` driver is
5
5
  // synchronous; the wrappers below catch sync errors and route them through
@@ -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-e2e/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'
@@ -60,27 +41,24 @@ import { dirname } from 'node:path'
60
41
  import { type AllStmt, type GetStmt, wrapAll, wrapGet } from './db-stmt.ts'
61
42
  import {
62
43
  CHAIN_AFTER_SQL, CHAIN_ALL_SQL, CHAIN_FROM_SQL, GATED_INSERT_SQL_SQLITE, HEAD_FOR_SQL,
63
- LAST_KEYFRAME_SEQ_SQL, REVISION_EXISTS_SQL, SEQ_OF_ID_SQL, mapRevisionRow, toSqlitePlaceholders,
44
+ LAST_KEYFRAME_SEQ_SQL, REVISION_BY_ID_SQL, REVISION_EXISTS_SQL, SEQ_OF_ID_SQL,
45
+ mapRevisionRow, toSqlitePlaceholders,
64
46
  } from './db-revision-sql.ts'
65
47
 
66
- // `CHECK (keyframe IN (0, 1))` is the value-domain guard on the
67
- // keyframe column. STRICT (the table marker) enforces the column's
68
- // TYPE — an INTEGER stays an INTEGER — but NOT its value range:
69
- // `keyframe = 2` is a perfectly valid integer that STRICT accepts,
70
- // which `mapRevisionRow`'s `=== 1` check then silently coerces back to
71
- // 0. That divergence between the stored row and the signed canonical
72
- // (which only ever encodes 0 / 1) poisons chain-replay verifies for
73
- // any peer who recomputes — the same operator-with-direct-DB-write
74
- // attack vector the STRICT guard in `openDbInner` catches for the
75
- // column TYPE. The CHECK closes the value-domain half, giving SQLite
76
- // the protection the Neon schema's identical `CHECK (keyframe IN
77
- // (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`).
78
57
  //
79
- // `WORKSPACE_REVISION_DEF` is the parenthesised column + constraint
80
- // body (plus the STRICT marker), shared by the initial `CREATE TABLE`
81
- // and the `migrateAddKeyframeCheck` rebuild below — so a table the
82
- // rebuild produces is byte-identical in shape to a freshly-created
83
- // 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.
84
62
  const WORKSPACE_REVISION_DEF = `(
85
63
  workspace_tag TEXT NOT NULL,
86
64
  seq INTEGER NOT NULL,
@@ -104,10 +82,10 @@ const SCHEMA = `
104
82
  ${WORKSPACE_REVISION_TAG_ID_INDEX};
105
83
  `
106
84
 
107
- // Row shape returned by the chain queries. SQLite stores `keyframe`
108
- // as INTEGER (0 / 1); `chainForWire` in server/index.ts normalises
109
- // to a strict boolean before broadcasting, but the raw row carries
110
- // 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-e2e/index.ts normalises to a strict boolean before broadcasting.
88
+ // `base` is nullable on the very first revision.
111
89
  export type RevisionRow = {
112
90
  base: string | null
113
91
  id: string
@@ -118,9 +96,8 @@ export type RevisionRow = {
118
96
  }
119
97
 
120
98
  // Input to `commitRevision`. `keyframe` is a strict boolean here —
121
- // the canonical-payload contract uses `=== true`, and the storage
122
- // path coerces to 0 / 1 via `keyframe ? 1 : 0` before hitting the
123
- // STRICT INTEGER column.
99
+ // the canonical-payload contract uses `=== true`; the storage path
100
+ // coerces to 0 / 1 before hitting the STRICT INTEGER column.
124
101
  export type RevisionInsert = {
125
102
  tag: string
126
103
  id: string
@@ -141,37 +118,31 @@ export type CommitResult =
141
118
  | { kind: 'duplicate' }
142
119
  | { kind: 'stale-base'; head: string | null }
143
120
 
144
- // Bag of pre-prepared statements + the underlying connection.
145
- // 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()`.
146
123
  //
147
- // `db` is the raw `DatabaseSync` and is SQLite-only. The Neon
148
- // backend (`./db-neon.ts`) constructs a Handle with `db` unset.
149
- // Callers that reach into `db` directly (e.g. `openObjstore`,
150
- // test-only fixture SQL) are SQLite-coupled by construction —
151
- // passing them a Neon-backed Handle is the operator's mistake to
152
- // 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-e2e/index.ts`.
153
130
  //
154
- // `tryCommit` is the backend-specific atomic-commit primitive that
131
+ // `tryCommit` is the backend-specific atomic-commit primitive
155
132
  // `commitRevision` dispatches through. SQLite runs one synchronous
156
- // gated INSERT (no in-process lock — `node:sqlite` doesn't yield
157
- // mid-statement, so the head-check + MAX(seq) read one snapshot;
158
- // see `commitRevisionSqlite`). Neon wraps the dup-check + head-check
159
- // + gated INSERT in a pipelined transaction; it relies on Postgres'
160
- // READ-COMMITTED single-statement snapshot (the gated INSERT's
161
- // head-check and MAX(seq) read one snapshot) plus the
162
- // `UNIQUE(workspace_tag, seq)` PK to keep cross-replica racers from
163
- // 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.
164
137
  //
165
138
  // `gatedInsert` is SQLite-only (like `db`): it backs
166
- // `commitRevisionSqlite`'s single gated INSERT (the dup-gate +
167
- // head-equals-base-gate + server-assigned seq folded into one
168
- // statement, mirroring the Neon path's gated INSERT). The Neon
169
- // backend leaves it unset — its gated INSERT lives inside the
170
- // pipelined `sql.transaction([...])`, not a standalone statement
171
- // object. Kept on the Handle (rather than a module-private closure)
172
- // so the SQLite white-box tests can wrap `.get` to inject a
173
- // unique-violation / non-unique failure into the commit, the same
174
- // 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`.
175
146
  export type Handle = {
176
147
  db?: DatabaseSync
177
148
  headFor: GetStmt<[string], { id: string }>
@@ -181,29 +152,32 @@ export type Handle = {
181
152
  chainAfterSeq: AllStmt<[string, number], RevisionRow>
182
153
  chainFromSeq: AllStmt<[string, number], RevisionRow>
183
154
  revisionExists: GetStmt<[string, string], unknown>
155
+ // Single-revision fetch by content-addressed id. The cross-instance
156
+ // pubsub receiver uses this to assemble a `workspace-state` from a
157
+ // NOTIFY hint (see `server-e2e/pubsub.ts`).
158
+ revisionById: GetStmt<[string, string], RevisionRow>
184
159
  gatedInsert?: GetStmt<[string, string, string | null, number, string, string, string, number], { seq: number }>
185
160
  tryCommit: (input: RevisionInsert) => Promise<CommitResult>
186
161
  close: () => Promise<void>
187
162
  }
188
163
 
189
164
  // Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
190
- // to be set. `openDb` returns this so call sites that need direct
165
+ // set. `openDb` returns this so call sites needing direct
191
166
  // `DatabaseSync` access (e.g. `openObjstore(handle.db, …)` in
192
- // `server/index.ts`'s SQLite branch) can reach `handle.db` without
193
- // an optional-chain or non-null assertion. A Neon-backed Handle
194
- // (`openNeonDb`) keeps the wider `db?: DatabaseSync` shape; routing
195
- // a Neon Handle into a SQLite-coupled call site is a compile-time
196
- // error. Mirrors the same pattern in `server/objstore/store.ts`.
167
+ // `server-e2e/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-e2e/objstore/store.ts`.
197
172
  export type SqliteHandle = Handle & { db: DatabaseSync }
198
173
 
199
174
  export function openDb(path: string): SqliteHandle {
200
175
  mkdirSync(dirname(path), { recursive: true })
201
176
  const db = new DatabaseSync(path)
202
- // Any throw between the DatabaseSync constructor and the return
203
- // would otherwise leak the underlying file / WAL / shm locks until
204
- // process exit — close before re-raising so the operator can fix
205
- // the underlying issue (failed STRICT check, ALTER TABLE error,
206
- // …) 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.
207
181
  try {
208
182
  return openDbInner(db)
209
183
  } catch (err) {
@@ -213,35 +187,30 @@ export function openDb(path: string): SqliteHandle {
213
187
  }
214
188
 
215
189
  function openDbInner(db: DatabaseSync): SqliteHandle {
216
- // WAL gives concurrent readers + faster writes and survives
217
- // crashes between commits without corrupting the file. Foreign
218
- // keys aren't strictly needed here (single-table schema) but
219
- // turning them on preserves the option to add referential
220
- // 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.
221
194
  db.exec('PRAGMA journal_mode = WAL;')
222
195
  // FULL (not NORMAL): the server emits `workspace-save-ack` BEFORE
223
- // returning to the event loop after `commitRevision`. With NORMAL,
224
- // SQLite only fsyncs at WAL checkpoint, so a power loss between
225
- // ack and the next checkpoint loses the row even though the
226
- // originator and broadcast peers were told the revision committed.
227
- // FULL fsyncs per commit; durability matches the contract the
228
- // ack implies. Trade-off is per-commit fsync latency, acceptable
229
- // for the protocol's edit-driven write pattern (triage edits, not
230
- // 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.
231
202
  db.exec('PRAGMA synchronous = FULL;')
232
203
  db.exec('PRAGMA foreign_keys = ON;')
233
204
  db.exec(SCHEMA)
234
205
  // Fail-loud on a pre-existing non-STRICT table — `CREATE TABLE IF
235
- // NOT EXISTS … STRICT` is a no-op when the table already exists,
236
- // so a deployment that predates the STRICT marker would silently
237
- // keep its non-STRICT shape. Without STRICT, an operator with
238
- // direct DB write access could insert mis-typed rows (e.g. a
239
- // `keyframe = "1\nfoo"` text value in the INTEGER column) and
240
- // poison the chain — the signed canonical the client originally
241
- // hashed says `keyframe = 1`, but the stored `keyframe = "1\nfoo"`
242
- // round-trips back into the canonical as a different string,
243
- // making every subsequent verify fail. Operator must migrate
244
- // 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.
245
214
  const meta = db.prepare(
246
215
  `SELECT strict FROM pragma_table_list WHERE schema = 'main' AND name = 'workspace_revision'`,
247
216
  ).get() as { strict: number } | undefined
@@ -249,13 +218,11 @@ function openDbInner(db: DatabaseSync): SqliteHandle {
249
218
  throw new Error('workspace_revision is non-STRICT — migrate via rename+create+copy before booting')
250
219
  }
251
220
  // Idempotent migration for DBs created before the keyframe column
252
- // existed. Inspect the column list rather than catching every
253
- // ALTER error — the previous shape swallowed `try { ALTER } catch
254
- // {}` for ANY failure (lock contention, disk full, corrupt page),
255
- // masking real problems as "column already exists". Now we only
256
- // ALTER when the column is genuinely missing, and any failure of
257
- // the ALTER itself bubbles up as an open-time crash where the
258
- // 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.
259
226
  const columns = db.prepare(`PRAGMA table_info(workspace_revision)`).all() as Array<{ name: string }>
260
227
  if (!columns.some((c) => c.name === 'keyframe')) {
261
228
  // ADD COLUMN carries the CHECK so a legacy DB migrating up lands
@@ -288,6 +255,17 @@ function openDbInner(db: DatabaseSync): SqliteHandle {
288
255
  const raw = wrapAll<P, Record<string, unknown>>(db.prepare(toSqlitePlaceholders(query)))
289
256
  return { all: async (...args: P) => (await raw.all(...args)).map(mapRevisionRow) }
290
257
  }
258
+ // Reuses `wrapGet` for the prepared statement, then maps the row
259
+ // through the shared coercion so the returned shape matches `chainFrom`.
260
+ const revisionByIdStmt: GetStmt<[string, string], RevisionRow> = (() => {
261
+ const raw = wrapGet<[string, string], Record<string, unknown>>(
262
+ db.prepare(toSqlitePlaceholders(REVISION_BY_ID_SQL)),
263
+ )
264
+ return { get: async (tag, id) => {
265
+ const row = await raw.get(tag, id)
266
+ return row ? mapRevisionRow(row) : undefined
267
+ } }
268
+ })()
291
269
  const handle: SqliteHandle = {
292
270
  db,
293
271
  headFor: wrapGet<[string], { id: string }>(db.prepare(toSqlitePlaceholders(HEAD_FOR_SQL))),
@@ -297,6 +275,7 @@ function openDbInner(db: DatabaseSync): SqliteHandle {
297
275
  chainAfterSeq: chainStmt<[string, number]>(CHAIN_AFTER_SQL),
298
276
  chainFromSeq: chainStmt<[string, number]>(CHAIN_FROM_SQL),
299
277
  revisionExists: wrapGet<[string, string], unknown>(db.prepare(toSqlitePlaceholders(REVISION_EXISTS_SQL))),
278
+ revisionById: revisionByIdStmt,
300
279
  // SQLite null-safe equality is `IS`; the numbered `?N` form (with
301
280
  // reuse) maps `$1`/`$2`/`$3` to repeated positional binds. `RETURNING
302
281
  // seq` works in node:sqlite (see objstore's `insertLiveIfAbsent`).
@@ -490,9 +469,8 @@ export function commitRevision(handle: Handle, input: RevisionInsert): Promise<C
490
469
  // base gate fails → `stale-base`.
491
470
  // • Two retransmits with the same id: the second's dup gate fails →
492
471
  // `duplicate`.
493
- // These are PROVEN green, unchanged, by the no-fork concurrency tests
494
- // in `tests/server-db.test.js` (two/N concurrent same-base, mixed,
495
- // 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).
496
474
  //
497
475
  // SQLite serialises writers internally even ACROSS connections, but a
498
476
  // multi-connection deployment is unsupported regardless. The