@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.
- package/LICENSE +21 -0
- package/common/save-error-reason.ts +53 -0
- package/common/utf8.d.ts +13 -0
- package/common/utf8.js +57 -0
- package/out/brotli-fallback.js +3 -0
- package/out/client-sync.js +15 -0
- package/out/graph.js +4 -0
- package/out/icon-maskable.svg +5 -0
- package/out/icon.svg +5 -0
- package/out/index.html +78 -0
- package/out/manifest.webmanifest +30 -0
- package/out/prism.js +14 -0
- package/out/terminal.js +39 -0
- package/out/view.css +1 -0
- package/out/view.html +12 -0
- package/out/view.js +138 -0
- package/package.json +129 -0
- package/server/auth.ts +99 -0
- package/server/config.example.json +3 -0
- package/server/config.ts +196 -0
- package/server/db-neon.ts +374 -0
- package/server/db-revision-sql.ts +152 -0
- package/server/db-stmt.ts +53 -0
- package/server/db.ts +577 -0
- package/server/http.ts +142 -0
- package/server/hub.ts +98 -0
- package/server/index.ts +353 -0
- package/server/lifecycle.ts +177 -0
- package/server/neon-driver.ts +26 -0
- package/server/objstore/blob-fs.ts +164 -0
- package/server/objstore/blob-vercel.ts +508 -0
- package/server/objstore/blob.ts +169 -0
- package/server/objstore/fs.ts +67 -0
- package/server/objstore/handlers.ts +235 -0
- package/server/objstore/init.ts +118 -0
- package/server/objstore/reaper.ts +199 -0
- package/server/objstore/rest.ts +484 -0
- package/server/objstore/sign.ts +164 -0
- package/server/objstore/store-neon.ts +351 -0
- package/server/objstore/store.ts +799 -0
- package/server/objstore/tokens.ts +168 -0
- package/server/origin.ts +68 -0
- package/server/peer.ts +38 -0
- package/server/sign.ts +231 -0
- package/server/static.ts +374 -0
- package/server/sync-handlers.ts +311 -0
- package/server/util.ts +27 -0
- package/server/validation.ts +36 -0
- 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
|
+
}
|