@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
@@ -0,0 +1,374 @@
1
+ // Neon Postgres backend for `workspace_revision`. Mirrors the
2
+ // SQLite-backed `openDb` in ./db.ts — same `Handle` shape (with
3
+ // `db: DatabaseSync` unset), same queries by intent, Postgres
4
+ // dialect on the wire.
5
+ //
6
+ // `@neondatabase/serverless` is an OPTIONAL peer dep — selected by
7
+ // the `DATABASE_URL` branch in `server/index.ts`. The peer dep
8
+ // itself is loaded lazily via the dynamic `import()` inside
9
+ // `openNeonDb` below, so a SQLite-only deployment never installs it
10
+ // (`autoInstallPeers: false` in `pnpm-workspace.yaml`) and never
11
+ // reaches the import.
12
+ //
13
+ // API shape: `neon(connectionString)` returns a tagged-template +
14
+ // callable function. We use the function-call form
15
+ // `sql(text, params)`. `tryCommitNeon` (below) folds the dup-check
16
+ // + head-check + gated INSERT into a single pipelined
17
+ // `sql.transaction([...])` — NO commit-time advisory lock. The gated
18
+ // INSERT's head-check and `MAX(seq)` run inside one Postgres
19
+ // READ-COMMITTED statement snapshot, and the
20
+ // `UNIQUE(workspace_tag, seq)` PK rejects a cross-replica racer that
21
+ // computed the same seq, so a racer either collides on the PK
22
+ // (→ recovery → stale-base) or sees the advanced head (→ no insert →
23
+ // stale-base) — never a silent fork. See `tryCommitNeon` for the
24
+ // per-statement argument, including the caveat that PGlite
25
+ // (single-connection) can't empirically exercise the cross-replica
26
+ // race — a real-Postgres concurrency test is the way to confirm it.
27
+ // The DDL bootstrap (below) keeps its own advisory lock (it
28
+ // serialises concurrent schema boots, unrelated to commits) and runs
29
+ // as a pipelined transaction so the schema creates either fully or
30
+ // not at all on a transient network failure mid-DDL.
31
+ //
32
+ // Durability: the SQLite path sets `PRAGMA synchronous = FULL` so
33
+ // `workspace-save-ack` is only emitted after the row is fsynced.
34
+ // The Neon path inherits whatever durability the Neon endpoint
35
+ // provides — by default Postgres `synchronous_commit = on`, which
36
+ // commits to durable WAL on the primary before returning. Neon
37
+ // additionally replicates synchronously to multiple AZs, so the
38
+ // "ack means durable" contract holds without explicit per-statement
39
+ // configuration here. `openNeonDb` runs a boot-time `SHOW
40
+ // synchronous_commit` and throws if the endpoint has been
41
+ // configured `off` — the only level that skips the primary's WAL
42
+ // fsync. `local` / `on` / `remote_*` all preserve the
43
+ // ack-implies-durable contract that `workspace-save-ack` carries.
44
+
45
+ import type { AllStmt, GetStmt, RunStmt } from './db-stmt.ts'
46
+ import { type CommitResult, type Handle, type RevisionInsert, type RevisionRow, isUniqueViolation } from './db.ts'
47
+ import {
48
+ CHAIN_AFTER_SQL, CHAIN_ALL_SQL, CHAIN_FROM_SQL, GATED_INSERT_SQL_PG, HEAD_FOR_SQL,
49
+ LAST_KEYFRAME_SEQ_SQL, REVISION_EXISTS_SQL, SEQ_OF_ID_SQL, mapRevisionRow, num, numOrNull,
50
+ } from './db-revision-sql.ts'
51
+
52
+ // `num` / `numOrNull` (safe-integer BIGINT coercion) live in the shared
53
+ // `./db-revision-sql.ts` so the shared `mapRevisionRow` can use them
54
+ // without a backend→backend import cycle. Re-exported here because the
55
+ // objstore Neon plane (`./objstore/store-neon.ts`) imports them from
56
+ // this module — keeping that import working without touching the
57
+ // objstore code, and keeping a single definition shared by all three
58
+ // call sites (revision Neon, revision SQLite mapper, objstore Neon).
59
+ export { num, numOrNull }
60
+
61
+ // Minimal structural type for the `neon()` callable. We don't pull
62
+ // `@neondatabase/serverless`'s types in at the top level because
63
+ // the peer dep may be absent; the runtime `import()` lands the real
64
+ // implementation. Anything we use here is at the wire level (SQL
65
+ // string + positional params + returned rows array).
66
+ //
67
+ // `transaction` is the driver's pipelined-transaction primitive —
68
+ // a single HTTP round-trip carries `BEGIN; <stmt>; …; COMMIT` so
69
+ // the whole batch atomically applies or fully aborts on the server
70
+ // side. Used by the DDL bootstrap below to prevent partial schema
71
+ // creation on a transient mid-batch network failure.
72
+ //
73
+ // `transaction` is typed against the query promise the call form
74
+ // returns (NOT plain `Promise<unknown>`) so a misuse like
75
+ // `sql.transaction([Promise.resolve(123)])` fails at compile time
76
+ // rather than failing opaquely inside the driver. The driver itself
77
+ // inspects each promise's internal shape and rejects non-query
78
+ // promises at runtime; the type narrows that to a static error.
79
+ type NeonQueryPromise = ReturnType<NeonSqlCall>
80
+ type NeonSqlCall = (queryText: string, params?: readonly unknown[]) => Promise<unknown[]>
81
+ export type NeonSql = NeonSqlCall & {
82
+ transaction: (queries: NeonQueryPromise[]) => Promise<unknown[][]>
83
+ }
84
+
85
+ // Advisory-lock keys (signed-int64 split into two int32s for the
86
+ // two-arg `pg_advisory_xact_lock` form). Distinct per-table so the
87
+ // triage-revision + objstore DDL bootstraps don't serialize against
88
+ // each other; same value across replicas so multiple boot-races
89
+ // converge on a single DDL run. Chosen to be unlikely to collide
90
+ // with operator-issued advisory locks.
91
+ const DDL_LOCK_KEY_REVISION = 0x6465_7670 // 'depv'
92
+ const DDL_LOCK_KEY_REVISION_SUB = 0x7273_6e72 // 'rsnr'
93
+
94
+ // Durability levels that satisfy the ack-implies-durable contract.
95
+ // `local` fsyncs the primary's WAL before returning — matches what
96
+ // SQLite's `PRAGMA synchronous = FULL` enforces (single-node fsync)
97
+ // and the contract that server acks imply durable commit. `on`
98
+ // (the Postgres default) is `local` plus waiting for any sync
99
+ // standbys. `remote_write` waits for sync replicas to receive WAL;
100
+ // `remote_apply` for them to apply it. Only `off` is rejected — it
101
+ // skips the WAL fsync entirely so a primary crash mid-ack loses
102
+ // the committed row.
103
+ const DURABLE_SYNC_COMMIT_LEVELS = new Set(['local', 'on', 'remote_write', 'remote_apply'])
104
+
105
+ export async function assertDurableSyncCommit(sql: NeonSql): Promise<void> {
106
+ const rows = await sql(`SHOW synchronous_commit`, []) as Array<{ synchronous_commit?: unknown }>
107
+ const level = String(rows[0]?.synchronous_commit ?? '').trim().toLowerCase()
108
+ if (!DURABLE_SYNC_COMMIT_LEVELS.has(level)) {
109
+ throw new Error(
110
+ `Neon endpoint has synchronous_commit='${level}' — refuses to start because server acks ` +
111
+ `imply durable commit; configure the Postgres role / project to use 'local', 'on' (default), ` +
112
+ `'remote_write', or 'remote_apply'.`,
113
+ )
114
+ }
115
+ }
116
+
117
+ // Postgres DDL. Statement-per-array-element because Neon's HTTP
118
+ // transport runs one statement per request — there's no
119
+ // multi-statement support like SQLite's `db.exec`. INTEGER → BIGINT
120
+ // for `seq` and `created_at` (epoch millis) so a long-running
121
+ // workspace doesn't overflow at 2^31. `keyframe` stays a small int
122
+ // that the row mapper coerces to 0 / 1 for parity with the SQLite
123
+ // shape. No `STRICT` clause — Postgres columns are strictly typed
124
+ // by default.
125
+ const SCHEMA_PG = [
126
+ // `CHECK (keyframe IN (0, 1))` is the value-domain guard: without
127
+ // it, a direct DB write of `keyframe = 2` would silently coerce to
128
+ // 0 in `mapRevisionRow`'s `=== 1` check, diverging from the signed
129
+ // canonical the row was hashed against. The SQLite path carries the
130
+ // identical CHECK (see `db.ts`) — note STRICT there enforces only
131
+ // the column TYPE (INTEGER), NOT this {0, 1} domain (`keyframe = 2`
132
+ // is a valid integer STRICT accepts), so BOTH backends need the
133
+ // explicit CHECK. Same operator-with-DB-write attack vector on both
134
+ // planes.
135
+ `CREATE TABLE IF NOT EXISTS workspace_revision (
136
+ workspace_tag TEXT NOT NULL,
137
+ seq BIGINT NOT NULL,
138
+ id TEXT NOT NULL,
139
+ base TEXT,
140
+ keyframe SMALLINT NOT NULL DEFAULT 0 CHECK (keyframe IN (0, 1)),
141
+ nonce TEXT NOT NULL,
142
+ ciphertext TEXT NOT NULL,
143
+ signature TEXT NOT NULL,
144
+ created_at BIGINT NOT NULL,
145
+ PRIMARY KEY (workspace_tag, seq),
146
+ UNIQUE (workspace_tag, id)
147
+ )`,
148
+ // NOTE: no separate `workspace_revision_tag_id_idx` on the Neon
149
+ // path. Postgres' UNIQUE constraint already builds a btree index
150
+ // on (workspace_tag, id); duplicating it would only cost extra
151
+ // write amplification + storage per INSERT. The SQLite path keeps
152
+ // a separate `CREATE INDEX` because SQLite's query planner
153
+ // historically did not always use the implicit UNIQUE index for
154
+ // covering lookups — Postgres' planner does.
155
+ ]
156
+
157
+ // Generic statement-builder helpers shared by both Neon planes
158
+ // (workspace_revision here + the objstore tables in store-neon.ts).
159
+ // They remove the repeated `{ run/get: async (...args) => sql(...) }`
160
+ // wrapper for the statements whose positional args map straight to
161
+ // the query's `$1..$N` placeholders and whose rows need no coercion.
162
+ // Statements that coerce BIGINT (via `num`) or map snake_case rows
163
+ // stay bespoke. `args as readonly unknown[]` widens the call-site
164
+ // tuple to the driver's positional-params type.
165
+
166
+ // Trivial passthrough write: args → $1..$N in order, no result shape.
167
+ export function runStmt<P extends unknown[]>(sql: NeonSql, query: string): RunStmt<P> {
168
+ return { run: async (...args: P) => { await sql(query, args as readonly unknown[]) } }
169
+ }
170
+
171
+ // First-row read with no coercion (callers needing BIGINT→number or
172
+ // snake_case mapping build their own). Returns `undefined` on the
173
+ // empty result set, matching the SQLite `wrapGet` contract.
174
+ export function getRowStmt<P extends unknown[], T>(sql: NeonSql, query: string): GetStmt<P, T> {
175
+ return { get: async (...args: P) => (await sql(query, args as readonly unknown[]) as T[])[0] }
176
+ }
177
+
178
+ // Per-statement builders — extracted so `openNeonDb` stays small
179
+ // (max-lines-per-function budget). Each closes over the Neon `sql`
180
+ // callable.
181
+ function buildHeadFor(sql: NeonSql): GetStmt<[string], { id: string }> {
182
+ return getRowStmt(sql, HEAD_FOR_SQL)
183
+ }
184
+
185
+ function buildSeqOfId(sql: NeonSql): GetStmt<[string, string], { seq: number }> {
186
+ return { get: async (tag, id) => {
187
+ const rows = await sql(SEQ_OF_ID_SQL, [tag, id]) as Array<{ seq: number | string }>
188
+ const r = rows[0]
189
+ if (!r) return undefined
190
+ const n = numOrNull(r.seq)
191
+ return n == null ? undefined : { seq: n }
192
+ } }
193
+ }
194
+
195
+ function buildLastKeyframeSeq(sql: NeonSql): GetStmt<[string], { s: number | null }> {
196
+ return { get: async (tag) => {
197
+ const rows = await sql(LAST_KEYFRAME_SEQ_SQL, [tag]) as Array<{ s: number | string | null }>
198
+ const r = rows[0]
199
+ return r ? { s: numOrNull(r.s) } : undefined
200
+ } }
201
+ }
202
+
203
+ function buildChain(sql: NeonSql, query: string): AllStmt<[string], RevisionRow> {
204
+ return { all: async (tag) => {
205
+ const rows = await sql(query, [tag]) as Array<Record<string, unknown>>
206
+ return rows.map(mapRevisionRow)
207
+ } }
208
+ }
209
+
210
+ function buildChainSeq(sql: NeonSql, query: string): AllStmt<[string, number], RevisionRow> {
211
+ return { all: async (tag, seq) => {
212
+ const rows = await sql(query, [tag, seq]) as Array<Record<string, unknown>>
213
+ return rows.map(mapRevisionRow)
214
+ } }
215
+ }
216
+
217
+ function buildRevisionExists(sql: NeonSql): GetStmt<[string, string], unknown> {
218
+ return getRowStmt(sql, REVISION_EXISTS_SQL)
219
+ }
220
+
221
+ // Atomic commit of a single revision (Neon backend). Wraps the
222
+ // dup-check, head-check and gated INSERT in one pipelined
223
+ // transaction — NO commit-time advisory lock. Cross-replica
224
+ // fork-safety rests on two Postgres guarantees:
225
+ //
226
+ // • Single-statement snapshot (READ COMMITTED, the default): the
227
+ // gated INSERT's head-check `(SELECT id … ORDER BY seq DESC
228
+ // LIMIT 1)` and its `COALESCE(MAX(seq),0)+1` seq computation
229
+ // evaluate against ONE snapshot taken at the start of that
230
+ // statement. A racer can't read `head` from one snapshot and
231
+ // `MAX(seq)` from a later one within the same INSERT.
232
+ // • The `UNIQUE(workspace_tag, seq)` PK.
233
+ //
234
+ // Walk the only race that mattered: replica A commits (seq=N+1,
235
+ // base=X). Replica B's gated INSERT runs concurrently. Either B's
236
+ // statement snapshot is BEFORE A's commit — B sees head=X AND
237
+ // MAX(seq)=N, computes seq=N+1, and its INSERT collides with A's row
238
+ // on the PK (recovery → `stale-base`) — or B's snapshot is AFTER A's
239
+ // commit — B sees head=A's-id ≠ X, the `head IS NOT DISTINCT FROM
240
+ // base` gate fails, and B inserts nothing (→ `stale-base`). The
241
+ // forked (seq=N+2, base=X) outcome the old advisory lock guarded
242
+ // against required head and MAX(seq) from DIFFERENT snapshots, which
243
+ // a single statement does not permit. Exactly one replica commits;
244
+ // the loser gets `stale-base`. So the per-tag advisory lock was
245
+ // belt-and-suspenders and is removed.
246
+ //
247
+ // CAVEAT (honesty): this cross-replica argument has NOT been
248
+ // empirically exercised. The PGlite test backend is single-
249
+ // connection, so it can't reproduce two replicas racing on one
250
+ // database — it only confirms the commit-outcome + recovery logic.
251
+ // Before relying on the lockless commit under genuine multi-replica
252
+ // load, add a real-Postgres concurrency test (two pooled
253
+ // connections racing same-base commits) to confirm the snapshot + PK
254
+ // argument holds against the actual server.
255
+ //
256
+ // The gated INSERT-SELECT-WHERE fires only when there's no duplicate
257
+ // id AND the current head matches the proposed base, so `RETURNING
258
+ // seq` rows.length === 1 implies a successful insert. Empty rows mean
259
+ // one of the gates failed; we then look at the dup-check / head-check
260
+ // results to decide between `duplicate` and `stale-base`.
261
+ function tryCommitNeon(sql: NeonSql): (input: RevisionInsert) => Promise<CommitResult> {
262
+ return async ({ tag, id, base, keyframe, nonce, ciphertext, signature }) => {
263
+ const baseNorm = base ?? null
264
+ const keyframeCol = keyframe === true ? 1 : 0
265
+ const createdAt = Date.now()
266
+ let results: unknown[][]
267
+ try {
268
+ results = await sql.transaction([
269
+ // Gated INSERT (shared `$N` builder, Postgres null-safe equality
270
+ // `IS NOT DISTINCT FROM`). `seq` is `COALESCE(MAX(seq),0)+1`; the
271
+ // WHERE re-asserts both gates (no dup AND head IS base) so the
272
+ // INSERT is a no-op when either fails, and a non-empty
273
+ // `RETURNING seq` means "inserted". The null-safe equality
274
+ // matches `base = NULL` on the first revision against the
275
+ // empty-chain head (also NULL); plain `=` would be NULL → false
276
+ // and the first revision would never insert.
277
+ sql(
278
+ GATED_INSERT_SQL_PG,
279
+ [tag, id, baseNorm, keyframeCol, nonce, ciphertext, signature, createdAt],
280
+ ),
281
+ // Discrimination reads, run AFTER the INSERT so they reflect
282
+ // post-INSERT state. With no advisory lock serialising the
283
+ // transaction, running these BEFORE the INSERT (READ COMMITTED
284
+ // takes a fresh snapshot per statement) could miss a duplicate or
285
+ // head-advance that landed concurrently and misclassify a no-op
286
+ // INSERT — e.g. report `stale-base` for what is actually a
287
+ // duplicate retransmit. Read after the INSERT, a no-op's cause is
288
+ // stable: our id present ⇒ duplicate, else the head moved ⇒
289
+ // stale-base. (The INSERT itself is still authoritative — its own
290
+ // single-statement snapshot is what prevents a chain fork.)
291
+ sql(REVISION_EXISTS_SQL, [tag, id]),
292
+ sql(HEAD_FOR_SQL, [tag]),
293
+ ])
294
+ } catch (err) {
295
+ // A unique-violation reaches here when a cross-replica racer (or
296
+ // a direct INSERT from an admin migration / repair script / future
297
+ // code path) landed our computed (workspace_tag, seq) or our
298
+ // (workspace_tag, id) first — the PK / UNIQUE the snapshot
299
+ // argument above relies on doing its job. Mirror the SQLite
300
+ // path's recovery — refetch and route the outcome through
301
+ // `inserted` / `stale-base` — so the originator gets a
302
+ // workspace-state catch-up instead of a raw driver rejection
303
+ // escaping to `handleSave`'s IIFE. Other errors (network, syntax,
304
+ // type mismatch) rethrow.
305
+ if (!isUniqueViolation(err)) throw err
306
+ const dupRows = await sql(REVISION_EXISTS_SQL, [tag, id]) as Array<unknown>
307
+ if (dupRows.length > 0) return { kind: 'inserted' }
308
+ const headRows = await sql(HEAD_FOR_SQL, [tag]) as Array<{ id: string }>
309
+ return { kind: 'stale-base', head: headRows[0]?.id ?? null }
310
+ }
311
+ const insertRows = results[0] as Array<unknown>
312
+ const dupRows = results[1] as Array<unknown>
313
+ const headRows = results[2] as Array<{ id: string }>
314
+ if (insertRows.length > 0) return { kind: 'inserted' }
315
+ if (dupRows.length > 0) return { kind: 'duplicate' }
316
+ return { kind: 'stale-base', head: headRows[0]?.id ?? null }
317
+ }
318
+ }
319
+
320
+ export async function openNeonDb(connectionString: string): Promise<Handle> {
321
+ // Dynamic import so the dep is only required when the Neon path is
322
+ // selected — a SQLite-only deployment never evaluates this. We go
323
+ // through the local `./neon-driver.ts` re-export wrapper rather than
324
+ // the bare specifier so tests can swap the real driver for an
325
+ // in-process Postgres (PGlite) via `mock.module`: that hook can only
326
+ // intercept a specifier it can RESOLVE, and the optional peer dep
327
+ // isn't installed in a SQLite-only checkout. The wrapper path always
328
+ // resolves — see `server/neon-driver.ts`. Cast through `unknown`
329
+ // because the wrapper's `export *` re-exports a `@ts-ignore`'d
330
+ // (possibly-absent) module, so tsc can't see `neon`'s type here.
331
+ const mod = (await import('./neon-driver.ts')) as unknown as { neon: (url: string) => NeonSql }
332
+ const sql: NeonSql = mod.neon(connectionString)
333
+ // Boot-time durability gate — refuses to open against an endpoint
334
+ // configured `synchronous_commit = off` (skips primary WAL fsync,
335
+ // breaking the ack-implies-durable contract). All other levels —
336
+ // `local`, `on`, `remote_write`, `remote_apply` — preserve parity
337
+ // with the SQLite path's `PRAGMA synchronous = FULL`. See
338
+ // DURABLE_SYNC_COMMIT_LEVELS.
339
+ await assertDurableSyncCommit(sql)
340
+ // DDL bootstrap under one pipelined transaction so a transient
341
+ // network failure mid-batch rolls back, AND a transaction-scoped
342
+ // advisory lock so two replicas booting concurrently serialize
343
+ // their DDL (the advisory lock releases at COMMIT; the
344
+ // `IF NOT EXISTS` clauses then make the second runner a no-op).
345
+ await sql.transaction([
346
+ sql(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_REVISION, DDL_LOCK_KEY_REVISION_SUB]),
347
+ ...SCHEMA_PG.map((stmt) => sql(stmt, [])),
348
+ ])
349
+
350
+ const handle: Handle = {
351
+ // `db` (and the SQLite-only `gatedInsert` statement) intentionally
352
+ // unset — Neon has no `DatabaseSync`, and its gated INSERT lives
353
+ // inside `tryCommitNeon`'s pipelined transaction. The Handle type
354
+ // makes both optional precisely for this case.
355
+ headFor: buildHeadFor(sql),
356
+ seqOfId: buildSeqOfId(sql),
357
+ lastKeyframeSeq: buildLastKeyframeSeq(sql),
358
+ chainAll: buildChain(sql, CHAIN_ALL_SQL),
359
+ chainAfterSeq: buildChainSeq(sql, CHAIN_AFTER_SQL),
360
+ chainFromSeq: buildChainSeq(sql, CHAIN_FROM_SQL),
361
+ revisionExists: buildRevisionExists(sql),
362
+ tryCommit: tryCommitNeon(sql),
363
+ // The serverless HTTP client is stateless — no socket to close.
364
+ // Async no-op so shutdown's `handle.close()` works uniformly
365
+ // across backends.
366
+ close: async () => {},
367
+ }
368
+ // No commit-time lock of any kind: `tryCommitNeon`'s single gated
369
+ // INSERT relies on the Postgres single-statement snapshot + the
370
+ // `UNIQUE(workspace_tag, seq)` PK for cross-replica fork-safety
371
+ // (see `tryCommitNeon`), so neither an in-process lock nor a
372
+ // per-tag advisory lock is needed.
373
+ return handle
374
+ }
@@ -0,0 +1,152 @@
1
+ // Shared SQL + row-mapping for the `workspace_revision` chain, used by
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.
7
+ //
8
+ // Single source of truth, in `$N` (Postgres) form:
9
+ // • the read queries (`headFor`, `seqOfId`, `lastKeyframeSeq`, the
10
+ // three `chain*` selects, `revisionExists`),
11
+ // • the gated commit INSERT, exported as two FINISHED per-dialect
12
+ // constants (`GATED_INSERT_SQL_PG` / `GATED_INSERT_SQL_SQLITE`; the
13
+ // only difference is the null-safe equality operator), and
14
+ // • `mapRevisionRow`, the chain-row coercion.
15
+ // SQLite consumers run the strings through `toSqlitePlaceholders` first
16
+ // (`$N` → `?N`); `node:sqlite` supports the numbered `?N` form with reuse
17
+ // (see `updateLiveCAS` in `./objstore/store.ts`).
18
+ //
19
+ // This module deliberately holds NO driver state and imports NO runtime
20
+ // value from `./db.ts` (only the `RevisionRow` TYPE, which is erased), so
21
+ // there is no runtime import cycle: `./db.ts` and `./db-neon.ts` both
22
+ // import runtime values FROM here; the only edge back to `./db.ts` is a
23
+ // type-only import.
24
+
25
+ import type { RevisionRow } from './db.ts'
26
+
27
+ // Postgres BIGINT can round-trip through the Neon driver as a string
28
+ // when the value would lose precision. For our use (per-workspace
29
+ // monotonic seq, epoch ms, byte lengths up to 100 MiB) the JS safe-
30
+ // integer range is fine — coerce to number for parity with the
31
+ // SQLite shape so chain consumers don't need to special-case the
32
+ // backend. Strict: throw on anything that isn't a safe-integer-
33
+ // compatible value. Silently returning 0 / null for unexpected shapes
34
+ // would mask driver-shape changes and let bogus values feed `seq` /
35
+ // `head` / length / version comparisons. `numOrNull`'s null return is
36
+ // reserved for genuine SQL NULL.
37
+ //
38
+ // Defined here (rather than in `./db-neon.ts`) because the shared
39
+ // `mapRevisionRow` below depends on `numOrNull` and this module must not
40
+ // import a runtime value from a backend. `./db-neon.ts` re-exports both
41
+ // so the objstore Neon plane (`./objstore/store-neon.ts`) keeps importing
42
+ // them from there unchanged — all three sites still share one definition.
43
+ export function num(v: unknown): number {
44
+ if (typeof v === 'number' && Number.isSafeInteger(v)) return v
45
+ if (typeof v === 'string' && v.length > 0) {
46
+ const n = Number(v)
47
+ if (Number.isSafeInteger(n)) return n
48
+ }
49
+ if (typeof v === 'bigint' && v >= -9_007_199_254_740_991n && v <= 9_007_199_254_740_991n) {
50
+ return Number(v)
51
+ }
52
+ throw new TypeError(`num: expected safe-integer value, got ${typeof v} ${String(v)}`)
53
+ }
54
+ export function numOrNull(v: unknown): number | null {
55
+ if (v == null) return null
56
+ return num(v)
57
+ }
58
+
59
+ // Chain-row coercion shared by both backends. The Neon driver hands back
60
+ // `Record<string, unknown>` rows whose `keyframe` may be a number OR (on
61
+ // a future driver change) a string; `node:sqlite` hands back native
62
+ // 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.
68
+ export function mapRevisionRow(r: Record<string, unknown>): RevisionRow {
69
+ return {
70
+ base: (r['base'] as string | null) ?? null,
71
+ id: String(r['id']),
72
+ keyframe: numOrNull(r['keyframe']) === 1 ? 1 : 0,
73
+ nonce: String(r['nonce']),
74
+ ciphertext: String(r['ciphertext']),
75
+ signature: String(r['signature']),
76
+ }
77
+ }
78
+
79
+ // `$N` (Postgres) → `?N` (node:sqlite) placeholder rewrite. node:sqlite
80
+ // supports the numbered `?N` form WITH reuse (the same `?N` may appear
81
+ // more than once and binds to one positional param), which is exactly
82
+ // how the `$N` strings reuse e.g. `$1` for the workspace_tag across the
83
+ // gated INSERT's SELECT / NOT EXISTS / head subqueries. None of these
84
+ // queries contain a literal `$` in a string literal, so the bare numeric
85
+ // match is unambiguous.
86
+ export function toSqlitePlaceholders(query: string): string {
87
+ return query.replaceAll(/\$(\d+)/gu, '?$1')
88
+ }
89
+
90
+ // The read queries, in `$N` form. Identical across backends — SQLite runs
91
+ // them through `toSqlitePlaceholders` at prepare time. `revisionExists`
92
+ // aliases `SELECT 1 AS one` so the column name is stable across drivers;
93
+ // callers only test truthiness of the returned row.
94
+ export const HEAD_FOR_SQL =
95
+ `SELECT id FROM workspace_revision WHERE workspace_tag = $1 ORDER BY seq DESC LIMIT 1`
96
+ export const SEQ_OF_ID_SQL =
97
+ `SELECT seq FROM workspace_revision WHERE workspace_tag = $1 AND id = $2`
98
+ export const LAST_KEYFRAME_SEQ_SQL =
99
+ `SELECT MAX(seq) AS s FROM workspace_revision WHERE workspace_tag = $1 AND keyframe = 1`
100
+ export const CHAIN_ALL_SQL =
101
+ `SELECT base, id, keyframe, nonce, ciphertext, signature
102
+ FROM workspace_revision WHERE workspace_tag = $1 ORDER BY seq ASC`
103
+ export const CHAIN_AFTER_SQL =
104
+ `SELECT base, id, keyframe, nonce, ciphertext, signature
105
+ FROM workspace_revision WHERE workspace_tag = $1 AND seq > $2 ORDER BY seq ASC`
106
+ export const CHAIN_FROM_SQL =
107
+ `SELECT base, id, keyframe, nonce, ciphertext, signature
108
+ FROM workspace_revision WHERE workspace_tag = $1 AND seq >= $2 ORDER BY seq ASC`
109
+ export const REVISION_EXISTS_SQL =
110
+ `SELECT 1 AS one FROM workspace_revision WHERE workspace_tag = $1 AND id = $2`
111
+
112
+ // The gated commit INSERT, in `$N` form. One statement folds the
113
+ // dup-check, the head-equals-base check, the server-assigned seq
114
+ // (`COALESCE(MAX(seq),0)+1`) and the INSERT, returning `seq` only when
115
+ // BOTH gates pass — so a non-empty result means "inserted" and an empty
116
+ // result means a gate failed (dup or stale base). The `$N` params are:
117
+ // $1 tag, $2 id, $3 base, $4 keyframe, $5 nonce, $6 ciphertext,
118
+ // $7 signature, $8 created_at
119
+ // `$1`/`$2`/`$3` are reused inside the subqueries.
120
+ //
121
+ // `nullSafeEq` is the dialect's null-safe equality operator between the
122
+ // current head id and the proposed `$3` base: `IS NOT DISTINCT FROM` on
123
+ // Postgres, `IS` on SQLite. It must be NULL-safe so the FIRST revision
124
+ // (base = NULL against an empty-chain head, also NULL) matches —
125
+ // plain `=` would be NULL → false and the first revision would never
126
+ // insert. This operator is the ONLY dialect difference in the statement.
127
+ //
128
+ // NOT exported: it interpolates `nullSafeEq` into the SQL, so exporting
129
+ // it would be a latent SQL-injection vector on accidental misuse (a
130
+ // future caller passing a dynamic / unsanitised value). It is invoked
131
+ // ONLY here, with the two hardcoded operator literals, to build the two
132
+ // finished per-dialect constants below — the interpolation never escapes
133
+ // this module, so callers only ever receive an expected, fixed string.
134
+ function buildGatedInsertSql(nullSafeEq: string): string {
135
+ return `INSERT INTO workspace_revision
136
+ (workspace_tag, seq, id, base, keyframe, nonce, ciphertext, signature, created_at)
137
+ SELECT $1,
138
+ COALESCE((SELECT MAX(seq) FROM workspace_revision WHERE workspace_tag = $1), 0) + 1,
139
+ $2, $3, $4, $5, $6, $7, $8
140
+ WHERE NOT EXISTS (SELECT 1 FROM workspace_revision WHERE workspace_tag = $1 AND id = $2)
141
+ AND (SELECT id FROM workspace_revision WHERE workspace_tag = $1 ORDER BY seq DESC LIMIT 1)
142
+ ${nullSafeEq} $3
143
+ RETURNING seq`
144
+ }
145
+
146
+ // The two finished gated-INSERT statements, one per dialect — built
147
+ // in-file from the hardcoded operators so each backend imports a ready,
148
+ // fixed string and never touches the interpolating builder. Postgres
149
+ // uses the `$N` form directly; SQLite uses the `?N` form (node:sqlite
150
+ // numbered placeholders, with reuse) via `toSqlitePlaceholders`.
151
+ export const GATED_INSERT_SQL_PG = buildGatedInsertSql('IS NOT DISTINCT FROM')
152
+ export const GATED_INSERT_SQL_SQLITE = toSqlitePlaceholders(buildGatedInsertSql('IS'))
@@ -0,0 +1,53 @@
1
+ // Shared async-statement primitives. Both `server/db.ts` (workspace_revision
2
+ // chain) and `server/objstore/store.ts` (objstore tables) expose Handles
3
+ // whose statements look like `{ get(...) → Promise<…>, all(...) → Promise<[…]>,
4
+ // run(...) → Promise<void> }`. The underlying `node:sqlite` driver is
5
+ // synchronous; the wrappers below catch sync errors and route them through
6
+ // the returned Promise. A future async-native backend would implement these
7
+ // same shapes with real I/O.
8
+ //
9
+ // Error-propagation contract: every wrapper returns a Promise. If the
10
+ // underlying sync driver throws (constraint violation, type bind error,
11
+ // closed DB, …), the wrapper converts the throw into a `Promise.reject`.
12
+ // Callers `.catch()` / `Promise.allSettled` / `await` uniformly without
13
+ // having to wrap each call in `try`.
14
+
15
+ import { type StatementSync } from 'node:sqlite'
16
+
17
+ export type GetStmt<P extends unknown[], T> = { get: (...args: P) => Promise<T | undefined> }
18
+ export type AllStmt<P extends unknown[], T> = { all: (...args: P) => Promise<T[]> }
19
+ export type RunStmt<P extends unknown[]> = { run: (...args: P) => Promise<void> }
20
+
21
+ // `StatementSync` types parameters as `SQLInputValue` (a narrow union).
22
+ // Callers pass our generic `P extends unknown[]`; widen via `unknown` so
23
+ // the spread compiles. Call-site types enforce the right shape — the
24
+ // driver validates parameter types at bind time, so a wrong type fails
25
+ // loud at the SQLite layer, surfaced as a rejection.
26
+ type AnyStmt = {
27
+ get: (...args: unknown[]) => unknown
28
+ all: (...args: unknown[]) => unknown[]
29
+ run: (...args: unknown[]) => unknown
30
+ }
31
+ function asAny(stmt: StatementSync): AnyStmt { return stmt as unknown as AnyStmt }
32
+
33
+ // Each wrapper is an `async` function with no internal `await` — the
34
+ // `async` keyword is what guarantees a sync throw from the driver
35
+ // surfaces as a Promise rejection rather than escaping the wrapper.
36
+ // `require-await` warns on async-without-await, but here it is the
37
+ // whole point: the lint disable is the intent.
38
+ export function wrapGet<P extends unknown[], T>(stmt: StatementSync): GetStmt<P, T> {
39
+ const s = asAny(stmt)
40
+ // eslint-disable-next-line require-await
41
+ return { get: async (...args: P) => s.get(...args) as T | undefined }
42
+ }
43
+ export function wrapAll<P extends unknown[], T>(stmt: StatementSync): AllStmt<P, T> {
44
+ const s = asAny(stmt)
45
+ // eslint-disable-next-line require-await
46
+ return { all: async (...args: P) => s.all(...args) as T[] }
47
+ }
48
+ export function wrapRun<P extends unknown[]>(stmt: StatementSync): RunStmt<P> {
49
+ const s = asAny(stmt)
50
+ // eslint-disable-next-line require-await
51
+ return { run: async (...args: P) => { s.run(...args) } }
52
+ }
53
+