@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.
@@ -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; the client-side codes (socket close, the
49
- // manual `overrun`) are everything else. ENOENT is included because
50
- // `createWriteStream` will reject with it if the `${tag}/.staging`
51
- // dir was removed out from under us (operator action / external
52
- // fs activity) — that's a server-side state, not a client-fixable
53
- // abort. PR #4 review. The Vercel-blob backend surfaces failures
54
- // through other paths (rejected put promise → caught by the
55
- // pipeline catch as a plain Error without a `code`); those land in
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 at all: the live blob is content-
75
- // addressed (`${tag}/${contentHash}.bin`) so two racing commits write
76
- // to DIFFERENT immutable addresses, and the commit itself is an atomic
77
- // version compare-and-set on the live row (see commitPut in store.ts).
78
- // Exactly one racer wins the CAS; the loser gets a 409 `conflict` and
79
- // rebases. This holds within a single process and across replicas —
80
- // there is no in-process mutex serialising commits. (The PUT *body*
81
- // does take a per-process single-writer reservation — `inFlightSids`
82
- // below — but that only rejects a duplicate upload of one staging
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 — a retry that doesn't cancel
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 the FS backend `createWriteStream(…, { flags: 'w' })`
91
- // truncates, so the two writers clobber each other's bytes/size and BOTH
92
- // can fail (size-mismatch 400 + promote-race 500) with neither
93
- // committing. `inFlightSids` admits exactly ONE in-flight upload per
94
- // staging id; a concurrent same-token PUT is rejected (409) before it
95
- // can open a second writer. The slot is held only across the body +
96
- // commit and released in a `finally` (and bounded by the REST idle-body
97
- // timeout in server/http.ts so a slow-loris can't pin it). It is NOT a
98
- // commit lock — commits stay lock-free on the version-CAS above. It's
99
- // per-process: the Vercel multi-replica path leans on `allowOverwrite:
100
- // true` + the commit CAS + content-addressing for cross-replica
101
- // correctness (a duplicate that lands on another replica can't clobber a
102
- // shared local file and still loses the CAS), not on this set.
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 have one shape to parse. Status + reason are NOT
125
- // intentionally indistinguishable across causes — 401, 404, 405,
126
- // 410, 411, 500 each map to a documented reason in server/README.md
127
- // and the client decides recovery from the code. Defense against
128
- // probe-driven distinguishing isn't a property the relay aims for;
129
- // every error reason is reachable only after the route + bearer-
130
- // token check passes (or as 401/404 from the public surface), so
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 not by a lock but by its
213
- // `begun_at`: an upload under the staging TTL stays fresh through the
214
- // body, and the after-body `refreshStagingBegunAt` re-extends the
215
- // TTL across the commit. (An upload exceeding the TTL during the
216
- // body can be reaped mid-flight → commit 410s; documented accepted
217
- // tradeoff — see commitPut in store.ts.)
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
- // and to make the wire-mapping ladder its own audit surface — the
224
- // exhaustiveness `never` guard at the bottom catches a forward-
225
- // compat hazard where a new `CommitPutResult` reason lands without
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. The commit's version-CAS
277
- // arbitrates concurrent commits; the staging row is kept fresh for
278
- // the reaper by its `begun_at` (+ the after-body refresh), not by a
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
- // row from workspace_object to compose their local broadcast. The
299
- // committed row is durable by here (commitPut's version-CAS already
300
- // landed), so the receiver typically sees either THIS version or a
301
- // STRICTLY newer one (also a valid broadcast — clients are
302
- // idempotent on (resourceTag, version)). The receiver is allowed
303
- // to find no live row at all if a subsequent delete races the
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
- // not-found → `unavailable` → 503, and the client refetches. We can
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
- // 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.
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 {