@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.4
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 +1 -1
- package/out/view.js +52 -49
- package/package.json +2 -2
- package/server/bus-receiver.ts +7 -7
- package/server/config.ts +4 -4
- package/server/db-revision-sql.ts +6 -9
- package/server/db.ts +95 -134
- package/server/hub.ts +4 -5
- package/server/index.ts +22 -26
- package/server/objstore/blob-fs.ts +6 -8
- package/server/objstore/blob-vercel.ts +56 -44
- package/server/objstore/blob.ts +23 -8
- package/server/objstore/handlers.ts +11 -13
- package/server/objstore/init.ts +18 -9
- package/server/objstore/reaper.ts +30 -10
- package/server/objstore/rest.ts +91 -75
- package/server/objstore/store-neon.ts +4 -4
- 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/util.ts +9 -0
- package/server/ws-server.ts +9 -14
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.4",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
"@electric-sql/pglite": "^0.4.5",
|
|
108
108
|
"@exodus/stasis": "^1.0.0-alpha.1",
|
|
109
109
|
"@noble/ciphers": "^2.2.0",
|
|
110
|
-
"@preventive/terminal": "^1.
|
|
110
|
+
"@preventive/terminal": "^1.3.0",
|
|
111
111
|
"@rray/frontend": "^1.0.0",
|
|
112
112
|
"@stylistic/stylelint-plugin": "^5.1.0",
|
|
113
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/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')) {
|
|
@@ -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
|
package/server/hub.ts
CHANGED
|
@@ -102,12 +102,11 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
function fanOut(set: Set<WebSocket>, payload: string, except: WebSocket | null): void {
|
|
105
|
-
// Snapshot before iterating —
|
|
106
|
-
// socket.send errors, but a socket transitioning to CLOSED
|
|
105
|
+
// Snapshot before iterating — a socket transitioning to CLOSED
|
|
107
106
|
// mid-broadcast triggers `unsubscribeAll` from the 'close' handler,
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
107
|
+
// mutating `set` while we walk it. The snapshot also keeps a future
|
|
108
|
+
// refactor (different collection, async send) from silently skipping
|
|
109
|
+
// subscribers. Audit M4 round-3.
|
|
111
110
|
for (const s of [...set]) {
|
|
112
111
|
if (s === except) continue
|
|
113
112
|
sendRaw(s, payload)
|
package/server/index.ts
CHANGED
|
@@ -97,8 +97,8 @@ import {
|
|
|
97
97
|
import { createBusReceiver } from './bus-receiver.ts'
|
|
98
98
|
|
|
99
99
|
// All external inputs (env vars + optional config.json) are parsed
|
|
100
|
-
// and validated in ./config.ts
|
|
101
|
-
//
|
|
100
|
+
// and validated in ./config.ts; destructure into the uppercase names
|
|
101
|
+
// the rest of this module uses.
|
|
102
102
|
const config = loadConfig()
|
|
103
103
|
const {
|
|
104
104
|
port: PORT, host: HOST, dbPath: DB_PATH, objstoreDir: OBJSTORE_DIR,
|
|
@@ -109,8 +109,8 @@ const {
|
|
|
109
109
|
} = config
|
|
110
110
|
|
|
111
111
|
// Same-origin gate for the WS upgrade and REST data plane (see
|
|
112
|
-
// ./origin.ts). `TRUST_PROXY_ENV`
|
|
113
|
-
//
|
|
112
|
+
// ./origin.ts). `TRUST_PROXY_ENV` also feeds the boot-time
|
|
113
|
+
// misconfiguration fail-fast below.
|
|
114
114
|
const { trustProxy: TRUST_PROXY, isOriginAllowed } = createOriginGate(HOST, TRUST_PROXY_ENV)
|
|
115
115
|
|
|
116
116
|
// Per-socket buffered-bytes cap. `socket.send` returns synchronously
|
|
@@ -131,10 +131,9 @@ const MAX_BUFFERED_BYTES = 16 * 1024 * 1024
|
|
|
131
131
|
|
|
132
132
|
// Per-connection state registry. One `Peer` per accepted socket holds
|
|
133
133
|
// the challenge nonce, auth flag, heartbeat liveness, in-flight count,
|
|
134
|
-
// and subscribed tags (see ./peer.ts)
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
// via `peers.get(socket)`.
|
|
134
|
+
// and subscribed tags (see ./peer.ts). The connection handler holds
|
|
135
|
+
// the Peer in a closure for the hot paths; cross-function call sites
|
|
136
|
+
// resolve it via `peers.get(socket)`.
|
|
138
137
|
const peers: PeerRegistry = new WeakMap()
|
|
139
138
|
|
|
140
139
|
// REST PUT idle-body timeout. A slow-loris client trickling bytes
|
|
@@ -163,8 +162,8 @@ const REST_PUT_IDLE_TIMEOUT_MS = 30_000
|
|
|
163
162
|
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
164
163
|
|
|
165
164
|
// Backend selection. Both planes (workspace_revision DB + the
|
|
166
|
-
// v1.objstore byte store) are picked from config at boot. Two
|
|
167
|
-
// pairings:
|
|
165
|
+
// v1.objstore byte store) are picked from config at boot. Two
|
|
166
|
+
// supported pairings:
|
|
168
167
|
// 1. DATABASE_URL set → Neon (workspace_revision + objstore
|
|
169
168
|
// tables) + Vercel Blob Private Storage (bytes). Requires
|
|
170
169
|
// BLOB_READ_WRITE_TOKEN — fail fast at boot if missing, since
|
|
@@ -174,11 +173,10 @@ const HEARTBEAT_INTERVAL_MS = 30_000
|
|
|
174
173
|
// process; the only pairing the SQLite plane supports.
|
|
175
174
|
// The Neon / Vercel files import their peer deps lazily inside the
|
|
176
175
|
// open functions, so static imports here are safe even on a SQLite-
|
|
177
|
-
// only install where the optional peer deps aren't present.
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
// without a non-null assertion.
|
|
176
|
+
// only install where the optional peer deps aren't present. Explicit
|
|
177
|
+
// branch (not a ternary) so the SQLite path keeps its `SqliteHandle`
|
|
178
|
+
// narrowing — `sqliteHandle.db` is a non-optional `DatabaseSync` that
|
|
179
|
+
// `openObjstore` accepts without a non-null assertion.
|
|
182
180
|
let handle: Handle
|
|
183
181
|
let objstoreHandle: ObjstoreHandle
|
|
184
182
|
let objstoreBanner: string
|
|
@@ -238,8 +236,7 @@ async function workspaceExists(tag: string): Promise<boolean> {
|
|
|
238
236
|
}
|
|
239
237
|
|
|
240
238
|
// WS fan-out hub: subscriber registry + backpressure-aware send /
|
|
241
|
-
// broadcast (see ./hub.ts).
|
|
242
|
-
// handlers / dispatcher / objstore wiring below read unchanged.
|
|
239
|
+
// broadcast (see ./hub.ts).
|
|
243
240
|
const hub = createHub({ peers, maxBufferedBytes: MAX_BUFFERED_BYTES, debug: DEBUG })
|
|
244
241
|
const { send, broadcast, subscribe, unsubscribeAll, broadcastLocalRaw } = hub
|
|
245
242
|
|
|
@@ -276,7 +273,7 @@ if (NEON_URL) {
|
|
|
276
273
|
}
|
|
277
274
|
|
|
278
275
|
// Password gate (see ./auth.ts) — HMAC derivation + the `authenticate`
|
|
279
|
-
// handshake.
|
|
276
|
+
// handshake.
|
|
280
277
|
const auth = createAuth({ peers, password: CONFIG_PASSWORD, send, debug: DEBUG })
|
|
281
278
|
const { requiresAuth, handleAuthenticate, sendUnauthorized } = auth
|
|
282
279
|
|
|
@@ -320,8 +317,8 @@ const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper
|
|
|
320
317
|
sendUnauthorized,
|
|
321
318
|
// `tokenSecret` is set only when OBJSTORE_TOKEN_SECRET was
|
|
322
319
|
// provided in env (see TOKEN_SECRET resolution above). Omitted
|
|
323
|
-
// → initObjstore mints a fresh per-process secret (
|
|
324
|
-
//
|
|
320
|
+
// → initObjstore mints a fresh per-process secret (fine for
|
|
321
|
+
// single-replica).
|
|
325
322
|
...(TOKEN_SECRET ? { tokenSecret: TOKEN_SECRET } : {}),
|
|
326
323
|
})
|
|
327
324
|
|
|
@@ -458,12 +455,11 @@ installLifecycle({
|
|
|
458
455
|
// needed here.
|
|
459
456
|
await startupReap
|
|
460
457
|
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
// the import resolves).
|
|
458
|
+
// Bind the HTTP/WS plane on the configured PORT/HOST. Exported so a
|
|
459
|
+
// launcher (server/cli.js — the triage-server bin) or any `import`er can
|
|
460
|
+
// start serving. The top-level `await startupReap` above means the server
|
|
461
|
+
// is fully ready — DB open, DDL bootstrapped, objstore reaper swept — by
|
|
462
|
+
// the time the import resolves.
|
|
467
463
|
export function start(): void {
|
|
468
464
|
httpServer.listen(PORT, HOST)
|
|
469
465
|
}
|
|
@@ -48,13 +48,13 @@ async function fsOpenLiveReader(dir: string, tag: string, contentHash: string):
|
|
|
48
48
|
const path = liveFilePath(dir, tag, contentHash)
|
|
49
49
|
let fh
|
|
50
50
|
try { fh = await open(path, 'r') } catch (err: unknown) {
|
|
51
|
-
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, reason: 'unavailable' }
|
|
51
|
+
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, reason: 'unavailable', detail: 'fs-enoent' }
|
|
52
52
|
throw err
|
|
53
53
|
}
|
|
54
54
|
let size: number
|
|
55
55
|
try { size = (await fh.stat()).size } catch {
|
|
56
56
|
await fh.close().catch(() => {})
|
|
57
|
-
return { ok: false, reason: 'unavailable' }
|
|
57
|
+
return { ok: false, reason: 'unavailable', detail: 'fs-stat-failed' }
|
|
58
58
|
}
|
|
59
59
|
const stream = fh.createReadStream()
|
|
60
60
|
let closed = false
|
|
@@ -90,12 +90,10 @@ export function openFsBlobBackend(dir: string): BlobBackend {
|
|
|
90
90
|
// closed by Node's stream machinery on 'finish'.
|
|
91
91
|
finalize: async () => {},
|
|
92
92
|
// `destroy(err)` synchronously starts tearing the stream
|
|
93
|
-
// down; the WriteStream emits 'close' on the next tick.
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// the no-await-in-async — the function signature is
|
|
98
|
-
// dictated by the BlobBackend contract.
|
|
93
|
+
// down; the WriteStream emits 'close' on the next tick. No
|
|
94
|
+
// remote upload to wait for on FS, so we resolve immediately
|
|
95
|
+
// — the REST layer awaits but doesn't block on anything real.
|
|
96
|
+
// Async signature is dictated by the BlobBackend contract.
|
|
99
97
|
// eslint-disable-next-line require-await
|
|
100
98
|
abort: async (err) => { writable.destroy(err as Error) },
|
|
101
99
|
}
|