@preventive/triage 1.0.0-alpha.0 → 1.0.0-alpha.10
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/api/reap.ts +79 -0
- package/common/save-error-reason.ts +20 -7
- package/common/server-info.ts +30 -0
- package/out/brotli-fallback.js +1 -1
- package/out/client-admin.js +28 -0
- package/out/client-managed.js +1 -0
- package/out/client-sync.js +17 -10
- package/out/graph.js +5 -4
- package/out/index.html +43 -38
- package/out/prism.js +2 -2
- package/out/terminal.js +32 -28
- package/out/view.css +1 -1
- package/out/view.js +78 -51
- package/package.json +70 -49
- package/{server → server-common}/origin.ts +5 -5
- package/{server → server-e2e}/auth.ts +16 -1
- package/server-e2e/bus-receiver.ts +95 -0
- package/server-e2e/cli.js +22 -0
- package/{server → server-e2e}/config.ts +21 -8
- package/{server → server-e2e}/db-neon.ts +41 -25
- package/{server → server-e2e}/db-revision-sql.ts +15 -9
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +113 -135
- package/server-e2e/http.ts +266 -0
- package/{server → server-e2e}/hub.ts +27 -8
- package/{server → server-e2e}/index.ts +185 -52
- package/{server → server-e2e}/lifecycle.ts +36 -5
- package/server-e2e/npm-proxy.ts +348 -0
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +25 -15
- package/{server → server-e2e}/objstore/init.ts +52 -12
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +119 -84
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +19 -19
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/server-e2e/pubsub.ts +394 -0
- package/{server → server-e2e}/sign.ts +12 -14
- package/server-e2e/sse-server.ts +384 -0
- package/server-e2e/sse-session.ts +216 -0
- package/{server → server-e2e}/static.ts +22 -17
- package/server-e2e/sync-handlers.ts +382 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/server-e2e/ws-server.ts +276 -0
- package/strip-types-loader.js +94 -0
- package/server/http.ts +0 -142
- package/server/sync-handlers.ts +0 -311
- package/server/ws-server.ts +0 -245
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/neon-driver.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -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,
|
|
@@ -108,6 +105,15 @@ export const CHAIN_FROM_SQL =
|
|
|
108
105
|
FROM workspace_revision WHERE workspace_tag = $1 AND seq >= $2 ORDER BY seq ASC`
|
|
109
106
|
export const REVISION_EXISTS_SQL =
|
|
110
107
|
`SELECT 1 AS one FROM workspace_revision WHERE workspace_tag = $1 AND id = $2`
|
|
108
|
+
// Fetch a single revision row by content-addressed id. The cross-instance
|
|
109
|
+
// pubsub (server-e2e/pubsub.ts) NOTIFY payload carries only `(tag, revisionId)`
|
|
110
|
+
// because the full `workspace-state` broadcast envelope is bounded by
|
|
111
|
+
// `MAX_CIPHERTEXT_LEN` (2 MiB) and Postgres NOTIFY caps payloads at ~8 KB.
|
|
112
|
+
// The receiver re-fetches the row from this shared table to construct the
|
|
113
|
+
// wire broadcast for its local peers.
|
|
114
|
+
export const REVISION_BY_ID_SQL =
|
|
115
|
+
`SELECT base, id, keyframe, nonce, ciphertext, signature
|
|
116
|
+
FROM workspace_revision WHERE workspace_tag = $1 AND id = $2`
|
|
111
117
|
|
|
112
118
|
// The gated commit INSERT, in `$N` form. One statement folds the
|
|
113
119
|
// dup-check, the head-equals-base check, the server-assigned seq
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Shared async-statement primitives. Both `server/db.ts` (workspace_revision
|
|
2
|
-
// chain) and `server/objstore/store.ts` (objstore tables) expose Handles
|
|
1
|
+
// Shared async-statement primitives. Both `server-e2e/db.ts` (workspace_revision
|
|
2
|
+
// chain) and `server-e2e/objstore/store.ts` (objstore tables) expose Handles
|
|
3
3
|
// whose statements look like `{ get(...) → Promise<…>, all(...) → Promise<[…]>,
|
|
4
4
|
// run(...) → Promise<void> }`. The underlying `node:sqlite` driver is
|
|
5
5
|
// synchronous; the wrappers below catch sync errors and route them through
|
|
@@ -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-e2e/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'
|
|
@@ -60,27 +41,24 @@ import { dirname } from 'node:path'
|
|
|
60
41
|
import { type AllStmt, type GetStmt, wrapAll, wrapGet } from './db-stmt.ts'
|
|
61
42
|
import {
|
|
62
43
|
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,
|
|
44
|
+
LAST_KEYFRAME_SEQ_SQL, REVISION_BY_ID_SQL, REVISION_EXISTS_SQL, SEQ_OF_ID_SQL,
|
|
45
|
+
mapRevisionRow, toSqlitePlaceholders,
|
|
64
46
|
} from './db-revision-sql.ts'
|
|
65
47
|
|
|
66
|
-
// `CHECK (keyframe IN (0, 1))` is the value-domain guard
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// 0
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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`).
|
|
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`).
|
|
78
57
|
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// 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.
|
|
84
62
|
const WORKSPACE_REVISION_DEF = `(
|
|
85
63
|
workspace_tag TEXT NOT NULL,
|
|
86
64
|
seq INTEGER NOT NULL,
|
|
@@ -104,10 +82,10 @@ const SCHEMA = `
|
|
|
104
82
|
${WORKSPACE_REVISION_TAG_ID_INDEX};
|
|
105
83
|
`
|
|
106
84
|
|
|
107
|
-
// Row shape
|
|
108
|
-
//
|
|
109
|
-
// to a strict boolean before broadcasting
|
|
110
|
-
//
|
|
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-e2e/index.ts normalises to a strict boolean before broadcasting.
|
|
88
|
+
// `base` is nullable on the very first revision.
|
|
111
89
|
export type RevisionRow = {
|
|
112
90
|
base: string | null
|
|
113
91
|
id: string
|
|
@@ -118,9 +96,8 @@ export type RevisionRow = {
|
|
|
118
96
|
}
|
|
119
97
|
|
|
120
98
|
// Input to `commitRevision`. `keyframe` is a strict boolean here —
|
|
121
|
-
// the canonical-payload contract uses `=== true
|
|
122
|
-
//
|
|
123
|
-
// STRICT INTEGER column.
|
|
99
|
+
// the canonical-payload contract uses `=== true`; the storage path
|
|
100
|
+
// coerces to 0 / 1 before hitting the STRICT INTEGER column.
|
|
124
101
|
export type RevisionInsert = {
|
|
125
102
|
tag: string
|
|
126
103
|
id: string
|
|
@@ -141,37 +118,31 @@ export type CommitResult =
|
|
|
141
118
|
| { kind: 'duplicate' }
|
|
142
119
|
| { kind: 'stale-base'; head: string | null }
|
|
143
120
|
|
|
144
|
-
//
|
|
145
|
-
//
|
|
121
|
+
// Pre-prepared statements + the underlying connection, held for the
|
|
122
|
+
// process lifetime; `close()` runs from `shutdown()`.
|
|
146
123
|
//
|
|
147
|
-
// `db` is the raw `DatabaseSync
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
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-e2e/index.ts`.
|
|
153
130
|
//
|
|
154
|
-
// `tryCommit` is the backend-specific atomic-commit primitive
|
|
131
|
+
// `tryCommit` is the backend-specific atomic-commit primitive
|
|
155
132
|
// `commitRevision` dispatches through. SQLite runs one synchronous
|
|
156
|
-
// gated INSERT (
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
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`.
|
|
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.
|
|
164
137
|
//
|
|
165
138
|
// `gatedInsert` is SQLite-only (like `db`): it backs
|
|
166
|
-
// `commitRevisionSqlite`'s single gated INSERT
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
// unique-violation / non-unique failure into the commit, the same
|
|
174
|
-
// 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`.
|
|
175
146
|
export type Handle = {
|
|
176
147
|
db?: DatabaseSync
|
|
177
148
|
headFor: GetStmt<[string], { id: string }>
|
|
@@ -181,29 +152,32 @@ export type Handle = {
|
|
|
181
152
|
chainAfterSeq: AllStmt<[string, number], RevisionRow>
|
|
182
153
|
chainFromSeq: AllStmt<[string, number], RevisionRow>
|
|
183
154
|
revisionExists: GetStmt<[string, string], unknown>
|
|
155
|
+
// Single-revision fetch by content-addressed id. The cross-instance
|
|
156
|
+
// pubsub receiver uses this to assemble a `workspace-state` from a
|
|
157
|
+
// NOTIFY hint (see `server-e2e/pubsub.ts`).
|
|
158
|
+
revisionById: GetStmt<[string, string], RevisionRow>
|
|
184
159
|
gatedInsert?: GetStmt<[string, string, string | null, number, string, string, string, number], { seq: number }>
|
|
185
160
|
tryCommit: (input: RevisionInsert) => Promise<CommitResult>
|
|
186
161
|
close: () => Promise<void>
|
|
187
162
|
}
|
|
188
163
|
|
|
189
164
|
// Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
|
|
190
|
-
//
|
|
165
|
+
// set. `openDb` returns this so call sites needing direct
|
|
191
166
|
// `DatabaseSync` access (e.g. `openObjstore(handle.db, …)` in
|
|
192
|
-
// `server/index.ts`'s SQLite branch)
|
|
193
|
-
//
|
|
194
|
-
// (`openNeonDb`) keeps the wider `db?: DatabaseSync` shape
|
|
195
|
-
//
|
|
196
|
-
//
|
|
167
|
+
// `server-e2e/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-e2e/objstore/store.ts`.
|
|
197
172
|
export type SqliteHandle = Handle & { db: DatabaseSync }
|
|
198
173
|
|
|
199
174
|
export function openDb(path: string): SqliteHandle {
|
|
200
175
|
mkdirSync(dirname(path), { recursive: true })
|
|
201
176
|
const db = new DatabaseSync(path)
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// …) 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.
|
|
207
181
|
try {
|
|
208
182
|
return openDbInner(db)
|
|
209
183
|
} catch (err) {
|
|
@@ -213,35 +187,30 @@ export function openDb(path: string): SqliteHandle {
|
|
|
213
187
|
}
|
|
214
188
|
|
|
215
189
|
function openDbInner(db: DatabaseSync): SqliteHandle {
|
|
216
|
-
// WAL gives concurrent readers + faster writes and survives
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
// 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.
|
|
221
194
|
db.exec('PRAGMA journal_mode = WAL;')
|
|
222
195
|
// FULL (not NORMAL): the server emits `workspace-save-ack` BEFORE
|
|
223
|
-
// returning to the event loop after `commitRevision`.
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// for the protocol's edit-driven write pattern (triage edits, not
|
|
230
|
-
// 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.
|
|
231
202
|
db.exec('PRAGMA synchronous = FULL;')
|
|
232
203
|
db.exec('PRAGMA foreign_keys = ON;')
|
|
233
204
|
db.exec(SCHEMA)
|
|
234
205
|
// Fail-loud on a pre-existing non-STRICT table — `CREATE TABLE IF
|
|
235
|
-
// NOT EXISTS … STRICT` is a no-op when the table
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
// making every subsequent verify fail. Operator must migrate
|
|
244
|
-
// 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.
|
|
245
214
|
const meta = db.prepare(
|
|
246
215
|
`SELECT strict FROM pragma_table_list WHERE schema = 'main' AND name = 'workspace_revision'`,
|
|
247
216
|
).get() as { strict: number } | undefined
|
|
@@ -249,13 +218,11 @@ function openDbInner(db: DatabaseSync): SqliteHandle {
|
|
|
249
218
|
throw new Error('workspace_revision is non-STRICT — migrate via rename+create+copy before booting')
|
|
250
219
|
}
|
|
251
220
|
// Idempotent migration for DBs created before the keyframe column
|
|
252
|
-
// existed. Inspect the column list rather than
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
// the ALTER itself bubbles up as an open-time crash where the
|
|
258
|
-
// 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.
|
|
259
226
|
const columns = db.prepare(`PRAGMA table_info(workspace_revision)`).all() as Array<{ name: string }>
|
|
260
227
|
if (!columns.some((c) => c.name === 'keyframe')) {
|
|
261
228
|
// ADD COLUMN carries the CHECK so a legacy DB migrating up lands
|
|
@@ -288,6 +255,17 @@ function openDbInner(db: DatabaseSync): SqliteHandle {
|
|
|
288
255
|
const raw = wrapAll<P, Record<string, unknown>>(db.prepare(toSqlitePlaceholders(query)))
|
|
289
256
|
return { all: async (...args: P) => (await raw.all(...args)).map(mapRevisionRow) }
|
|
290
257
|
}
|
|
258
|
+
// Reuses `wrapGet` for the prepared statement, then maps the row
|
|
259
|
+
// through the shared coercion so the returned shape matches `chainFrom`.
|
|
260
|
+
const revisionByIdStmt: GetStmt<[string, string], RevisionRow> = (() => {
|
|
261
|
+
const raw = wrapGet<[string, string], Record<string, unknown>>(
|
|
262
|
+
db.prepare(toSqlitePlaceholders(REVISION_BY_ID_SQL)),
|
|
263
|
+
)
|
|
264
|
+
return { get: async (tag, id) => {
|
|
265
|
+
const row = await raw.get(tag, id)
|
|
266
|
+
return row ? mapRevisionRow(row) : undefined
|
|
267
|
+
} }
|
|
268
|
+
})()
|
|
291
269
|
const handle: SqliteHandle = {
|
|
292
270
|
db,
|
|
293
271
|
headFor: wrapGet<[string], { id: string }>(db.prepare(toSqlitePlaceholders(HEAD_FOR_SQL))),
|
|
@@ -297,6 +275,7 @@ function openDbInner(db: DatabaseSync): SqliteHandle {
|
|
|
297
275
|
chainAfterSeq: chainStmt<[string, number]>(CHAIN_AFTER_SQL),
|
|
298
276
|
chainFromSeq: chainStmt<[string, number]>(CHAIN_FROM_SQL),
|
|
299
277
|
revisionExists: wrapGet<[string, string], unknown>(db.prepare(toSqlitePlaceholders(REVISION_EXISTS_SQL))),
|
|
278
|
+
revisionById: revisionByIdStmt,
|
|
300
279
|
// SQLite null-safe equality is `IS`; the numbered `?N` form (with
|
|
301
280
|
// reuse) maps `$1`/`$2`/`$3` to repeated positional binds. `RETURNING
|
|
302
281
|
// seq` works in node:sqlite (see objstore's `insertLiveIfAbsent`).
|
|
@@ -490,9 +469,8 @@ export function commitRevision(handle: Handle, input: RevisionInsert): Promise<C
|
|
|
490
469
|
// base gate fails → `stale-base`.
|
|
491
470
|
// • Two retransmits with the same id: the second's dup gate fails →
|
|
492
471
|
// `duplicate`.
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
// 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).
|
|
496
474
|
//
|
|
497
475
|
// SQLite serialises writers internally even ACROSS connections, but a
|
|
498
476
|
// multi-connection deployment is unsupported regardless. The
|