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