@preventive/triage 1.0.0-alpha.1 → 1.0.0-alpha.3
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/out/client-sync.js +11 -11
- package/out/graph.js +3 -3
- package/out/index.html +3 -15
- package/out/view.css +1 -1
- package/out/view.js +51 -48
- package/package.json +11 -4
- package/server/bus-receiver.ts +7 -7
- package/server/cli.js +22 -0
- package/server/config.ts +4 -4
- package/server/db-neon.ts +29 -23
- package/server/db-revision-sql.ts +6 -9
- package/server/db.ts +95 -134
- package/server/hub.ts +4 -5
- package/server/index.ts +42 -27
- package/server/lifecycle.ts +6 -1
- package/server/objstore/blob-fs.ts +4 -6
- package/server/objstore/blob-vercel.ts +66 -33
- package/server/objstore/blob.ts +16 -8
- package/server/objstore/handlers.ts +11 -13
- package/server/objstore/init.ts +18 -9
- package/server/objstore/rest.ts +60 -68
- package/server/objstore/store-neon.ts +16 -16
- package/server/objstore/store.ts +92 -112
- package/server/objstore/tokens.ts +9 -12
- package/server/peer.ts +7 -9
- package/server/pubsub.ts +17 -27
- package/server/sign.ts +8 -10
- package/server/sse-server.ts +10 -12
- package/server/sse-session.ts +2 -8
- package/server/static.ts +8 -12
- package/server/sync-handlers.ts +32 -39
- package/server/ws-server.ts +9 -14
- package/strip-types-loader.js +94 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.3",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -28,12 +28,18 @@
|
|
|
28
28
|
"#client/sync.js": "./client/sync.js",
|
|
29
29
|
"#client/sync-host.js": "./client/sync-host.js"
|
|
30
30
|
},
|
|
31
|
+
"exports": {
|
|
32
|
+
"./server": "./server/index.ts",
|
|
33
|
+
"./strip-types-loader": "./strip-types-loader.js",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
31
36
|
"bin": {
|
|
32
|
-
"triage-server": "server/
|
|
37
|
+
"triage-server": "server/cli.js"
|
|
33
38
|
},
|
|
34
39
|
"files": [
|
|
35
40
|
"server/auth.ts",
|
|
36
41
|
"server/bus-receiver.ts",
|
|
42
|
+
"server/cli.js",
|
|
37
43
|
"server/config.ts",
|
|
38
44
|
"server/db-neon.ts",
|
|
39
45
|
"server/db-revision-sql.ts",
|
|
@@ -83,7 +89,8 @@
|
|
|
83
89
|
"out/terminal.js",
|
|
84
90
|
"out/view.css",
|
|
85
91
|
"out/view.html",
|
|
86
|
-
"out/view.js"
|
|
92
|
+
"out/view.js",
|
|
93
|
+
"strip-types-loader.js"
|
|
87
94
|
],
|
|
88
95
|
"scripts": {
|
|
89
96
|
"lint": "node --run lint:js && node --run lint:css && node --run lint:ts",
|
|
@@ -100,7 +107,7 @@
|
|
|
100
107
|
"@electric-sql/pglite": "^0.4.5",
|
|
101
108
|
"@exodus/stasis": "^1.0.0-alpha.1",
|
|
102
109
|
"@noble/ciphers": "^2.2.0",
|
|
103
|
-
"@preventive/terminal": "^1.
|
|
110
|
+
"@preventive/terminal": "^1.3.0",
|
|
104
111
|
"@rray/frontend": "^1.0.0",
|
|
105
112
|
"@stylistic/stylelint-plugin": "^5.1.0",
|
|
106
113
|
"@types/node": "^25.6.2",
|
package/server/bus-receiver.ts
CHANGED
|
@@ -69,13 +69,13 @@ export function createBusReceiver(deps: BusReceiverDeps): (msg: BusMessage) => P
|
|
|
69
69
|
if (debug) console.warn(`pubsub: objput ${msg.res.slice(0, 8)}… missing for ${msg.tag.slice(0, 12)}…`)
|
|
70
70
|
return
|
|
71
71
|
}
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
72
|
+
// `getLive` returns the CURRENT live row, which may be a strictly
|
|
73
|
+
// NEWER version than the NOTIFY referred to (two closely-spaced
|
|
74
|
+
// puts on one resourceTag arrive with the DB already showing v2
|
|
75
|
+
// for both). Broadcasting v2 twice is sound — client `putHandlers`
|
|
76
|
+
// already absorb same-instance PUT echoes (rest.ts uses
|
|
77
|
+
// `except: null`), so handlers must be idempotent on
|
|
78
|
+
// (resourceTag, version) anyway.
|
|
79
79
|
broadcastLocalRaw(msg.tag, JSON.stringify({
|
|
80
80
|
type: 'objstore-put',
|
|
81
81
|
workspaceTag: msg.tag,
|
package/server/cli.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Executable entry for the `triage-server` bin.
|
|
3
|
+
//
|
|
4
|
+
// Node refuses to strip TypeScript types from files under node_modules
|
|
5
|
+
// (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so once this package is
|
|
6
|
+
// installed as a dependency, server/index.ts can't be executed directly.
|
|
7
|
+
// This launcher registers the built-in-type-stripping hook FIRST
|
|
8
|
+
// (synchronous, in-thread — see ../strip-types-loader.js), then loads the
|
|
9
|
+
// server with the hook active and starts it.
|
|
10
|
+
//
|
|
11
|
+
// The `await import()` is load-bearing: a static `import './index.ts'`
|
|
12
|
+
// would be fetched during this module's instantiation — before the body
|
|
13
|
+
// runs and before the hook is registered — and would hit the node_modules
|
|
14
|
+
// strip error. Deferring to a dynamic import runs it only after the static
|
|
15
|
+
// `../strip-types-loader.js` import has evaluated and registered the hook.
|
|
16
|
+
//
|
|
17
|
+
// Because the server is imported here (not the process entry), its
|
|
18
|
+
// `import.meta.main` auto-start gate stays off, so we call the exported
|
|
19
|
+
// `start()` ourselves.
|
|
20
|
+
import '../strip-types-loader.js'
|
|
21
|
+
const { start } = await import('./index.ts')
|
|
22
|
+
start()
|
package/server/config.ts
CHANGED
|
@@ -156,8 +156,8 @@ export function loadConfig(): Config {
|
|
|
156
156
|
// 0 = OS-assigned ephemeral port (the test harness boots with PORT=0).
|
|
157
157
|
const port = intEnv('PORT', 8765, 0, 65535)
|
|
158
158
|
const host = env['HOST'] ?? '127.0.0.1'
|
|
159
|
-
// `fileURLToPath` decodes percent-escapes / non-ASCII path segments
|
|
160
|
-
//
|
|
159
|
+
// `fileURLToPath` decodes percent-escapes / non-ASCII path segments;
|
|
160
|
+
// `new URL(...).pathname` would leave `%20` raw.
|
|
161
161
|
const dbPath = env['DB_PATH'] ?? fileURLToPath(new URL('./data/data.db', import.meta.url))
|
|
162
162
|
// `path.join` so a Windows DB_PATH doesn't get a mixed-separator child.
|
|
163
163
|
const objstoreDir = env['OBJSTORE_DIR'] ?? join(dirname(dbPath), 'objstore')
|
|
@@ -174,8 +174,8 @@ export function loadConfig(): Config {
|
|
|
174
174
|
const password = rawPassword ?? null
|
|
175
175
|
// Upper bound 65_536 — bounds memory under hostile load; a deployer
|
|
176
176
|
// passing MAX_SAFE_INTEGER would silently defeat the cap. Validated
|
|
177
|
-
// here
|
|
178
|
-
//
|
|
177
|
+
// here, after the config.json / password parse, to keep the
|
|
178
|
+
// error-precedence order.
|
|
179
179
|
const maxInflightPerSocket = intEnv('MAX_INFLIGHT_PER_SOCKET', 64, 1, 65_536)
|
|
180
180
|
|
|
181
181
|
if (argv.includes('--help') || argv.includes('-h')) {
|
package/server/db-neon.ts
CHANGED
|
@@ -10,9 +10,14 @@
|
|
|
10
10
|
// (`autoInstallPeers: false` in `pnpm-workspace.yaml`) and never
|
|
11
11
|
// reaches the import.
|
|
12
12
|
//
|
|
13
|
-
// API shape: `neon(connectionString)` returns a tagged-template
|
|
14
|
-
// callable
|
|
15
|
-
// `sql(text, params)
|
|
13
|
+
// API shape: `neon(connectionString)` returns a tagged-template
|
|
14
|
+
// callable with a `.query(text, params)` value-placeholder method.
|
|
15
|
+
// We use the `sql.query(text, params)` form everywhere — our queries
|
|
16
|
+
// are dynamically composed strings + parameter arrays from
|
|
17
|
+
// `./db-revision-sql.ts`, so the tagged-template form doesn't fit.
|
|
18
|
+
// The function-call form (`sql(text, params)`) that the 0.x driver
|
|
19
|
+
// accepted was removed in `@neondatabase/serverless@1.0.0`; the peer
|
|
20
|
+
// dep declares `^1.0.2`. `tryCommitNeon` (below) folds the dup-check
|
|
16
21
|
// + head-check + gated INSERT into a single pipelined
|
|
17
22
|
// `sql.transaction([...])` — NO commit-time advisory lock. The gated
|
|
18
23
|
// INSERT's head-check and `MAX(seq)` run inside one Postgres
|
|
@@ -71,15 +76,16 @@ export { num, numOrNull }
|
|
|
71
76
|
// side. Used by the DDL bootstrap below to prevent partial schema
|
|
72
77
|
// creation on a transient mid-batch network failure.
|
|
73
78
|
//
|
|
74
|
-
// `transaction` is typed against the query promise
|
|
79
|
+
// `transaction` is typed against the query promise `sql.query`
|
|
75
80
|
// returns (NOT plain `Promise<unknown>`) so a misuse like
|
|
76
81
|
// `sql.transaction([Promise.resolve(123)])` fails at compile time
|
|
77
82
|
// rather than failing opaquely inside the driver. The driver itself
|
|
78
83
|
// inspects each promise's internal shape and rejects non-query
|
|
79
84
|
// promises at runtime; the type narrows that to a static error.
|
|
80
|
-
type NeonQueryPromise = ReturnType<
|
|
81
|
-
type
|
|
82
|
-
export type NeonSql =
|
|
85
|
+
type NeonQueryPromise = ReturnType<NeonQueryCall>
|
|
86
|
+
type NeonQueryCall = (queryText: string, params?: readonly unknown[]) => Promise<unknown[]>
|
|
87
|
+
export type NeonSql = {
|
|
88
|
+
query: NeonQueryCall
|
|
83
89
|
transaction: (queries: NeonQueryPromise[]) => Promise<unknown[][]>
|
|
84
90
|
}
|
|
85
91
|
|
|
@@ -104,7 +110,7 @@ const DDL_LOCK_KEY_REVISION_SUB = 0x7273_6e72 // 'rsnr'
|
|
|
104
110
|
const DURABLE_SYNC_COMMIT_LEVELS = new Set(['local', 'on', 'remote_write', 'remote_apply'])
|
|
105
111
|
|
|
106
112
|
export async function assertDurableSyncCommit(sql: NeonSql): Promise<void> {
|
|
107
|
-
const rows = await sql(`SHOW synchronous_commit`, []) as Array<{ synchronous_commit?: unknown }>
|
|
113
|
+
const rows = await sql.query(`SHOW synchronous_commit`, []) as Array<{ synchronous_commit?: unknown }>
|
|
108
114
|
const level = String(rows[0]?.synchronous_commit ?? '').trim().toLowerCase()
|
|
109
115
|
if (!DURABLE_SYNC_COMMIT_LEVELS.has(level)) {
|
|
110
116
|
throw new Error(
|
|
@@ -157,7 +163,7 @@ const SCHEMA_PG = [
|
|
|
157
163
|
|
|
158
164
|
// Generic statement-builder helpers shared by both Neon planes
|
|
159
165
|
// (workspace_revision here + the objstore tables in store-neon.ts).
|
|
160
|
-
// They remove the repeated `{ run/get: async (...args) => sql(...) }`
|
|
166
|
+
// They remove the repeated `{ run/get: async (...args) => sql.query(...) }`
|
|
161
167
|
// wrapper for the statements whose positional args map straight to
|
|
162
168
|
// the query's `$1..$N` placeholders and whose rows need no coercion.
|
|
163
169
|
// Statements that coerce BIGINT (via `num`) or map snake_case rows
|
|
@@ -166,14 +172,14 @@ const SCHEMA_PG = [
|
|
|
166
172
|
|
|
167
173
|
// Trivial passthrough write: args → $1..$N in order, no result shape.
|
|
168
174
|
export function runStmt<P extends unknown[]>(sql: NeonSql, query: string): RunStmt<P> {
|
|
169
|
-
return { run: async (...args: P) => { await sql(query, args as readonly unknown[]) } }
|
|
175
|
+
return { run: async (...args: P) => { await sql.query(query, args as readonly unknown[]) } }
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
// First-row read with no coercion (callers needing BIGINT→number or
|
|
173
179
|
// snake_case mapping build their own). Returns `undefined` on the
|
|
174
180
|
// empty result set, matching the SQLite `wrapGet` contract.
|
|
175
181
|
export function getRowStmt<P extends unknown[], T>(sql: NeonSql, query: string): GetStmt<P, T> {
|
|
176
|
-
return { get: async (...args: P) => (await sql(query, args as readonly unknown[]) as T[])[0] }
|
|
182
|
+
return { get: async (...args: P) => (await sql.query(query, args as readonly unknown[]) as T[])[0] }
|
|
177
183
|
}
|
|
178
184
|
|
|
179
185
|
// Per-statement builders — extracted so `openNeonDb` stays small
|
|
@@ -185,7 +191,7 @@ function buildHeadFor(sql: NeonSql): GetStmt<[string], { id: string }> {
|
|
|
185
191
|
|
|
186
192
|
function buildSeqOfId(sql: NeonSql): GetStmt<[string, string], { seq: number }> {
|
|
187
193
|
return { get: async (tag, id) => {
|
|
188
|
-
const rows = await sql(SEQ_OF_ID_SQL, [tag, id]) as Array<{ seq: number | string }>
|
|
194
|
+
const rows = await sql.query(SEQ_OF_ID_SQL, [tag, id]) as Array<{ seq: number | string }>
|
|
189
195
|
const r = rows[0]
|
|
190
196
|
if (!r) return undefined
|
|
191
197
|
const n = numOrNull(r.seq)
|
|
@@ -195,7 +201,7 @@ function buildSeqOfId(sql: NeonSql): GetStmt<[string, string], { seq: number }>
|
|
|
195
201
|
|
|
196
202
|
function buildLastKeyframeSeq(sql: NeonSql): GetStmt<[string], { s: number | null }> {
|
|
197
203
|
return { get: async (tag) => {
|
|
198
|
-
const rows = await sql(LAST_KEYFRAME_SEQ_SQL, [tag]) as Array<{ s: number | string | null }>
|
|
204
|
+
const rows = await sql.query(LAST_KEYFRAME_SEQ_SQL, [tag]) as Array<{ s: number | string | null }>
|
|
199
205
|
const r = rows[0]
|
|
200
206
|
return r ? { s: numOrNull(r.s) } : undefined
|
|
201
207
|
} }
|
|
@@ -203,14 +209,14 @@ function buildLastKeyframeSeq(sql: NeonSql): GetStmt<[string], { s: number | nul
|
|
|
203
209
|
|
|
204
210
|
function buildChain(sql: NeonSql, query: string): AllStmt<[string], RevisionRow> {
|
|
205
211
|
return { all: async (tag) => {
|
|
206
|
-
const rows = await sql(query, [tag]) as Array<Record<string, unknown>>
|
|
212
|
+
const rows = await sql.query(query, [tag]) as Array<Record<string, unknown>>
|
|
207
213
|
return rows.map(mapRevisionRow)
|
|
208
214
|
} }
|
|
209
215
|
}
|
|
210
216
|
|
|
211
217
|
function buildChainSeq(sql: NeonSql, query: string): AllStmt<[string, number], RevisionRow> {
|
|
212
218
|
return { all: async (tag, seq) => {
|
|
213
|
-
const rows = await sql(query, [tag, seq]) as Array<Record<string, unknown>>
|
|
219
|
+
const rows = await sql.query(query, [tag, seq]) as Array<Record<string, unknown>>
|
|
214
220
|
return rows.map(mapRevisionRow)
|
|
215
221
|
} }
|
|
216
222
|
}
|
|
@@ -221,7 +227,7 @@ function buildRevisionExists(sql: NeonSql): GetStmt<[string, string], unknown> {
|
|
|
221
227
|
|
|
222
228
|
function buildRevisionById(sql: NeonSql): GetStmt<[string, string], RevisionRow> {
|
|
223
229
|
return { get: async (tag, id) => {
|
|
224
|
-
const rows = await sql(REVISION_BY_ID_SQL, [tag, id]) as Array<Record<string, unknown>>
|
|
230
|
+
const rows = await sql.query(REVISION_BY_ID_SQL, [tag, id]) as Array<Record<string, unknown>>
|
|
225
231
|
const r = rows[0]
|
|
226
232
|
return r ? mapRevisionRow(r) : undefined
|
|
227
233
|
} }
|
|
@@ -283,7 +289,7 @@ function tryCommitNeon(sql: NeonSql): (input: RevisionInsert) => Promise<CommitR
|
|
|
283
289
|
// matches `base = NULL` on the first revision against the
|
|
284
290
|
// empty-chain head (also NULL); plain `=` would be NULL → false
|
|
285
291
|
// and the first revision would never insert.
|
|
286
|
-
sql(
|
|
292
|
+
sql.query(
|
|
287
293
|
GATED_INSERT_SQL_PG,
|
|
288
294
|
[tag, id, baseNorm, keyframeCol, nonce, ciphertext, signature, createdAt],
|
|
289
295
|
),
|
|
@@ -297,8 +303,8 @@ function tryCommitNeon(sql: NeonSql): (input: RevisionInsert) => Promise<CommitR
|
|
|
297
303
|
// stable: our id present ⇒ duplicate, else the head moved ⇒
|
|
298
304
|
// stale-base. (The INSERT itself is still authoritative — its own
|
|
299
305
|
// single-statement snapshot is what prevents a chain fork.)
|
|
300
|
-
sql(REVISION_EXISTS_SQL, [tag, id]),
|
|
301
|
-
sql(HEAD_FOR_SQL, [tag]),
|
|
306
|
+
sql.query(REVISION_EXISTS_SQL, [tag, id]),
|
|
307
|
+
sql.query(HEAD_FOR_SQL, [tag]),
|
|
302
308
|
])
|
|
303
309
|
} catch (err) {
|
|
304
310
|
// A unique-violation reaches here when a cross-replica racer (or
|
|
@@ -312,9 +318,9 @@ function tryCommitNeon(sql: NeonSql): (input: RevisionInsert) => Promise<CommitR
|
|
|
312
318
|
// escaping to `handleSave`'s IIFE. Other errors (network, syntax,
|
|
313
319
|
// type mismatch) rethrow.
|
|
314
320
|
if (!isUniqueViolation(err)) throw err
|
|
315
|
-
const dupRows = await sql(REVISION_EXISTS_SQL, [tag, id]) as Array<unknown>
|
|
321
|
+
const dupRows = await sql.query(REVISION_EXISTS_SQL, [tag, id]) as Array<unknown>
|
|
316
322
|
if (dupRows.length > 0) return { kind: 'inserted' }
|
|
317
|
-
const headRows = await sql(HEAD_FOR_SQL, [tag]) as Array<{ id: string }>
|
|
323
|
+
const headRows = await sql.query(HEAD_FOR_SQL, [tag]) as Array<{ id: string }>
|
|
318
324
|
return { kind: 'stale-base', head: headRows[0]?.id ?? null }
|
|
319
325
|
}
|
|
320
326
|
const insertRows = results[0] as Array<unknown>
|
|
@@ -352,8 +358,8 @@ export async function openNeonDb(connectionString: string): Promise<Handle> {
|
|
|
352
358
|
// their DDL (the advisory lock releases at COMMIT; the
|
|
353
359
|
// `IF NOT EXISTS` clauses then make the second runner a no-op).
|
|
354
360
|
await sql.transaction([
|
|
355
|
-
sql(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_REVISION, DDL_LOCK_KEY_REVISION_SUB]),
|
|
356
|
-
...SCHEMA_PG.map((stmt) => sql(stmt, [])),
|
|
361
|
+
sql.query(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_REVISION, DDL_LOCK_KEY_REVISION_SUB]),
|
|
362
|
+
...SCHEMA_PG.map((stmt) => sql.query(stmt, [])),
|
|
357
363
|
])
|
|
358
364
|
|
|
359
365
|
const handle: Handle = {
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// Shared SQL + row-mapping for the `workspace_revision` chain, used by
|
|
2
2
|
// BOTH backends — `./db.ts` (SQLite) and `./db-neon.ts` (Neon/Postgres).
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// duplication is collapsed here so a query edit can't silently drift
|
|
6
|
-
// between backends.
|
|
3
|
+
// Single source of truth (modulo `?`↔`$N` placeholders) so a query edit
|
|
4
|
+
// can't silently drift between backends.
|
|
7
5
|
//
|
|
8
6
|
// Single source of truth, in `$N` (Postgres) form:
|
|
9
7
|
// • the read queries (`headFor`, `seqOfId`, `lastKeyframeSeq`, the
|
|
@@ -60,11 +58,10 @@ export function numOrNull(v: unknown): number | null {
|
|
|
60
58
|
// `Record<string, unknown>` rows whose `keyframe` may be a number OR (on
|
|
61
59
|
// a future driver change) a string; `node:sqlite` hands back native
|
|
62
60
|
// numbers. The `num`/`numOrNull` coercion is safe over both — a native
|
|
63
|
-
// `0`/`1` integer passes through unchanged,
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
// 0 / 1 via the `=== 1` check the chain-broadcast contract relies on.
|
|
61
|
+
// `0`/`1` integer passes through unchanged, while Neon rows keep their
|
|
62
|
+
// defensive string→number coercion. `base` is the only nullable column
|
|
63
|
+
// (first revision); `keyframe` collapses to a strict 0 / 1 via the
|
|
64
|
+
// `=== 1` check the chain-broadcast contract relies on.
|
|
68
65
|
export function mapRevisionRow(r: Record<string, unknown>): RevisionRow {
|
|
69
66
|
return {
|
|
70
67
|
base: (r['base'] as string | null) ?? null,
|
package/server/db.ts
CHANGED
|
@@ -10,49 +10,30 @@
|
|
|
10
10
|
// `base` points at the previous revision's `id` (or null for the
|
|
11
11
|
// first revision in a workspace).
|
|
12
12
|
//
|
|
13
|
-
// `keyframe` is `1` for a revision the client emits with
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// `
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// reaches this column. Client-driven: the server only stores what
|
|
22
|
-
// the client sent and treats keyframes as catch-up roots when a
|
|
23
|
-
// from=null subscriber arrives.
|
|
13
|
+
// `keyframe` is `1` for a revision the client emits with full state
|
|
14
|
+
// baked in (rather than a delta). The wire flag is covered by the
|
|
15
|
+
// signature, so the column value MUST match the signed canonical
|
|
16
|
+
// bytes: `canonicalSave` (server/sign.ts, via `handleSave`) encodes
|
|
17
|
+
// `keyframe ? '1' : ''` into the bytes `verifyEd25519` checks, so a
|
|
18
|
+
// mismatched wire flag fails verify and never reaches this column.
|
|
19
|
+
// Client-driven: the server stores what the client sent and treats
|
|
20
|
+
// keyframes as catch-up roots when a from=null subscriber arrives.
|
|
24
21
|
//
|
|
25
22
|
// `node:sqlite` is the built-in driver (Node ≥ 22 experimental,
|
|
26
|
-
// stable in 24+)
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// off a sync `node:sqlite` call.
|
|
23
|
+
// stable in 24+), synchronous under the hood; the Handle wraps each
|
|
24
|
+
// prepared statement so call sites `await` uniformly — async-ready
|
|
25
|
+
// surface for a future async DB backend (every op resolves in the
|
|
26
|
+
// current microtask off a sync call).
|
|
31
27
|
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// can interleave mid-statement, and the head-check + MAX(seq) read
|
|
42
|
-
// from ONE consistent snapshot. That single-snapshot property is
|
|
43
|
-
// what makes a per-tag lock redundant: the lock formerly existed
|
|
44
|
-
// only to stop a chain fork where a racer read `head` from one
|
|
45
|
-
// snapshot but `MAX(seq)` from a LATER one (after a sibling
|
|
46
|
-
// committed) and inserted (seq=N+2, base=X) alongside the winner's
|
|
47
|
-
// (seq=N+1, base=X) — same base, different seq, no PK conflict. With
|
|
48
|
-
// both reads inside one statement that interleaving is impossible:
|
|
49
|
-
// a racer's snapshot is either before the winner's commit (→ same
|
|
50
|
-
// seq=N+1 → the UNIQUE(workspace_tag, seq) PK rejects the second →
|
|
51
|
-
// recovery → stale-base) or after it (→ head ≠ base → no insert →
|
|
52
|
-
// stale-base). Exactly one commits; the loser gets stale-base.
|
|
53
|
-
// SQLite also serialises writers internally, and the PK backstops
|
|
54
|
-
// the unsupported multi-connection case. See `commitRevisionSqlite`
|
|
55
|
-
// for the full fork-safety argument.
|
|
28
|
+
// Operations being async, two handlers can interleave across an
|
|
29
|
+
// `await`. `commitRevision` (below) takes NO in-process lock — it
|
|
30
|
+
// folds the dup recheck, base-equality check, MAX(seq) and INSERT
|
|
31
|
+
// into ONE gated INSERT (`commitRevisionSqlite`). `node:sqlite` runs
|
|
32
|
+
// that statement to completion without yielding, so its head-check +
|
|
33
|
+
// MAX(seq) read ONE snapshot, which is what makes a per-tag lock
|
|
34
|
+
// redundant. SQLite also serialises writers internally, and the PK
|
|
35
|
+
// backstops the unsupported multi-connection case. See
|
|
36
|
+
// `commitRevisionSqlite` for the full fork-safety argument.
|
|
56
37
|
|
|
57
38
|
import { DatabaseSync } from 'node:sqlite'
|
|
58
39
|
import { mkdirSync } from 'node:fs'
|
|
@@ -64,24 +45,20 @@ import {
|
|
|
64
45
|
mapRevisionRow, toSqlitePlaceholders,
|
|
65
46
|
} from './db-revision-sql.ts'
|
|
66
47
|
|
|
67
|
-
// `CHECK (keyframe IN (0, 1))` is the value-domain guard
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// 0
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// column TYPE. The CHECK closes the value-domain half, giving SQLite
|
|
77
|
-
// the protection the Neon schema's identical `CHECK (keyframe IN
|
|
78
|
-
// (0, 1))` carries (see `db-neon.ts`).
|
|
48
|
+
// `CHECK (keyframe IN (0, 1))` is the value-domain guard. STRICT
|
|
49
|
+
// (the table marker) enforces the column TYPE (an INTEGER stays an
|
|
50
|
+
// INTEGER) but NOT its value range: `keyframe = 2` is a valid integer
|
|
51
|
+
// STRICT accepts, which `mapRevisionRow`'s `=== 1` check then coerces
|
|
52
|
+
// back to 0. That divergence from the signed canonical (only ever
|
|
53
|
+
// 0 / 1) poisons chain-replay verifies for any recomputing peer — the
|
|
54
|
+
// same operator-with-direct-DB-write vector the STRICT guard in
|
|
55
|
+
// `openDbInner` catches for TYPE. The CHECK closes the value-domain
|
|
56
|
+
// half, matching the Neon schema's identical CHECK (see `db-neon.ts`).
|
|
79
57
|
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// one, and a future column edit can't drift the two apart.
|
|
58
|
+
// Parenthesised column + constraint body (plus STRICT marker), shared
|
|
59
|
+
// by the initial `CREATE TABLE` and the `migrateAddKeyframeCheck`
|
|
60
|
+
// rebuild below — so a rebuilt table is byte-identical in shape to a
|
|
61
|
+
// fresh one and a future column edit can't drift the two apart.
|
|
85
62
|
const WORKSPACE_REVISION_DEF = `(
|
|
86
63
|
workspace_tag TEXT NOT NULL,
|
|
87
64
|
seq INTEGER NOT NULL,
|
|
@@ -105,10 +82,10 @@ const SCHEMA = `
|
|
|
105
82
|
${WORKSPACE_REVISION_TAG_ID_INDEX};
|
|
106
83
|
`
|
|
107
84
|
|
|
108
|
-
// Row shape
|
|
109
|
-
//
|
|
110
|
-
// to a strict boolean before broadcasting
|
|
111
|
-
//
|
|
85
|
+
// Row shape from the chain queries. `keyframe` is stored as INTEGER
|
|
86
|
+
// (0 / 1); the raw row carries the integer — `chainForWire` in
|
|
87
|
+
// server/index.ts normalises to a strict boolean before broadcasting.
|
|
88
|
+
// `base` is nullable on the very first revision.
|
|
112
89
|
export type RevisionRow = {
|
|
113
90
|
base: string | null
|
|
114
91
|
id: string
|
|
@@ -119,9 +96,8 @@ export type RevisionRow = {
|
|
|
119
96
|
}
|
|
120
97
|
|
|
121
98
|
// Input to `commitRevision`. `keyframe` is a strict boolean here —
|
|
122
|
-
// the canonical-payload contract uses `=== true
|
|
123
|
-
//
|
|
124
|
-
// STRICT INTEGER column.
|
|
99
|
+
// the canonical-payload contract uses `=== true`; the storage path
|
|
100
|
+
// coerces to 0 / 1 before hitting the STRICT INTEGER column.
|
|
125
101
|
export type RevisionInsert = {
|
|
126
102
|
tag: string
|
|
127
103
|
id: string
|
|
@@ -142,37 +118,31 @@ export type CommitResult =
|
|
|
142
118
|
| { kind: 'duplicate' }
|
|
143
119
|
| { kind: 'stale-base'; head: string | null }
|
|
144
120
|
|
|
145
|
-
//
|
|
146
|
-
//
|
|
121
|
+
// Pre-prepared statements + the underlying connection, held for the
|
|
122
|
+
// process lifetime; `close()` runs from `shutdown()`.
|
|
147
123
|
//
|
|
148
|
-
// `db` is the raw `DatabaseSync
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
124
|
+
// `db` is the raw `DatabaseSync`, SQLite-only — the Neon backend
|
|
125
|
+
// (`./db-neon.ts`) constructs a Handle with `db` unset. Callers that
|
|
126
|
+
// reach into `db` directly (e.g. `openObjstore`, test-only fixture
|
|
127
|
+
// SQL) are SQLite-coupled by construction; passing them a Neon-backed
|
|
128
|
+
// Handle is the operator's mistake to catch at the `if (DATABASE_URL)`
|
|
129
|
+
// switch in `server/index.ts`.
|
|
154
130
|
//
|
|
155
|
-
// `tryCommit` is the backend-specific atomic-commit primitive
|
|
131
|
+
// `tryCommit` is the backend-specific atomic-commit primitive
|
|
156
132
|
// `commitRevision` dispatches through. SQLite runs one synchronous
|
|
157
|
-
// gated INSERT (
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// READ-COMMITTED single-statement snapshot (the gated INSERT's
|
|
162
|
-
// head-check and MAX(seq) read one snapshot) plus the
|
|
163
|
-
// `UNIQUE(workspace_tag, seq)` PK to keep cross-replica racers from
|
|
164
|
-
// forking the chain — see `db-neon.ts`'s `tryCommitNeon`.
|
|
133
|
+
// gated INSERT (see `commitRevisionSqlite`); Neon wraps it in a
|
|
134
|
+
// pipelined transaction (see `db-neon.ts`'s `tryCommitNeon`). Both
|
|
135
|
+
// rely on a single-statement snapshot + the `UNIQUE(workspace_tag,
|
|
136
|
+
// seq)` PK for fork-safety; see those functions for the argument.
|
|
165
137
|
//
|
|
166
138
|
// `gatedInsert` is SQLite-only (like `db`): it backs
|
|
167
|
-
// `commitRevisionSqlite`'s single gated INSERT
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
// unique-violation / non-unique failure into the commit, the same
|
|
175
|
-
// recovery paths the Neon suite stages via `failNextCommit`.
|
|
139
|
+
// `commitRevisionSqlite`'s single gated INSERT. The Neon backend
|
|
140
|
+
// leaves it unset — its gated INSERT lives inside the pipelined
|
|
141
|
+
// `sql.transaction([...])`, not a standalone statement object. Kept
|
|
142
|
+
// on the Handle (not a module-private closure) so SQLite white-box
|
|
143
|
+
// tests can wrap `.get` to inject a unique-violation / non-unique
|
|
144
|
+
// failure into the commit, exercising the same recovery paths the
|
|
145
|
+
// Neon suite stages via `failNextCommit`.
|
|
176
146
|
export type Handle = {
|
|
177
147
|
db?: DatabaseSync
|
|
178
148
|
headFor: GetStmt<[string], { id: string }>
|
|
@@ -192,23 +162,22 @@ export type Handle = {
|
|
|
192
162
|
}
|
|
193
163
|
|
|
194
164
|
// Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
|
|
195
|
-
//
|
|
165
|
+
// set. `openDb` returns this so call sites needing direct
|
|
196
166
|
// `DatabaseSync` access (e.g. `openObjstore(handle.db, …)` in
|
|
197
|
-
// `server/index.ts`'s SQLite branch)
|
|
198
|
-
//
|
|
199
|
-
// (`openNeonDb`) keeps the wider `db?: DatabaseSync` shape
|
|
200
|
-
//
|
|
201
|
-
//
|
|
167
|
+
// `server/index.ts`'s SQLite branch) reach `handle.db` without an
|
|
168
|
+
// optional-chain or non-null assertion. A Neon-backed Handle
|
|
169
|
+
// (`openNeonDb`) keeps the wider `db?: DatabaseSync` shape, so routing
|
|
170
|
+
// one into a SQLite-coupled call site is a compile-time error. Mirrors
|
|
171
|
+
// `server/objstore/store.ts`.
|
|
202
172
|
export type SqliteHandle = Handle & { db: DatabaseSync }
|
|
203
173
|
|
|
204
174
|
export function openDb(path: string): SqliteHandle {
|
|
205
175
|
mkdirSync(dirname(path), { recursive: true })
|
|
206
176
|
const db = new DatabaseSync(path)
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
// …) and re-run without a stale lock pinning the file.
|
|
177
|
+
// A throw between the DatabaseSync constructor and the return would
|
|
178
|
+
// leak the file / WAL / shm locks until process exit — close before
|
|
179
|
+
// re-raising so the operator can fix the cause (failed STRICT check,
|
|
180
|
+
// ALTER TABLE error, …) and re-run without a stale lock on the file.
|
|
212
181
|
try {
|
|
213
182
|
return openDbInner(db)
|
|
214
183
|
} catch (err) {
|
|
@@ -218,35 +187,30 @@ export function openDb(path: string): SqliteHandle {
|
|
|
218
187
|
}
|
|
219
188
|
|
|
220
189
|
function openDbInner(db: DatabaseSync): SqliteHandle {
|
|
221
|
-
// WAL gives concurrent readers + faster writes and survives
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
// tables later without revisiting init.
|
|
190
|
+
// WAL gives concurrent readers + faster writes and survives crashes
|
|
191
|
+
// between commits without corrupting the file. Foreign keys aren't
|
|
192
|
+
// needed here (single-table schema) but turning them on keeps the
|
|
193
|
+
// option to add referential tables later without revisiting init.
|
|
226
194
|
db.exec('PRAGMA journal_mode = WAL;')
|
|
227
195
|
// FULL (not NORMAL): the server emits `workspace-save-ack` BEFORE
|
|
228
|
-
// returning to the event loop after `commitRevision`.
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
// for the protocol's edit-driven write pattern (triage edits, not
|
|
235
|
-
// streaming throughput). Audit round-9 M1.
|
|
196
|
+
// returning to the event loop after `commitRevision`. NORMAL only
|
|
197
|
+
// fsyncs at WAL checkpoint, so a power loss between ack and the next
|
|
198
|
+
// checkpoint loses a row the originator + peers were told committed.
|
|
199
|
+
// FULL fsyncs per commit, matching the durability the ack implies.
|
|
200
|
+
// Trade-off is per-commit fsync latency, acceptable for the edit-
|
|
201
|
+
// driven write pattern (triage edits, not streaming). Audit round-9 M1.
|
|
236
202
|
db.exec('PRAGMA synchronous = FULL;')
|
|
237
203
|
db.exec('PRAGMA foreign_keys = ON;')
|
|
238
204
|
db.exec(SCHEMA)
|
|
239
205
|
// Fail-loud on a pre-existing non-STRICT table — `CREATE TABLE IF
|
|
240
|
-
// NOT EXISTS … STRICT` is a no-op when the table
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
// making every subsequent verify fail. Operator must migrate
|
|
249
|
-
// before this server boots.
|
|
206
|
+
// NOT EXISTS … STRICT` is a no-op when the table exists, so a
|
|
207
|
+
// deployment predating the STRICT marker keeps its non-STRICT shape.
|
|
208
|
+
// Without STRICT, an operator with direct DB write access could
|
|
209
|
+
// insert mis-typed rows (e.g. `keyframe = "1\nfoo"` text in the
|
|
210
|
+
// INTEGER column) and poison the chain: the signed canonical says
|
|
211
|
+
// `keyframe = 1`, but the stored text round-trips into the canonical
|
|
212
|
+
// as a different string, failing every subsequent verify. Operator
|
|
213
|
+
// must migrate before this server boots.
|
|
250
214
|
const meta = db.prepare(
|
|
251
215
|
`SELECT strict FROM pragma_table_list WHERE schema = 'main' AND name = 'workspace_revision'`,
|
|
252
216
|
).get() as { strict: number } | undefined
|
|
@@ -254,13 +218,11 @@ function openDbInner(db: DatabaseSync): SqliteHandle {
|
|
|
254
218
|
throw new Error('workspace_revision is non-STRICT — migrate via rename+create+copy before booting')
|
|
255
219
|
}
|
|
256
220
|
// Idempotent migration for DBs created before the keyframe column
|
|
257
|
-
// existed. Inspect the column list rather than
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// the ALTER itself bubbles up as an open-time crash where the
|
|
263
|
-
// operator can act on it.
|
|
221
|
+
// existed. Inspect the column list rather than `try { ALTER } catch
|
|
222
|
+
// {}`: a blanket catch swallows ANY failure (lock contention, disk
|
|
223
|
+
// full, corrupt page) as "column already exists". ALTER only when
|
|
224
|
+
// the column is genuinely missing, so an ALTER failure bubbles up as
|
|
225
|
+
// an open-time crash the operator can act on.
|
|
264
226
|
const columns = db.prepare(`PRAGMA table_info(workspace_revision)`).all() as Array<{ name: string }>
|
|
265
227
|
if (!columns.some((c) => c.name === 'keyframe')) {
|
|
266
228
|
// ADD COLUMN carries the CHECK so a legacy DB migrating up lands
|
|
@@ -507,9 +469,8 @@ export function commitRevision(handle: Handle, input: RevisionInsert): Promise<C
|
|
|
507
469
|
// base gate fails → `stale-base`.
|
|
508
470
|
// • Two retransmits with the same id: the second's dup gate fails →
|
|
509
471
|
// `duplicate`.
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
// chainFrom-during-commits) which now pass with no lock present.
|
|
472
|
+
// Covered by the no-fork concurrency tests in `tests/server-db.test.js`
|
|
473
|
+
// (two/N concurrent same-base, mixed, chainFrom-during-commits).
|
|
513
474
|
//
|
|
514
475
|
// SQLite serialises writers internally even ACROSS connections, but a
|
|
515
476
|
// multi-connection deployment is unsupported regardless. The
|