@preventive/triage 1.0.0-alpha.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/common/save-error-reason.ts +53 -0
  3. package/common/utf8.d.ts +13 -0
  4. package/common/utf8.js +57 -0
  5. package/out/brotli-fallback.js +3 -0
  6. package/out/client-sync.js +15 -0
  7. package/out/graph.js +4 -0
  8. package/out/icon-maskable.svg +5 -0
  9. package/out/icon.svg +5 -0
  10. package/out/index.html +78 -0
  11. package/out/manifest.webmanifest +30 -0
  12. package/out/prism.js +14 -0
  13. package/out/terminal.js +39 -0
  14. package/out/view.css +1 -0
  15. package/out/view.html +12 -0
  16. package/out/view.js +138 -0
  17. package/package.json +129 -0
  18. package/server/auth.ts +99 -0
  19. package/server/config.example.json +3 -0
  20. package/server/config.ts +196 -0
  21. package/server/db-neon.ts +374 -0
  22. package/server/db-revision-sql.ts +152 -0
  23. package/server/db-stmt.ts +53 -0
  24. package/server/db.ts +577 -0
  25. package/server/http.ts +142 -0
  26. package/server/hub.ts +98 -0
  27. package/server/index.ts +353 -0
  28. package/server/lifecycle.ts +177 -0
  29. package/server/neon-driver.ts +26 -0
  30. package/server/objstore/blob-fs.ts +164 -0
  31. package/server/objstore/blob-vercel.ts +508 -0
  32. package/server/objstore/blob.ts +169 -0
  33. package/server/objstore/fs.ts +67 -0
  34. package/server/objstore/handlers.ts +235 -0
  35. package/server/objstore/init.ts +118 -0
  36. package/server/objstore/reaper.ts +199 -0
  37. package/server/objstore/rest.ts +484 -0
  38. package/server/objstore/sign.ts +164 -0
  39. package/server/objstore/store-neon.ts +351 -0
  40. package/server/objstore/store.ts +799 -0
  41. package/server/objstore/tokens.ts +168 -0
  42. package/server/origin.ts +68 -0
  43. package/server/peer.ts +38 -0
  44. package/server/sign.ts +231 -0
  45. package/server/static.ts +374 -0
  46. package/server/sync-handlers.ts +311 -0
  47. package/server/util.ts +27 -0
  48. package/server/validation.ts +36 -0
  49. package/server/ws-server.ts +245 -0
