@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/common/save-error-reason.ts +53 -0
  3. package/common/utf8.d.ts +13 -0
  4. package/common/utf8.js +57 -0
  5. package/out/brotli-fallback.js +3 -0
  6. package/out/client-sync.js +15 -0
  7. package/out/graph.js +4 -0
  8. package/out/icon-maskable.svg +5 -0
  9. package/out/icon.svg +5 -0
  10. package/out/index.html +78 -0
  11. package/out/manifest.webmanifest +30 -0
  12. package/out/prism.js +14 -0
  13. package/out/terminal.js +39 -0
  14. package/out/view.css +1 -0
  15. package/out/view.html +12 -0
  16. package/out/view.js +138 -0
  17. package/package.json +129 -0
  18. package/server/auth.ts +99 -0
  19. package/server/config.example.json +3 -0
  20. package/server/config.ts +196 -0
  21. package/server/db-neon.ts +374 -0
  22. package/server/db-revision-sql.ts +152 -0
  23. package/server/db-stmt.ts +53 -0
  24. package/server/db.ts +577 -0
  25. package/server/http.ts +142 -0
  26. package/server/hub.ts +98 -0
  27. package/server/index.ts +353 -0
  28. package/server/lifecycle.ts +177 -0
  29. package/server/neon-driver.ts +26 -0
  30. package/server/objstore/blob-fs.ts +164 -0
  31. package/server/objstore/blob-vercel.ts +508 -0
  32. package/server/objstore/blob.ts +169 -0
  33. package/server/objstore/fs.ts +67 -0
  34. package/server/objstore/handlers.ts +235 -0
  35. package/server/objstore/init.ts +118 -0
  36. package/server/objstore/reaper.ts +199 -0
  37. package/server/objstore/rest.ts +484 -0
  38. package/server/objstore/sign.ts +164 -0
  39. package/server/objstore/store-neon.ts +351 -0
  40. package/server/objstore/store.ts +799 -0
  41. package/server/objstore/tokens.ts +168 -0
  42. package/server/origin.ts +68 -0
  43. package/server/peer.ts +38 -0
  44. package/server/sign.ts +231 -0
  45. package/server/static.ts +374 -0
  46. package/server/sync-handlers.ts +311 -0
  47. package/server/util.ts +27 -0
  48. package/server/validation.ts +36 -0
  49. package/server/ws-server.ts +245 -0
