@preventive/triage 1.0.0-alpha.1 → 1.0.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/client-sync.js +11 -11
- package/out/graph.js +3 -3
- package/out/index.html +3 -15
- package/out/view.css +1 -1
- package/out/view.js +51 -48
- package/package.json +11 -4
- package/server/bus-receiver.ts +7 -7
- package/server/cli.js +22 -0
- package/server/config.ts +4 -4
- package/server/db-neon.ts +29 -23
- package/server/db-revision-sql.ts +6 -9
- package/server/db.ts +95 -134
- package/server/hub.ts +4 -5
- package/server/index.ts +42 -27
- package/server/lifecycle.ts +6 -1
- package/server/objstore/blob-fs.ts +4 -6
- package/server/objstore/blob-vercel.ts +66 -33
- package/server/objstore/blob.ts +16 -8
- package/server/objstore/handlers.ts +11 -13
- package/server/objstore/init.ts +18 -9
- package/server/objstore/rest.ts +60 -68
- package/server/objstore/store-neon.ts +16 -16
- package/server/objstore/store.ts +92 -112
- package/server/objstore/tokens.ts +9 -12
- package/server/peer.ts +7 -9
- package/server/pubsub.ts +17 -27
- package/server/sign.ts +8 -10
- package/server/sse-server.ts +10 -12
- package/server/sse-session.ts +2 -8
- package/server/static.ts +8 -12
- package/server/sync-handlers.ts +32 -39
- package/server/ws-server.ts +9 -14
- package/strip-types-loader.js +94 -0
package/server/objstore/rest.ts
CHANGED
|
@@ -45,16 +45,14 @@ import { errStack } from '../util.ts'
|
|
|
45
45
|
|
|
46
46
|
// Server-side fault codes that should surface as 500 `io-error`
|
|
47
47
|
// rather than 400 `aborted`. `pipeline(req, ws)` rejects with the
|
|
48
|
-
// first stream error;
|
|
49
|
-
//
|
|
50
|
-
// `createWriteStream`
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// the default `aborted` branch and the operator-facing log line
|
|
57
|
-
// carries the SDK's error message.
|
|
48
|
+
// first stream error; client-side codes (socket close, the manual
|
|
49
|
+
// `overrun`) are everything else. ENOENT is included because
|
|
50
|
+
// `createWriteStream` rejects with it if the `${tag}/.staging` dir
|
|
51
|
+
// was removed out from under us (operator / external fs activity) —
|
|
52
|
+
// a server-side state, not a client-fixable abort. Vercel-blob
|
|
53
|
+
// failures arrive without a `code` (rejected put promise → plain
|
|
54
|
+
// Error); those land in the default `aborted` branch, with the SDK
|
|
55
|
+
// error in the operator log line.
|
|
58
56
|
const IO_FAULT_CODES = new Set(['ENOSPC', 'EACCES', 'EROFS', 'EIO', 'EMFILE', 'ENFILE', 'EDQUOT', 'EPERM', 'ENOENT'])
|
|
59
57
|
|
|
60
58
|
export type ObjstoreRestDeps = {
|
|
@@ -71,35 +69,34 @@ export type ObjstoreRestDeps = {
|
|
|
71
69
|
debug: boolean
|
|
72
70
|
}
|
|
73
71
|
|
|
74
|
-
// Concurrent commits need no lock
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// slot; it never makes distinct commits wait.)
|
|
72
|
+
// Concurrent commits need no lock: the live blob is content-addressed
|
|
73
|
+
// (`${tag}/${contentHash}.bin`) so two racing commits write to
|
|
74
|
+
// DIFFERENT immutable addresses, and the commit is an atomic version
|
|
75
|
+
// compare-and-set on the live row (see commitPut in store.ts). Exactly
|
|
76
|
+
// one racer wins the CAS; the loser gets a 409 `conflict` and rebases.
|
|
77
|
+
// Holds within a process and across replicas — no in-process mutex
|
|
78
|
+
// serialises commits. (The PUT *body* takes a per-process single-
|
|
79
|
+
// writer reservation — `inFlightSids` below — but that only rejects a
|
|
80
|
+
// duplicate upload of one staging slot; distinct commits never wait.)
|
|
84
81
|
|
|
85
82
|
// Per-process single-writer guard for the REST PUT body. A put-token is
|
|
86
83
|
// a REUSABLE bearer capability (tokens.ts) valid for its whole TTL, so a
|
|
87
|
-
// client replaying it on overlapping PUTs
|
|
88
|
-
// the in-flight request, a proxy re-issuing the PUT, a double-submit
|
|
84
|
+
// client replaying it on overlapping PUTs (a retry that doesn't cancel
|
|
85
|
+
// the in-flight request, a proxy re-issuing the PUT, a double-submit)
|
|
89
86
|
// would otherwise have two requests stream into the SAME staging file
|
|
90
|
-
// (same sid). On
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
87
|
+
// (same sid). On FS, `createWriteStream(…, { flags: 'w' })` truncates,
|
|
88
|
+
// so the two writers clobber each other's bytes/size and BOTH can fail
|
|
89
|
+
// (size-mismatch 400 + promote-race 500) with neither committing.
|
|
90
|
+
// `inFlightSids` admits exactly ONE in-flight upload per staging id; a
|
|
91
|
+
// concurrent same-token PUT is rejected (409) before it can open a
|
|
92
|
+
// second writer. The slot is held only across body + commit, released
|
|
93
|
+
// in a `finally`, and bounded by the REST idle-body timeout in
|
|
94
|
+
// server/http.ts so a slow-loris can't pin it. NOT a commit lock —
|
|
95
|
+
// commits stay lock-free on the version-CAS above. Per-process: the
|
|
96
|
+
// Vercel multi-replica path leans on `allowOverwrite: true` + the
|
|
97
|
+
// commit CAS + content-addressing for cross-replica correctness (a
|
|
98
|
+
// duplicate landing on another replica can't clobber a shared local
|
|
99
|
+
// file and still loses the CAS), not on this set.
|
|
103
100
|
const inFlightSids = new Set<string>()
|
|
104
101
|
|
|
105
102
|
// `/api/objstore/${workspaceTag}/${resourceTag}` — base64url
|
|
@@ -121,14 +118,13 @@ export function matchRoute(url: string | undefined): RouteMatch | null {
|
|
|
121
118
|
|
|
122
119
|
function deny(res: ServerResponse, status: number, body: string): void {
|
|
123
120
|
// Uniform `{ error: <reason> }` JSON envelope for every failure so
|
|
124
|
-
// clients
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// there's no signal here a probe couldn't otherwise enumerate.
|
|
121
|
+
// clients parse one shape. Status + reason are NOT intentionally
|
|
122
|
+
// indistinguishable across causes — 401/404/405/410/411/500 each map
|
|
123
|
+
// to a documented reason in server/README.md and the client decides
|
|
124
|
+
// recovery from the code. Probe-distinguishing defense isn't a goal:
|
|
125
|
+
// every reason is reachable only after the route + bearer-token check
|
|
126
|
+
// passes (or as 401/404 from the public surface), so a probe gains no
|
|
127
|
+
// signal it couldn't otherwise enumerate.
|
|
132
128
|
res.writeHead(status, { 'content-type': 'application/json' })
|
|
133
129
|
res.end(JSON.stringify({ error: body }))
|
|
134
130
|
}
|
|
@@ -209,21 +205,20 @@ async function handleRestPut(
|
|
|
209
205
|
// No lock: the commit's version-CAS arbitrates concurrent commits
|
|
210
206
|
// (the live blob is content-addressed, so racers can't desync
|
|
211
207
|
// metadata vs bytes — the loser surfaces a 409 `conflict`). The
|
|
212
|
-
// staging row is protected from the reaper
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
208
|
+
// staging row is protected from the reaper by its `begun_at`, not a
|
|
209
|
+
// lock: an upload under the staging TTL stays fresh through the body,
|
|
210
|
+
// and the after-body `refreshStagingBegunAt` re-extends the TTL across
|
|
211
|
+
// the commit. (An upload exceeding the TTL during the body can be
|
|
212
|
+
// reaped mid-flight → commit 410s; documented accepted tradeoff —
|
|
213
|
+
// see commitPut in store.ts.)
|
|
218
214
|
await handleRestPutBody(deps, req, res, route, payload, declared)
|
|
219
215
|
}
|
|
220
216
|
|
|
221
217
|
// Map a `commitPut` failure to its wire response. Extracted from
|
|
222
|
-
// `handleRestPutBody` to keep it under the per-function line cap
|
|
223
|
-
//
|
|
224
|
-
// exhaustiveness `never` guard at the bottom catches a
|
|
225
|
-
//
|
|
226
|
-
// updating this dispatch.
|
|
218
|
+
// `handleRestPutBody` to keep it under the per-function line cap and
|
|
219
|
+
// to make the wire-mapping ladder its own audit surface — the
|
|
220
|
+
// exhaustiveness `never` guard at the bottom catches a new
|
|
221
|
+
// `CommitPutResult` reason landing without updating this dispatch.
|
|
227
222
|
function denyCommitFailure(res: ServerResponse, result: Exclude<CommitPutResult, { ok: true }>): void {
|
|
228
223
|
if (result.reason === 'conflict') {
|
|
229
224
|
denyConflict(res, result.conflict?.version ?? null, result.conflict?.incarnation ?? null)
|
|
@@ -273,10 +268,9 @@ async function handleRestPutBody(
|
|
|
273
268
|
}
|
|
274
269
|
inFlightSids.add(payload.sid)
|
|
275
270
|
try {
|
|
276
|
-
// Stream the upload + commit, no lock
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// mutex. See the rationale in handleRestPut above.
|
|
271
|
+
// Stream the upload + commit, no lock — version-CAS arbitrates
|
|
272
|
+
// commits, the staging row stays fresh for the reaper via its
|
|
273
|
+
// `begun_at` (+ after-body refresh). See handleRestPut above.
|
|
280
274
|
const result = await runUploadAndCommit(deps, route, payload, declared, req, res)
|
|
281
275
|
if (result.handled) return
|
|
282
276
|
if (!result.commit.ok) { denyCommitFailure(res, result.commit); return }
|
|
@@ -294,15 +288,13 @@ async function handleRestPutBody(
|
|
|
294
288
|
...objectMetaWire(row),
|
|
295
289
|
}, null)
|
|
296
290
|
// Cross-instance fan-out (Neon mode). The bus payload carries only
|
|
297
|
-
// (tag, resourceTag); peers on other instances re-fetch the live
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
// notification; bus-receiver.ts drops that case silently. SQLite
|
|
305
|
-
// mode publishes to a no-op.
|
|
291
|
+
// (tag, resourceTag); peers on other instances re-fetch the live row
|
|
292
|
+
// from workspace_object to compose their broadcast. The committed
|
|
293
|
+
// row is durable by here (commitPut's version-CAS landed), so the
|
|
294
|
+
// receiver sees THIS version or a STRICTLY newer one (also valid —
|
|
295
|
+
// clients are idempotent on (resourceTag, version)), or no live row
|
|
296
|
+
// at all if a subsequent delete races the notification (bus-
|
|
297
|
+
// receiver.ts drops that silently). SQLite mode publishes to a no-op.
|
|
306
298
|
deps.publishObjPut(route.tag, route.resourceTag)
|
|
307
299
|
if (deps.debug) console.log(`objstore put → ${route.tag.slice(0, 12)}…/${route.resourceTag.slice(0, 8)}… v${row.version}`)
|
|
308
300
|
} finally {
|
|
@@ -451,7 +443,7 @@ async function openLiveSnapshot(
|
|
|
451
443
|
// bytes behind `live.content_hash` can never change underneath us —
|
|
452
444
|
// the worst a race can do is have the reaper GC an already-superseded
|
|
453
445
|
// hash just before we open it, which surfaces as openLiveReader
|
|
454
|
-
//
|
|
446
|
+
// returning `unavailable` → 503, and the client refetches. We can
|
|
455
447
|
// never serve torn or wrong bytes. (For the FS backend the open also
|
|
456
448
|
// returns a pinned fd; for the Vercel backend a fetch-backed stream.)
|
|
457
449
|
const live = await deps.handle.selectLiveOne.get(route.tag, route.resourceTag)
|
|
@@ -46,10 +46,10 @@ const DDL_LOCK_KEY_OBJSTORE_SUB = 0x6f62_6a73 // 'objs'
|
|
|
46
46
|
// defend the commitPut conflict arithmetic — a manual `UPDATE
|
|
47
47
|
// workspace_object SET version = -1` would otherwise round-trip
|
|
48
48
|
// through `num()` (which only rejects non-safe-integers) and corrupt
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
49
|
+
// version monotonicity. SQLite carries the identical CHECKs (see
|
|
50
|
+
// `store.ts`): STRICT there enforces column TYPE but NOT this value
|
|
51
|
+
// domain (`version = -1` is a valid integer STRICT accepts), so both
|
|
52
|
+
// backends need the explicit CHECKs.
|
|
53
53
|
const SCHEMA_PG = [
|
|
54
54
|
`CREATE TABLE IF NOT EXISTS workspace_object (
|
|
55
55
|
workspace_tag TEXT NOT NULL,
|
|
@@ -121,7 +121,7 @@ type StagingRow = {
|
|
|
121
121
|
|
|
122
122
|
function buildSelectStaging(sql: NeonSql): GetStmt<[string, string, string], StagingRow> {
|
|
123
123
|
return { get: async (tag, resourceTag, stagingId) => {
|
|
124
|
-
const rows = await sql(
|
|
124
|
+
const rows = await sql.query(
|
|
125
125
|
`SELECT prev_version, prev_incarnation, expected_length, content_hash, signature, begun_at
|
|
126
126
|
FROM workspace_object_staging
|
|
127
127
|
WHERE workspace_tag = $1 AND resource_tag = $2 AND staging_id = $3`,
|
|
@@ -162,7 +162,7 @@ function buildDeleteStaging(sql: NeonSql): RunStmt<[string, string, string]> {
|
|
|
162
162
|
// Bind order: (tag, res, sid, staleBefore).
|
|
163
163
|
function buildDeleteStagingIfStale(sql: NeonSql): GetStmt<[string, string, string, number], { ok: number }> {
|
|
164
164
|
return { get: async (tag, resourceTag, stagingId, staleBefore) => {
|
|
165
|
-
const rows = await sql(
|
|
165
|
+
const rows = await sql.query(
|
|
166
166
|
`DELETE FROM workspace_object_staging
|
|
167
167
|
WHERE workspace_tag = $1 AND resource_tag = $2 AND staging_id = $3 AND begun_at < $4
|
|
168
168
|
RETURNING 1 AS ok`,
|
|
@@ -175,7 +175,7 @@ function buildDeleteStagingIfStale(sql: NeonSql): GetStmt<[string, string, strin
|
|
|
175
175
|
|
|
176
176
|
function buildSelectLive(sql: NeonSql): AllStmt<[string], LiveDbRow> {
|
|
177
177
|
return { all: async (tag) => {
|
|
178
|
-
const rows = await sql(
|
|
178
|
+
const rows = await sql.query(
|
|
179
179
|
`SELECT resource_tag, version, incarnation, content_hash, content_length,
|
|
180
180
|
signature, put_at
|
|
181
181
|
FROM workspace_object
|
|
@@ -189,7 +189,7 @@ function buildSelectLive(sql: NeonSql): AllStmt<[string], LiveDbRow> {
|
|
|
189
189
|
|
|
190
190
|
function buildSelectLiveOne(sql: NeonSql): GetStmt<[string, string], LiveDbRow> {
|
|
191
191
|
return { get: async (tag, resourceTag) => {
|
|
192
|
-
const rows = await sql(
|
|
192
|
+
const rows = await sql.query(
|
|
193
193
|
`SELECT resource_tag, version, incarnation, content_hash, content_length,
|
|
194
194
|
signature, put_at
|
|
195
195
|
FROM workspace_object
|
|
@@ -208,7 +208,7 @@ function buildSelectLiveOne(sql: NeonSql): GetStmt<[string, string], LiveDbRow>
|
|
|
208
208
|
// (tag, res, hash, len, sig, put_at); version is the literal 1.
|
|
209
209
|
function buildInsertLiveIfAbsent(sql: NeonSql): GetStmt<[string, string, string, string, number, string, number], { ok: number }> {
|
|
210
210
|
return { get: async (tag, resourceTag, incarnation, contentHash, contentLength, signature, putAt) => {
|
|
211
|
-
const rows = await sql(
|
|
211
|
+
const rows = await sql.query(
|
|
212
212
|
`INSERT INTO workspace_object
|
|
213
213
|
(workspace_tag, resource_tag, version, incarnation, content_hash, content_length,
|
|
214
214
|
signature, put_at)
|
|
@@ -230,7 +230,7 @@ function buildInsertLiveIfAbsent(sql: NeonSql): GetStmt<[string, string, string,
|
|
|
230
230
|
// (tag, res, nextVersion, hash, len, sig, put_at, expectedVersion).
|
|
231
231
|
function buildUpdateLiveCAS(sql: NeonSql): GetStmt<[string, string, number, string, number, string, number, number, string], { ok: number }> {
|
|
232
232
|
return { get: async (tag, resourceTag, nextVersion, contentHash, contentLength, signature, putAt, expectedVersion, expectedIncarnation) => {
|
|
233
|
-
const rows = await sql(
|
|
233
|
+
const rows = await sql.query(
|
|
234
234
|
`UPDATE workspace_object
|
|
235
235
|
SET version = $3,
|
|
236
236
|
content_hash = $4,
|
|
@@ -252,7 +252,7 @@ function buildUpdateLiveCAS(sql: NeonSql): GetStmt<[string, string, number, stri
|
|
|
252
252
|
// not-found, never a lost update.
|
|
253
253
|
function buildDeleteLiveCAS(sql: NeonSql): GetStmt<[string, string, number, string], { ok: number }> {
|
|
254
254
|
return { get: async (tag, resourceTag, expectedVersion, expectedIncarnation) => {
|
|
255
|
-
const rows = await sql(
|
|
255
|
+
const rows = await sql.query(
|
|
256
256
|
`DELETE FROM workspace_object
|
|
257
257
|
WHERE workspace_tag = $1 AND resource_tag = $2 AND version = $3 AND incarnation = $4
|
|
258
258
|
RETURNING 1 AS ok`,
|
|
@@ -268,7 +268,7 @@ function buildListAllStaging(sql: NeonSql): AllStmt<[number], { workspace_tag: s
|
|
|
268
268
|
// `WHERE begun_at < $1` uses workspace_object_staging_begun_at_idx
|
|
269
269
|
// so the reaper sweep is O(stale-rows) cluster-wide. DB-layout
|
|
270
270
|
// audit follow-up.
|
|
271
|
-
const rows = await sql(
|
|
271
|
+
const rows = await sql.query(
|
|
272
272
|
`SELECT workspace_tag, resource_tag, staging_id, begun_at
|
|
273
273
|
FROM workspace_object_staging
|
|
274
274
|
WHERE begun_at < $1`,
|
|
@@ -285,7 +285,7 @@ function buildListAllStaging(sql: NeonSql): AllStmt<[number], { workspace_tag: s
|
|
|
285
285
|
|
|
286
286
|
function buildListLiveTags(sql: NeonSql): AllStmt<[], { workspace_tag: string }> {
|
|
287
287
|
return { all: async () => {
|
|
288
|
-
const rows = await sql(
|
|
288
|
+
const rows = await sql.query(
|
|
289
289
|
`SELECT DISTINCT workspace_tag FROM workspace_object`,
|
|
290
290
|
[],
|
|
291
291
|
) as Array<Record<string, unknown>>
|
|
@@ -295,7 +295,7 @@ function buildListLiveTags(sql: NeonSql): AllStmt<[], { workspace_tag: string }>
|
|
|
295
295
|
|
|
296
296
|
function buildCountLive(sql: NeonSql): GetStmt<[string], { c: number }> {
|
|
297
297
|
return { get: async (tag) => {
|
|
298
|
-
const rows = await sql(
|
|
298
|
+
const rows = await sql.query(
|
|
299
299
|
`SELECT COUNT(*) AS c FROM workspace_object WHERE workspace_tag = $1`,
|
|
300
300
|
[tag],
|
|
301
301
|
) as Array<{ c: number | string | bigint }>
|
|
@@ -324,8 +324,8 @@ export async function openNeonObjstore(connectionString: string, blob: BlobBacke
|
|
|
324
324
|
// advisory lock so two replicas booting concurrently serialize
|
|
325
325
|
// their DDL (the advisory lock releases at COMMIT).
|
|
326
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, [])),
|
|
327
|
+
sql.query(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_OBJSTORE, DDL_LOCK_KEY_OBJSTORE_SUB]),
|
|
328
|
+
...SCHEMA_PG.map((stmt) => sql.query(stmt, [])),
|
|
329
329
|
])
|
|
330
330
|
|
|
331
331
|
return {
|