package/server/db.ts ADDED
@@ -0,0 +1,577 @@
1
+ // SQLite-backed revision storage. Two columns identify a revision:
2
+ // `seq` monotonic per workspace_tag, server-assigned at insert.
3
+ // Drives chain ordering and `from`-cutoff filtering.
4
+ // `id` content-addressed identifier — SHA-256 of the canonical
5
+ // save bytes (workspaceTag, base, keyframe, nonce,
6
+ // ciphertext), base64url-encoded. Computed by client AND
7
+ // server from the same input, so the server can't
8
+ // reassign or relabel revisions; UNIQUE on
9
+ // (workspace_tag, id) makes retransmits idempotent.
10
+ // `base` points at the previous revision's `id` (or null for the
11
+ // first revision in a workspace).
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.
24
+ //
25
+ // `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.
31
+ //
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.
56
+
57
+ import { DatabaseSync } from 'node:sqlite'
58
+ import { mkdirSync } from 'node:fs'
59
+ import { dirname } from 'node:path'
60
+ import { type AllStmt, type GetStmt, wrapAll, wrapGet } from './db-stmt.ts'
61
+ import {
62
+ 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,
64
+ } from './db-revision-sql.ts'
65
+
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`).
78
+ //
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.
84
+ const WORKSPACE_REVISION_DEF = `(
85
+ workspace_tag TEXT NOT NULL,
86
+ seq INTEGER NOT NULL,
87
+ id TEXT NOT NULL,
88
+ base TEXT,
89
+ keyframe INTEGER NOT NULL DEFAULT 0 CHECK (keyframe IN (0, 1)),
90
+ nonce TEXT NOT NULL,
91
+ ciphertext TEXT NOT NULL,
92
+ signature TEXT NOT NULL,
93
+ created_at INTEGER NOT NULL,
94
+ PRIMARY KEY (workspace_tag, seq),
95
+ UNIQUE (workspace_tag, id)
96
+ ) STRICT`
97
+
98
+ const WORKSPACE_REVISION_TAG_ID_INDEX = `CREATE INDEX IF NOT EXISTS workspace_revision_tag_id_idx
99
+ ON workspace_revision (workspace_tag, id)`
100
+
101
+ const SCHEMA = `
102
+ CREATE TABLE IF NOT EXISTS workspace_revision ${WORKSPACE_REVISION_DEF};
103
+
104
+ ${WORKSPACE_REVISION_TAG_ID_INDEX};
105
+ `
106
+
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.
111
+ export type RevisionRow = {
112
+ base: string | null
113
+ id: string
114
+ keyframe: number
115
+ nonce: string
116
+ ciphertext: string
117
+ signature: string
118
+ }
119
+
120
+ // 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.
124
+ export type RevisionInsert = {
125
+ tag: string
126
+ id: string
127
+ base: string | null
128
+ keyframe: boolean
129
+ nonce: string
130
+ ciphertext: string
131
+ signature: string
132
+ }
133
+
134
+ // Outcome of `commitRevision`. `duplicate` means the id is already
135
+ // in the chain (a retransmit landed during our await window).
136
+ // `stale-base` means a concurrent save advanced the head past the
137
+ // caller's claimed base — the caller renders this as a
138
+ // `workspace-state` catch-up. `inserted` is the success path.
139
+ export type CommitResult =
140
+ | { kind: 'inserted' }
141
+ | { kind: 'duplicate' }
142
+ | { kind: 'stale-base'; head: string | null }
143
+
144
+ // Bag of pre-prepared statements + the underlying connection.
145
+ // Held for the process lifetime; `close()` runs from `shutdown()`.
146
+ //
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`.
153
+ //
154
+ // `tryCommit` is the backend-specific atomic-commit primitive that
155
+ // `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`.
164
+ //
165
+ // `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`.
175
+ export type Handle = {
176
+ db?: DatabaseSync
177
+ headFor: GetStmt<[string], { id: string }>
178
+ seqOfId: GetStmt<[string, string], { seq: number }>
179
+ lastKeyframeSeq: GetStmt<[string], { s: number | null }>
180
+ chainAll: AllStmt<[string], RevisionRow>
181
+ chainAfterSeq: AllStmt<[string, number], RevisionRow>
182
+ chainFromSeq: AllStmt<[string, number], RevisionRow>
183
+ revisionExists: GetStmt<[string, string], unknown>
184
+ gatedInsert?: GetStmt<[string, string, string | null, number, string, string, string, number], { seq: number }>
185
+ tryCommit: (input: RevisionInsert) => Promise<CommitResult>
186
+ close: () => Promise<void>
187
+ }
188
+
189
+ // Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
190
+ // to be set. `openDb` returns this so call sites that need direct
191
+ // `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`.
197
+ export type SqliteHandle = Handle & { db: DatabaseSync }
198
+
199
+ export function openDb(path: string): SqliteHandle {
200
+ mkdirSync(dirname(path), { recursive: true })
201
+ 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.
207
+ try {
208
+ return openDbInner(db)
209
+ } catch (err) {
210
+ try { db.close() } catch {}
211
+ throw err
212
+ }
213
+ }
214
+
215
+ 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.
221
+ db.exec('PRAGMA journal_mode = WAL;')
222
+ // 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.
231
+ db.exec('PRAGMA synchronous = FULL;')
232
+ db.exec('PRAGMA foreign_keys = ON;')
233
+ db.exec(SCHEMA)
234
+ // 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.
245
+ const meta = db.prepare(
246
+ `SELECT strict FROM pragma_table_list WHERE schema = 'main' AND name = 'workspace_revision'`,
247
+ ).get() as { strict: number } | undefined
248
+ if (meta && meta.strict !== 1) {
249
+ throw new Error('workspace_revision is non-STRICT — migrate via rename+create+copy before booting')
250
+ }
251
+ // 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.
259
+ const columns = db.prepare(`PRAGMA table_info(workspace_revision)`).all() as Array<{ name: string }>
260
+ if (!columns.some((c) => c.name === 'keyframe')) {
261
+ // ADD COLUMN carries the CHECK so a legacy DB migrating up lands
262
+ // the value-domain guard alongside the column. Existing rows take
263
+ // the DEFAULT 0, which satisfies the CHECK, so the ALTER succeeds.
264
+ db.exec(`ALTER TABLE workspace_revision ADD COLUMN keyframe INTEGER NOT NULL DEFAULT 0 CHECK (keyframe IN (0, 1))`)
265
+ }
266
+ // Auto-migrate a pre-existing keyframe column that lacks the CHECK.
267
+ // `CREATE TABLE IF NOT EXISTS … CHECK` is a no-op when the table
268
+ // already exists, and SQLite cannot ALTER a CHECK onto an existing
269
+ // column, so a DB created before this CHECK existed keeps its
270
+ // unconstrained keyframe column — silently dropping the value-domain
271
+ // guard. The freshly-added ALTER column above already carries the
272
+ // CHECK, so only a genuinely pre-CHECK keyframe column reaches the
273
+ // rebuild. Detect it from the stored DDL (SQLite folds an
274
+ // ALTER-added column's CHECK back into the table's CREATE text, so
275
+ // this matches both creation paths) and rebuild the table in place.
276
+ const ddl = (db.prepare(
277
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workspace_revision'`,
278
+ ).get() as { sql: string } | undefined)?.sql ?? ''
279
+ if (!/CHECK\s*\(\s*keyframe\s+IN\b/iu.test(ddl)) {
280
+ migrateAddKeyframeCheck(db)
281
+ }
282
+ // Prepare a chain SELECT (shared `$N` source → `?N`) and map each row
283
+ // through the shared `mapRevisionRow` — same `AllStmt<…, RevisionRow>`
284
+ // contract the Neon backend exposes, so consumers see identical rows.
285
+ // Reuses `wrapAll` (the sync-throw→rejection wrapper) for the raw rows
286
+ // and layers the row mapper on top.
287
+ const chainStmt = <P extends unknown[]>(query: string): AllStmt<P, RevisionRow> => {
288
+ const raw = wrapAll<P, Record<string, unknown>>(db.prepare(toSqlitePlaceholders(query)))
289
+ return { all: async (...args: P) => (await raw.all(...args)).map(mapRevisionRow) }
290
+ }
291
+ const handle: SqliteHandle = {
292
+ db,
293
+ headFor: wrapGet<[string], { id: string }>(db.prepare(toSqlitePlaceholders(HEAD_FOR_SQL))),
294
+ seqOfId: wrapGet<[string, string], { seq: number }>(db.prepare(toSqlitePlaceholders(SEQ_OF_ID_SQL))),
295
+ lastKeyframeSeq: wrapGet<[string], { s: number | null }>(db.prepare(toSqlitePlaceholders(LAST_KEYFRAME_SEQ_SQL))),
296
+ chainAll: chainStmt<[string]>(CHAIN_ALL_SQL),
297
+ chainAfterSeq: chainStmt<[string, number]>(CHAIN_AFTER_SQL),
298
+ chainFromSeq: chainStmt<[string, number]>(CHAIN_FROM_SQL),
299
+ revisionExists: wrapGet<[string, string], unknown>(db.prepare(toSqlitePlaceholders(REVISION_EXISTS_SQL))),
300
+ // SQLite null-safe equality is `IS`; the numbered `?N` form (with
301
+ // reuse) maps `$1`/`$2`/`$3` to repeated positional binds. `RETURNING
302
+ // seq` works in node:sqlite (see objstore's `insertLiveIfAbsent`).
303
+ gatedInsert: wrapGet<
304
+ [string, string, string | null, number, string, string, string, number],
305
+ { seq: number }
306
+ >(db.prepare(GATED_INSERT_SQL_SQLITE)),
307
+ tryCommit: (input) => commitRevisionSqlite(handle, input),
308
+ // Match the wrap{Get,All,Run} contract: async-wrapped so a sync
309
+ // throw from `db.close()` (already closed, locked transaction, …)
310
+ // surfaces as a Promise rejection rather than escaping the
311
+ // wrapper synchronously.
312
+ // eslint-disable-next-line require-await
313
+ close: async () => { db.close() },
314
+ }
315
+ return handle
316
+ }
317
+
318
+ // Rebuild `workspace_revision` in place to add the
319
+ // `CHECK (keyframe IN (0, 1))` a pre-CHECK DB lacks. SQLite can't ALTER
320
+ // a CHECK onto an existing column, so this runs the documented
321
+ // create-new + copy + drop + rename rebuild, wrapped in ONE transaction
322
+ // so a crash mid-rebuild rolls back to the original table rather than
323
+ // losing it. No table in this DB file carries a foreign key referencing
324
+ // workspace_revision (the objstore tables are independent), so the DROP
325
+ // can't cascade and `foreign_keys` can stay ON. If any existing row
326
+ // holds a keyframe outside {0, 1} — the exact poison the CHECK exists
327
+ // to reject — the copy trips the new CHECK, the whole transaction rolls
328
+ // back, and the original table survives intact; the violation surfaces
329
+ // to the operator (openDb's catch closes the handle and rethrows)
330
+ // rather than silently coercing or dropping the bad row. Reuses
331
+ // `WORKSPACE_REVISION_DEF` so the rebuilt table matches a fresh one.
332
+ function migrateAddKeyframeCheck(db: DatabaseSync): void {
333
+ db.exec('BEGIN IMMEDIATE')
334
+ try {
335
+ db.exec(`CREATE TABLE workspace_revision_new ${WORKSPACE_REVISION_DEF}`)
336
+ db.exec(
337
+ `INSERT INTO workspace_revision_new
338
+ (workspace_tag, seq, id, base, keyframe, nonce, ciphertext, signature, created_at)
339
+ SELECT workspace_tag, seq, id, base, keyframe, nonce, ciphertext, signature, created_at
340
+ FROM workspace_revision`,
341
+ )
342
+ db.exec('DROP TABLE workspace_revision')
343
+ db.exec('ALTER TABLE workspace_revision_new RENAME TO workspace_revision')
344
+ db.exec(WORKSPACE_REVISION_TAG_ID_INDEX)
345
+ db.exec('COMMIT')
346
+ } catch (err) {
347
+ // Best-effort rollback so the original table survives a failed
348
+ // rebuild (e.g. a poison keyframe row tripping the new CHECK). The
349
+ // ROLLBACK's own error is irrelevant — we always rethrow the
350
+ // original cause, which is what the operator needs to act on.
351
+ try { db.exec('ROLLBACK') } catch {}
352
+ throw err
353
+ }
354
+ }
355
+
356
+ export async function headFor(handle: Handle, tag: string): Promise<string | null> {
357
+ const row = await handle.headFor.get(tag)
358
+ return row?.id ?? null
359
+ }
360
+
361
+ export async function chainFrom(handle: Handle, tag: string, fromId: string | null): Promise<RevisionRow[]> {
362
+ // No base id, OR a base id the server doesn't recognise (db reset,
363
+ // chain compaction, malicious peer): in either case the client has
364
+ // no anchor we can incrementally serve from. Skip past everything
365
+ // before the latest keyframe — the keyframe replaces baseState, so
366
+ // anything older is redundant — and fall through to the full chain
367
+ // only when no keyframe has been emitted yet (small workspace
368
+ // hasn't crossed the threshold). Keeps the catch-up cost O(keyframe
369
+ // interval) instead of O(history length) for either entry point.
370
+ if (fromId != null) {
371
+ const row = await handle.seqOfId.get(tag, fromId)
372
+ if (row) return handle.chainAfterSeq.all(tag, row.seq)
373
+ // fall through to the from=null path below
374
+ }
375
+ const kf = await handle.lastKeyframeSeq.get(tag)
376
+ if (kf?.s != null) return handle.chainFromSeq.all(tag, kf.s)
377
+ return handle.chainAll.all(tag)
378
+ }
379
+
380
+ export async function revisionExists(handle: Handle, tag: string, id: string): Promise<boolean> {
381
+ return Boolean(await handle.revisionExists.get(tag, id))
382
+ }
383
+
384
+ // Driver shapes for a primary-key or unique-index violation.
385
+ // `commitRevision`'s INSERT can hit either constraint under a
386
+ // multi-process race against the same database: the
387
+ // `(workspace_tag, seq)` PK if a sibling process landed a row with
388
+ // our computed seq, or the `(workspace_tag, id)` UNIQUE if a
389
+ // sibling retransmit slipped in with the same id. Both are
390
+ // recoverable.
391
+ //
392
+ // We accept several driver-error shapes so the recovery path
393
+ // doesn't silently regress under a driver upgrade:
394
+ // • Postgres / Neon: SQLSTATE `23505` via `err.code`.
395
+ // • node:sqlite: `SQLITE_CONSTRAINT_UNIQUE` /
396
+ // `SQLITE_CONSTRAINT_PRIMARYKEY` via `err.code`.
397
+ // • SQLite fallback by message-shape: modern releases emit
398
+ // "UNIQUE constraint failed: …" for both UNIQUE-index and
399
+ // PRIMARY-KEY violations; older / certain paths instead emit
400
+ // "PRIMARY KEY must be unique". Match both so a future Node
401
+ // `node:sqlite` change can't silently turn a recoverable
402
+ // conflict into an unhandled rejection.
403
+ export function isUniqueViolation(err: unknown): boolean {
404
+ if (!(err instanceof Error)) return false
405
+ const code = (err as { code?: string }).code
406
+ if (code === '23505') return true
407
+ if (code === 'SQLITE_CONSTRAINT_UNIQUE') return true
408
+ if (code === 'SQLITE_CONSTRAINT_PRIMARYKEY') return true
409
+ if (err.message.includes('UNIQUE constraint failed')) return true
410
+ if (err.message.includes('PRIMARY KEY must be unique')) return true
411
+ // Postgres / Neon message-shape fallback. The Postgres phrasing
412
+ // is "duplicate key value violates unique constraint …" — caught
413
+ // by SQLSTATE `23505` above today, but the message-shape match
414
+ // is belt-and-suspenders against a future Neon driver release
415
+ // that omits or renames `err.code`. Without it a missing `code`
416
+ // would silently regress the recovery path to "rethrow as an
417
+ // operational error" and the originator would never see the
418
+ // `stale-base` / `duplicate` catch-up.
419
+ if (err.message.includes('duplicate key value violates unique constraint')) return true
420
+ return false
421
+ }
422
+
423
+ // Atomic commit of a single revision. Backends dispatch through
424
+ // `handle.tryCommit` (set up at openDb / openNeonDb time):
425
+ //
426
+ // • SQLite uses `commitRevisionSqlite` (below) — ONE synchronous
427
+ // gated INSERT, no in-process lock. `node:sqlite` doesn't yield
428
+ // mid-statement, so the head-check and MAX(seq) read from one
429
+ // snapshot; a racer is forced onto either the same seq (PK
430
+ // rejects → recovery → stale-base) or a stale head (no insert →
431
+ // stale-base). Single-process is the only supported SQLite
432
+ // deployment shape; the PK backstops the multi-connection case.
433
+ // • Neon wraps the dup-check + head-check + gated INSERT in a
434
+ // pipelined transaction. No commit-time advisory lock: the
435
+ // gated INSERT's head-check and MAX(seq) read one Postgres
436
+ // READ-COMMITTED statement snapshot, and the
437
+ // `UNIQUE(workspace_tag, seq)` PK rejects a racer that computed
438
+ // the same seq — so a cross-replica racer either collides on the
439
+ // PK (→ recovery → stale-base) or sees the advanced head (→ no
440
+ // insert → stale-base), never a silent fork. See `db-neon.ts`'s
441
+ // `tryCommitNeon` for the per-statement rationale.
442
+ export function commitRevision(handle: Handle, input: RevisionInsert): Promise<CommitResult> {
443
+ // A hand-rolled Handle literal (e.g. a test mock) won't carry a
444
+ // `tryCommit` impl. Surface as a Promise rejection rather than a
445
+ // sync TypeError so the function's Promise-returning contract
446
+ // holds for every caller. Matches the previous-shape error
447
+ // string ("handle not opened via openDb") so existing tests /
448
+ // log alerts that match on it keep firing.
449
+ if (typeof handle.tryCommit !== 'function') {
450
+ return Promise.reject(new Error('commitRevision: handle not opened via openDb'))
451
+ }
452
+ return handle.tryCommit(input)
453
+ }
454
+
455
+ // SQLite-style atomic commit. NO in-process lock — it fires the SAME
456
+ // single gated INSERT the Neon path uses —
457
+ // `INSERT … SELECT COALESCE(MAX(seq),0)+1 … WHERE NOT EXISTS(dup) AND
458
+ // head IS base RETURNING seq` (SQLite's `IS` is the null-safe equality;
459
+ // Neon uses `IS NOT DISTINCT FROM`) — then discriminates the outcome.
460
+ //
461
+ // Why the lock is gone (the fork-safety argument):
462
+ // • `node:sqlite` is SYNCHRONOUS: the gated INSERT runs to completion
463
+ // in one turn without yielding the event loop, so no concurrent
464
+ // `commitRevision` can interleave in the middle of it. The
465
+ // head-check `(SELECT id … ORDER BY seq DESC LIMIT 1)` and the
466
+ // `COALESCE(MAX(seq),0)+1` seq computation therefore read from ONE
467
+ // consistent snapshot of the table.
468
+ // • The lock formerly existed ONLY to stop a chain fork in which a
469
+ // racer read `head=X` from one snapshot but `MAX(seq)=N+1` from a
470
+ // LATER one (after a sibling committed (seq=N+1, base=X)), then
471
+ // inserted (seq=N+2, base=X) — same base, different seq, no PK
472
+ // conflict. With head-check and MAX(seq) in ONE statement that
473
+ // split snapshot can't happen: a racer's snapshot is either BEFORE
474
+ // the winner's commit (→ it computes the same seq=N+1 → the
475
+ // `UNIQUE(workspace_tag, seq)` PK rejects the second INSERT →
476
+ // recovery → `stale-base`) or AFTER it (→ head ≠ base → the WHERE
477
+ // gate fails → no insert → `stale-base`). Exactly one commits.
478
+ // • The WHERE re-asserts BOTH checks: `NOT EXISTS(dup-id)` is the dup
479
+ // recheck; `head IS $3` is the base-equality check (NULL-safe so
480
+ // the FIRST revision — base = NULL against an empty-chain head,
481
+ // also NULL — matches and inserts).
482
+ // • A non-empty `RETURNING seq` ⇔ both gates passed → `inserted`. An
483
+ // empty result means a gate failed; we re-read to discriminate
484
+ // `duplicate` (dup gate) from `stale-base` (base gate).
485
+ //
486
+ // Concurrency hazards closed WITHOUT the lock:
487
+ // • Two saves with the same `base` and DIFFERENT id never both insert
488
+ // (UNIQUE is on id, not base): the synchronous gated INSERTs run
489
+ // one after the other, so the second sees the first's head and the
490
+ // base gate fails → `stale-base`.
491
+ // • Two retransmits with the same id: the second's dup gate fails →
492
+ // `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.
496
+ //
497
+ // SQLite serialises writers internally even ACROSS connections, but a
498
+ // multi-connection deployment is unsupported regardless. The
499
+ // unique-violation catch below is the residual backstop for that
500
+ // scenario (e.g. a test fixture opening two `openDb` handles to one
501
+ // file): a sibling landing our computed seq / id makes the gated INSERT
502
+ // throw a PK / UNIQUE violation, which we refetch through `inserted` /
503
+ // `stale-base` — read-after-write-failure with no isolation-level
504
+ // assumption, any committed head we see being a valid stale-base
505
+ // target. No silent failure, no chain fork.
506
+ export async function commitRevisionSqlite(
507
+ handle: Handle,
508
+ { tag, id, base, keyframe, nonce, ciphertext, signature }: RevisionInsert,
509
+ ): Promise<CommitResult> {
510
+ // `gatedInsert` is populated by `openDbInner` only (the Neon backend
511
+ // folds its gated INSERT into a transaction and leaves this unset —
512
+ // see `db-neon.ts`); the only way to reach a missing one here is to
513
+ // construct a `Handle` literal by hand (e.g. a test mock) or to route
514
+ // a Neon-backed Handle into this SQLite primitive. Throwing inside
515
+ // this `async` function surfaces as a Promise REJECTION (not a sync
516
+ // throw), so the function's Promise-returning contract holds for
517
+ // every caller — an unawaited write would otherwise leak an uncaught
518
+ // exception.
519
+ const gatedInsert = handle.gatedInsert
520
+ if (!gatedInsert) {
521
+ throw new Error('commitRevisionSqlite: handle not opened via openDb')
522
+ }
523
+ // Strict-boolean coercion via `=== true`. The canonical signed by
524
+ // the client uses `keyframe === true ? '1' : ''` — anything
525
+ // truthy-but-not-strictly-true (e.g. `1`, `"true"`, `{}`) would
526
+ // canonicalize to `''` (non-keyframe) on the verifier side but
527
+ // would round-trip to `1` here via the looser ternary, diverging
528
+ // signed bytes from stored bytes. TS narrows `keyframe` to
529
+ // `boolean` upstream; the strict comparison is defense-in-depth
530
+ // against a future caller that loosens the field type or a direct
531
+ // invocation from a non-typed context. Input-validation audit.
532
+ const keyframeCol = keyframe === true ? 1 : 0
533
+ const baseNorm = base ?? null
534
+ try {
535
+ // Gated INSERT: inserts (and RETURNs the assigned seq) only when
536
+ // there is no dup id AND the current head equals the proposed
537
+ // base. A returned row ⇔ inserted. `node:sqlite` runs this whole
538
+ // statement synchronously, so the head-check and MAX(seq) it
539
+ // contains read one snapshot — no concurrent commit interleaves.
540
+ const inserted = await gatedInsert.get(tag, id, baseNorm, keyframeCol, nonce, ciphertext, signature, Date.now())
541
+ if (inserted) return { kind: 'inserted' }
542
+ // No insert → a gate failed. Re-assert the dup gate first (a
543
+ // retransmit landed) before falling back to the base gate
544
+ // (head advanced past our base) — same dup-then-base precedence.
545
+ if (await handle.revisionExists.get(tag, id)) return { kind: 'duplicate' }
546
+ const headRow = await handle.headFor.get(tag)
547
+ return { kind: 'stale-base', head: headRow?.id ?? null }
548
+ } catch (err) {
549
+ // Only convert unique-violations; rethrow other driver errors
550
+ // (network, connection-closed, …) so they surface as real
551
+ // failures rather than masking as a stale-base catch-up.
552
+ if (!isUniqueViolation(err)) throw err
553
+ // The PK / UNIQUE was the only thing standing between us and
554
+ // a chain fork. Refetch and route through one of two outcomes:
555
+ //
556
+ // • The row IS in the chain — return `inserted`. We can't
557
+ // distinguish "we successfully INSERTed but the driver's
558
+ // retry layer wrapped the response as a unique-violation"
559
+ // from "a sibling process committed our id first". In the
560
+ // first case the row IS our save and peers MUST receive
561
+ // the broadcast; in the second it's still safe to
562
+ // broadcast because clients dedup by content-addressed
563
+ // `id` (and the id collision implies the canonical bytes
564
+ // are byte-identical, so a peer can't tell the difference
565
+ // anyway). Treating recovery-exists as `inserted` is the
566
+ // defensive choice — broadcast on possibly-ours rather
567
+ // than silently drop the broadcast on definitely-ours.
568
+ //
569
+ // • The row is NOT in the chain — head advanced past our
570
+ // computed seq via a sibling commit with a different id.
571
+ // `stale-base` so the caller renders a `workspace-state`
572
+ // catch-up.
573
+ if (await handle.revisionExists.get(tag, id)) return { kind: 'inserted' }
574
+ const newHeadRow = await handle.headFor.get(tag)
575
+ return { kind: 'stale-base', head: newHeadRow?.id ?? null }
576
+ }
577
+ }