@@ -0,0 +1,351 @@
1
+ // Neon Postgres backend for the v1.objstore tables. Mirrors the
2
+ // SQLite-backed `openObjstore` in ./store.ts — same `Handle` shape
3
+ // (with `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 inside `openNeonObjstore` below. A single
9
+ // Neon project / database holds both `workspace_revision` and the
10
+ // two objstore tables; each plane opens its own stateless
11
+ // `neon(url)` HTTP callable independently — there is no shared
12
+ // connection. Calling `neon()` is cheap; no resource is held to
13
+ // release.
14
+ //
15
+ // Byte plane: passed in as a `BlobBackend` (./blob.ts). The
16
+ // supported pairings selected by server/index.ts are:
17
+ // - Neon + Vercel Blob Private Storage (multi-replica, the only
18
+ // pairing that survives a replica restart without local-disk
19
+ // coordination). Activated by setting BLOB_READ_WRITE_TOKEN
20
+ // alongside DATABASE_URL.
21
+ // - Neon + local FS (development / operator escape hatch — bytes
22
+ // still need to live on a shared filesystem if you run more
23
+ // than one replica).
24
+
25
+ import type { BlobBackend } from './blob.ts'
26
+ import type { AllStmt, GetStmt, RunStmt } from '../db-stmt.ts'
27
+ import type { Handle } from './store.ts'
28
+ import { type NeonSql, assertDurableSyncCommit, getRowStmt, num, numOrNull, runStmt } from '../db-neon.ts'
29
+
30
+ // Advisory-lock keys (two int32s for the two-arg form). Distinct
31
+ // from the workspace_revision DDL keys in `db-neon.ts` so the two
32
+ // bootstraps don't serialize against each other. See db-neon.ts for
33
+ // the choice rationale.
34
+ const DDL_LOCK_KEY_OBJSTORE = 0x6465_7670 // 'depv'
35
+ const DDL_LOCK_KEY_OBJSTORE_SUB = 0x6f62_6a73 // 'objs'
36
+
37
+ // Statement-per-array-element because Neon's HTTP transport runs
38
+ // one statement per request — there's no multi-statement support
39
+ // like SQLite's `db.exec`. The DDL bootstrap in `openNeonObjstore`
40
+ // wraps the full array in a pipelined `sql.transaction` so the
41
+ // whole schema either applies or rolls back, AND grabs a
42
+ // transaction-scoped advisory lock so concurrent boots serialize.
43
+ //
44
+ // `CHECK (version >= 0)` / `CHECK (content_length >= 0)` (plus the
45
+ // staging table's `expected_length >= 0` and `prev_version` guards)
46
+ // defend the commitPut conflict arithmetic — a manual `UPDATE
47
+ // workspace_object SET version = -1` would otherwise round-trip
48
+ // through `num()` (which only rejects non-safe-integers) and corrupt
49
+ // the version monotonicity invariant. The SQLite schema carries the
50
+ // identical CHECKs (see `store.ts`) — STRICT there enforces the column
51
+ // TYPE but NOT this value domain (`version = -1` is a valid integer
52
+ // STRICT accepts), so both backends need the explicit CHECKs.
53
+ const SCHEMA_PG = [
54
+ `CREATE TABLE IF NOT EXISTS workspace_object (
55
+ workspace_tag TEXT NOT NULL,
56
+ resource_tag TEXT NOT NULL,
57
+ version BIGINT NOT NULL CHECK (version >= 0),
58
+ incarnation TEXT NOT NULL,
59
+ content_hash TEXT NOT NULL,
60
+ content_length BIGINT NOT NULL CHECK (content_length >= 0),
61
+ signature TEXT NOT NULL,
62
+ put_at BIGINT NOT NULL,
63
+ PRIMARY KEY (workspace_tag, resource_tag)
64
+ )`,
65
+ `CREATE TABLE IF NOT EXISTS workspace_object_staging (
66
+ workspace_tag TEXT NOT NULL,
67
+ resource_tag TEXT NOT NULL,
68
+ staging_id TEXT NOT NULL,
69
+ prev_version BIGINT CHECK (prev_version IS NULL OR prev_version >= 0),
70
+ prev_incarnation TEXT,
71
+ expected_length BIGINT NOT NULL CHECK (expected_length >= 0),
72
+ content_hash TEXT NOT NULL,
73
+ signature TEXT NOT NULL,
74
+ begun_at BIGINT NOT NULL,
75
+ PRIMARY KEY (workspace_tag, resource_tag, staging_id)
76
+ )`,
77
+ `CREATE INDEX IF NOT EXISTS workspace_object_staging_begun_at_idx
78
+ ON workspace_object_staging (begun_at)`,
79
+ ]
80
+
81
+ // `num` / `numOrNull` (safe-integer BIGINT coercion) are shared with
82
+ // the workspace_revision Neon plane — imported from ../db-neon.ts so
83
+ // the two planes can't drift on the coercion / range-check rules.
84
+
85
+ type LiveDbRow = {
86
+ resource_tag: string; version: number; incarnation: string; content_hash: string; content_length: number
87
+ signature: string; put_at: number
88
+ }
89
+
90
+ function mapLiveRow(r: Record<string, unknown>): LiveDbRow {
91
+ return {
92
+ resource_tag: String(r['resource_tag']),
93
+ version: num(r['version']),
94
+ incarnation: String(r['incarnation']),
95
+ content_hash: String(r['content_hash']),
96
+ content_length: num(r['content_length']),
97
+ signature: String(r['signature']),
98
+ put_at: num(r['put_at']),
99
+ }
100
+ }
101
+
102
+ // Per-statement builders — extracted so `openNeonObjstore` stays
103
+ // within the max-lines-per-function budget. Each closes over the
104
+ // Neon `sql` callable.
105
+
106
+ function buildInsertStaging(sql: NeonSql): RunStmt<[string, string, string, number | null, string | null, number, string, string, number]> {
107
+ return runStmt(sql, `INSERT INTO workspace_object_staging
108
+ (workspace_tag, resource_tag, staging_id, prev_version, prev_incarnation,
109
+ expected_length, content_hash, signature, begun_at)
110
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`)
111
+ }
112
+
113
+ type StagingRow = {
114
+ prev_version: number | null
115
+ prev_incarnation: string | null
116
+ expected_length: number
117
+ content_hash: string
118
+ signature: string
119
+ begun_at: number
120
+ }
121
+
122
+ function buildSelectStaging(sql: NeonSql): GetStmt<[string, string, string], StagingRow> {
123
+ return { get: async (tag, resourceTag, stagingId) => {
124
+ const rows = await sql(
125
+ `SELECT prev_version, prev_incarnation, expected_length, content_hash, signature, begun_at
126
+ FROM workspace_object_staging
127
+ WHERE workspace_tag = $1 AND resource_tag = $2 AND staging_id = $3`,
128
+ [tag, resourceTag, stagingId],
129
+ ) as Array<Record<string, unknown>>
130
+ const r = rows[0]
131
+ if (!r) return undefined
132
+ return {
133
+ prev_version: numOrNull(r['prev_version']),
134
+ prev_incarnation: r['prev_incarnation'] == null ? null : String(r['prev_incarnation']),
135
+ expected_length: num(r['expected_length']),
136
+ content_hash: String(r['content_hash']),
137
+ signature: String(r['signature']),
138
+ begun_at: num(r['begun_at']),
139
+ }
140
+ } }
141
+ }
142
+
143
+ function buildSelectStagingByWsSid(sql: NeonSql): GetStmt<[string, string], unknown> {
144
+ return getRowStmt(sql, `SELECT 1 AS one FROM workspace_object_staging WHERE workspace_tag = $1 AND staging_id = $2`)
145
+ }
146
+
147
+ function buildRefreshStagingBegunAt(sql: NeonSql): RunStmt<[number, string, string, string]> {
148
+ return runStmt(sql, `UPDATE workspace_object_staging SET begun_at = $1
149
+ WHERE workspace_tag = $2 AND resource_tag = $3 AND staging_id = $4`)
150
+ }
151
+
152
+ function buildDeleteStaging(sql: NeonSql): RunStmt<[string, string, string]> {
153
+ return runStmt(sql, `DELETE FROM workspace_object_staging
154
+ WHERE workspace_tag = $1 AND resource_tag = $2 AND staging_id = $3`)
155
+ }
156
+
157
+ // Atomic conditional stale-row delete for the reaper. Deletes the row
158
+ // IFF its `begun_at < $4`, returning `{ ok: 1 }` only when a row was
159
+ // actually removed (a concurrent `refreshStagingBegunAt` that bumped
160
+ // `begun_at` fresh makes the predicate fail → empty RETURNING →
161
+ // caller skips). Mirrors the SQLite `deleteStagingIfStale` statement.
162
+ // Bind order: (tag, res, sid, staleBefore).
163
+ function buildDeleteStagingIfStale(sql: NeonSql): GetStmt<[string, string, string, number], { ok: number }> {
164
+ return { get: async (tag, resourceTag, stagingId, staleBefore) => {
165
+ const rows = await sql(
166
+ `DELETE FROM workspace_object_staging
167
+ WHERE workspace_tag = $1 AND resource_tag = $2 AND staging_id = $3 AND begun_at < $4
168
+ RETURNING 1 AS ok`,
169
+ [tag, resourceTag, stagingId, staleBefore],
170
+ ) as Array<{ ok: number | string }>
171
+ const r = rows[0]
172
+ return r ? { ok: num(r.ok) } : undefined
173
+ } }
174
+ }
175
+
176
+ function buildSelectLive(sql: NeonSql): AllStmt<[string], LiveDbRow> {
177
+ return { all: async (tag) => {
178
+ const rows = await sql(
179
+ `SELECT resource_tag, version, incarnation, content_hash, content_length,
180
+ signature, put_at
181
+ FROM workspace_object
182
+ WHERE workspace_tag = $1
183
+ ORDER BY resource_tag ASC`,
184
+ [tag],
185
+ ) as Array<Record<string, unknown>>
186
+ return rows.map(mapLiveRow)
187
+ } }
188
+ }
189
+
190
+ function buildSelectLiveOne(sql: NeonSql): GetStmt<[string, string], LiveDbRow> {
191
+ return { get: async (tag, resourceTag) => {
192
+ const rows = await sql(
193
+ `SELECT resource_tag, version, incarnation, content_hash, content_length,
194
+ signature, put_at
195
+ FROM workspace_object
196
+ WHERE workspace_tag = $1 AND resource_tag = $2`,
197
+ [tag, resourceTag],
198
+ ) as Array<Record<string, unknown>>
199
+ const r = rows[0]
200
+ return r ? mapLiveRow(r) : undefined
201
+ } }
202
+ }
203
+
204
+ // First-write CAS: insert the live row at version 1 IF ABSENT.
205
+ // `ON CONFLICT … DO NOTHING RETURNING 1` returns a row only when our
206
+ // insert won; a racing first-write that landed first makes this a
207
+ // no-op (empty result) → caller maps to conflict. Bind order:
208
+ // (tag, res, hash, len, sig, put_at); version is the literal 1.
209
+ function buildInsertLiveIfAbsent(sql: NeonSql): GetStmt<[string, string, string, string, number, string, number], { ok: number }> {
210
+ return { get: async (tag, resourceTag, incarnation, contentHash, contentLength, signature, putAt) => {
211
+ const rows = await sql(
212
+ `INSERT INTO workspace_object
213
+ (workspace_tag, resource_tag, version, incarnation, content_hash, content_length,
214
+ signature, put_at)
215
+ VALUES ($1, $2, 1, $3, $4, $5, $6, $7)
216
+ ON CONFLICT (workspace_tag, resource_tag) DO NOTHING
217
+ RETURNING 1 AS ok`,
218
+ [tag, resourceTag, incarnation, contentHash, contentLength, signature, putAt],
219
+ ) as Array<{ ok: number | string }>
220
+ const r = rows[0]
221
+ return r ? { ok: num(r.ok) } : undefined
222
+ } }
223
+ }
224
+
225
+ // Re-upload CAS: bump the row to `$3` (next version) only when the
226
+ // live version still equals `$8` (the precondition the caller read).
227
+ // `RETURNING 1` comes back only when the WHERE matched — exactly one
228
+ // of N racing re-uploads against the same base version wins; the
229
+ // losers get an empty result → conflict + rebase. Bind order:
230
+ // (tag, res, nextVersion, hash, len, sig, put_at, expectedVersion).
231
+ function buildUpdateLiveCAS(sql: NeonSql): GetStmt<[string, string, number, string, number, string, number, number, string], { ok: number }> {
232
+ return { get: async (tag, resourceTag, nextVersion, contentHash, contentLength, signature, putAt, expectedVersion, expectedIncarnation) => {
233
+ const rows = await sql(
234
+ `UPDATE workspace_object
235
+ SET version = $3,
236
+ content_hash = $4,
237
+ content_length = $5,
238
+ signature = $6,
239
+ put_at = $7
240
+ WHERE workspace_tag = $1 AND resource_tag = $2 AND version = $8 AND incarnation = $9
241
+ RETURNING 1 AS ok`,
242
+ [tag, resourceTag, nextVersion, contentHash, contentLength, signature, putAt, expectedVersion, expectedIncarnation],
243
+ ) as Array<{ ok: number | string }>
244
+ const r = rows[0]
245
+ return r ? { ok: num(r.ok) } : undefined
246
+ } }
247
+ }
248
+
249
+ // Version-CAS delete: drop the row only while its version still matches
250
+ // the precondition (mirrors buildUpdateLiveCAS). RETURNING a row iff we
251
+ // removed it; deleteObject treats 0 rows as a lost race → conflict /
252
+ // not-found, never a lost update.
253
+ function buildDeleteLiveCAS(sql: NeonSql): GetStmt<[string, string, number, string], { ok: number }> {
254
+ return { get: async (tag, resourceTag, expectedVersion, expectedIncarnation) => {
255
+ const rows = await sql(
256
+ `DELETE FROM workspace_object
257
+ WHERE workspace_tag = $1 AND resource_tag = $2 AND version = $3 AND incarnation = $4
258
+ RETURNING 1 AS ok`,
259
+ [tag, resourceTag, expectedVersion, expectedIncarnation],
260
+ ) as Array<{ ok: number | string }>
261
+ const r = rows[0]
262
+ return r ? { ok: num(r.ok) } : undefined
263
+ } }
264
+ }
265
+
266
+ function buildListAllStaging(sql: NeonSql): AllStmt<[number], { workspace_tag: string; resource_tag: string; staging_id: string; begun_at: number }> {
267
+ return { all: async (staleBefore) => {
268
+ // `WHERE begun_at < $1` uses workspace_object_staging_begun_at_idx
269
+ // so the reaper sweep is O(stale-rows) cluster-wide. DB-layout
270
+ // audit follow-up.
271
+ const rows = await sql(
272
+ `SELECT workspace_tag, resource_tag, staging_id, begun_at
273
+ FROM workspace_object_staging
274
+ WHERE begun_at < $1`,
275
+ [staleBefore],
276
+ ) as Array<Record<string, unknown>>
277
+ return rows.map((r) => ({
278
+ workspace_tag: String(r['workspace_tag']),
279
+ resource_tag: String(r['resource_tag']),
280
+ staging_id: String(r['staging_id']),
281
+ begun_at: num(r['begun_at']),
282
+ }))
283
+ } }
284
+ }
285
+
286
+ function buildListLiveTags(sql: NeonSql): AllStmt<[], { workspace_tag: string }> {
287
+ return { all: async () => {
288
+ const rows = await sql(
289
+ `SELECT DISTINCT workspace_tag FROM workspace_object`,
290
+ [],
291
+ ) as Array<Record<string, unknown>>
292
+ return rows.map((r) => ({ workspace_tag: String(r['workspace_tag']) }))
293
+ } }
294
+ }
295
+
296
+ function buildCountLive(sql: NeonSql): GetStmt<[string], { c: number }> {
297
+ return { get: async (tag) => {
298
+ const rows = await sql(
299
+ `SELECT COUNT(*) AS c FROM workspace_object WHERE workspace_tag = $1`,
300
+ [tag],
301
+ ) as Array<{ c: number | string | bigint }>
302
+ const r = rows[0]
303
+ return r ? { c: num(r.c) } : undefined
304
+ } }
305
+ }
306
+
307
+ export async function openNeonObjstore(connectionString: string, blob: BlobBackend): Promise<Handle> {
308
+ // Same dynamic-import pattern as db-neon.ts — routed through the
309
+ // local `../neon-driver.ts` re-export wrapper so the peer dep stays
310
+ // optional AND tests can mock the driver (a local path is always
311
+ // resolvable; the bare specifier isn't when the dep is absent). See
312
+ // `server/neon-driver.ts`. Cast through `unknown` because the
313
+ // wrapper's `export *` re-exports a `@ts-ignore`'d module.
314
+ const mod = (await import('../neon-driver.ts')) as unknown as { neon: (url: string) => NeonSql }
315
+ const sql: NeonSql = mod.neon(connectionString)
316
+ // Boot-time durability gate (see db-neon.ts) — `openNeonDb` runs
317
+ // the same assertion when it opens, but `openNeonObjstore` is
318
+ // independently invoked for the objstore plane and an operator
319
+ // who runs only this plane (future stand-alone deployment) must
320
+ // be gated too.
321
+ await assertDurableSyncCommit(sql)
322
+ // DDL bootstrap under one pipelined transaction so a transient
323
+ // network failure mid-batch rolls back, AND a transaction-scoped
324
+ // advisory lock so two replicas booting concurrently serialize
325
+ // their DDL (the advisory lock releases at COMMIT).
326
+ await sql.transaction([
327
+ sql(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_OBJSTORE, DDL_LOCK_KEY_OBJSTORE_SUB]),
328
+ ...SCHEMA_PG.map((stmt) => sql(stmt, [])),
329
+ ])
330
+
331
+ return {
332
+ // `db` intentionally unset — Neon has no `DatabaseSync`.
333
+ // `dir` intentionally unset — the byte plane goes through the
334
+ // `blob` backend, which may not have an on-disk layout at all.
335
+ blob,
336
+ insertStaging: buildInsertStaging(sql),
337
+ selectStaging: buildSelectStaging(sql),
338
+ selectStagingByWsSid: buildSelectStagingByWsSid(sql),
339
+ refreshStagingBegunAt: buildRefreshStagingBegunAt(sql),
340
+ deleteStaging: buildDeleteStaging(sql),
341
+ deleteStagingIfStale: buildDeleteStagingIfStale(sql),
342
+ selectLive: buildSelectLive(sql),
343
+ selectLiveOne: buildSelectLiveOne(sql),
344
+ insertLiveIfAbsent: buildInsertLiveIfAbsent(sql),
345
+ updateLiveCAS: buildUpdateLiveCAS(sql),
346
+ deleteLiveCAS: buildDeleteLiveCAS(sql),
347
+ listAllStaging: buildListAllStaging(sql),
348
+ listLiveTags: buildListLiveTags(sql),
349
+ countLive: buildCountLive(sql),
350
+ }
351
+ }