@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.4

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.
@@ -41,20 +41,18 @@ import {
41
41
  } from './store.ts'
42
42
  import type { LiveReader } from './blob.ts'
43
43
  import { type TokenSecret, extractBearer, verifyToken } from './tokens.ts'
44
- import { errStack } from '../util.ts'
44
+ import { debugId, debugTag, errMsg, 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 {
@@ -439,7 +431,10 @@ async function runUploadAndCommit(
439
431
  type GetOpened =
440
432
  | { reason: 'ok'; reader: LiveReader }
441
433
  | { reason: 'not-found' }
442
- | { reason: 'unavailable' }
434
+ // `detail` is a short non-sensitive cause tag (backend sub-reason +
435
+ // content-hash prefix) the GET handler logs so a 503 is diagnosable —
436
+ // every byte-side failure collapses to the same wire 503 otherwise.
437
+ | { reason: 'unavailable'; detail: string }
443
438
 
444
439
  async function openLiveSnapshot(
445
440
  deps: ObjstoreRestDeps, route: RouteMatch, payload: { ver: number; inc: string },
@@ -451,22 +446,32 @@ async function openLiveSnapshot(
451
446
  // bytes behind `live.content_hash` can never change underneath us —
452
447
  // the worst a race can do is have the reaper GC an already-superseded
453
448
  // hash just before we open it, which surfaces as openLiveReader
454
- // not-found → `unavailable` → 503, and the client refetches. We can
449
+ // returning `unavailable` → 503, and the client refetches. We can
455
450
  // never serve torn or wrong bytes. (For the FS backend the open also
456
451
  // returns a pinned fd; for the Vercel backend a fetch-backed stream.)
457
452
  const live = await deps.handle.selectLiveOne.get(route.tag, route.resourceTag)
458
453
  if (!live || live.version !== payload.ver || live.incarnation !== payload.inc) return { reason: 'not-found' }
454
+ // Tag the content hash into every `unavailable` detail so an operator
455
+ // can go check the byte store directly for THIS blob (gone → reaper /
456
+ // deletion; present → transient read fault).
457
+ const hashTag = `hash=${debugId(live.content_hash)}`
459
458
  let opened
460
459
  try { opened = await deps.handle.blob.openLiveReader(route.tag, live.content_hash) }
461
- catch { return { reason: 'unavailable' } }
462
- if (!opened.ok) return { reason: opened.reason }
460
+ // Length-cap the thrown error text: today a non-BlobNotFound SDK throw
461
+ // (BlobServiceNotAvailable / store-not-found) carries no credential
462
+ // (the RW token rides the Authorization header, never `.message`), but
463
+ // a future SDK could embed a signed URL / token fragment — bound the
464
+ // log line so it can't dump one verbatim. Mirrors the `.slice(0, 200)`
465
+ // cap used on SDK error text in blob-vercel.ts.
466
+ catch (err) { return { reason: 'unavailable', detail: `open-threw ${hashTag} ${String(errMsg(err)).slice(0, 200)}` } }
467
+ if (!opened.ok) return { reason: 'unavailable', detail: `${opened.detail ?? 'backend'} ${hashTag}` }
463
468
  // Size mismatch between the live row and the on-storage bytes
464
469
  // is a transient inconsistency — reaper will reconcile. Close
465
470
  // the reader before returning so we don't leak the fd / fetch
466
471
  // reader. PR #4 review H8.
467
472
  if (opened.reader.size !== live.content_length) {
468
473
  await opened.reader.close().catch(() => {})
469
- return { reason: 'unavailable' }
474
+ return { reason: 'unavailable', detail: `size-mismatch row=${live.content_length} blob=${opened.reader.size} ${hashTag}` }
470
475
  }
471
476
  return { reason: 'ok', reader: opened.reader }
472
477
  }
@@ -486,8 +491,19 @@ async function handleRestGet(
486
491
  // If the live row is there but the bytes are missing / wrong size,
487
492
  // it's a transient inconsistency the reaper will sort out — 503
488
493
  // (vs 404) tells the client this is a server-side state, not a
489
- // "the resource truly isn't there" answer.
490
- if (opened.reason === 'unavailable') { deny(res, 503, 'unavailable'); return }
494
+ // "the resource truly isn't there" answer. Log the cause
495
+ // UNCONDITIONALLY (not behind `debug`): a 503 means a live row whose
496
+ // bytes can't be served, and the wire response can't distinguish a
497
+ // permanent loss (reaper GC'd referenced bytes) from a transient read
498
+ // fault. `detail` carries the backend sub-reason + content-hash prefix
499
+ // so an operator can tell which — the only server-side breadcrumb for
500
+ // the "all data turned into 503" failure. (Volume is bounded: a 503 is
501
+ // an error path; a workspace-wide outage is exactly when these are
502
+ // wanted.)
503
+ if (opened.reason === 'unavailable') {
504
+ console.warn(`objstore-get: 503 unavailable ${debugTag(route.tag)}/${route.resourceTag.slice(0, 8)}… v${payload.ver} ${opened.detail}`)
505
+ deny(res, 503, 'unavailable'); return
506
+ }
491
507
  res.writeHead(200, {
492
508
  'content-type': 'application/octet-stream',
493
509
  'content-length': String(opened.reader.size),
@@ -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,
@@ -79,9 +79,8 @@ const SCHEMA = `
79
79
 
80
80
  // One LIVE row, exactly the shape the `workspace-subscribed` ack's
81
81
  // `resources` array carries on the wire, minus `keyframe`-style
82
- // server-only flags. `put_at` is a
83
- // debug aid the wire format doesn't include — operators can inspect
84
- // it via the DB but the server never volunteers it.
82
+ // server-only flags. `put_at` is a debug aid the wire format never
83
+ // includes — inspectable via the DB but the server never volunteers it.
85
84
  export type ObjectRow = {
86
85
  resourceTag: string
87
86
  version: number
@@ -118,13 +117,11 @@ export type BeginPutInput = {
118
117
  // PUT will reference. `workspace-full` is the per-workspace resource-
119
118
  // count cap rejection — see `MAX_RESOURCES_PER_WORKSPACE`.
120
119
  //
121
- // `filePath` is set only for the FS-backed handle (where it's the
122
- // absolute on-disk staging path); the Vercel-Blob handle omits it
123
- // since "path" isn't a meaningful concept against a remote object
124
- // store. Production code (rest.ts) never reads this field — the
125
- // REST layer goes through `handle.blob.openStagingWriter(tag, sid)`.
126
- // Tests for the FS path use it as a convenience to write fixture
127
- // bytes directly to the staging slot.
120
+ // `filePath` is the absolute on-disk staging path, set only for the
121
+ // FS handle; the Vercel handle omits it ("path" is meaningless against
122
+ // a remote store). Production (rest.ts) never reads it — it goes
123
+ // through `handle.blob.openStagingWriter(tag, sid)`. FS-path tests use
124
+ // it as a convenience to write fixture bytes to the staging slot.
128
125
  export type BeginPutResult =
129
126
  | { ok: true; stagingId: string; filePath?: string }
130
127
  | { ok: false; reason: 'conflict'; conflict: ObjectRow | null }
@@ -134,17 +131,14 @@ export type CommitPutInput = {
134
131
  workspaceTag: string
135
132
  resourceTag: string
136
133
  stagingId: string
137
- // Optional: the storage-side byte count the caller already
138
- // verified after the upload landed. When provided, commitPut
139
- // skips the otherwise-redundant `statStaging` round-trip — for
140
- // the Vercel backend that's one fewer HTTP HEAD per PUT.
141
- // Caller must ONLY pass a value it observed for THIS stagingId
142
- // after its upload finished (i.e. the REST PUT path's post-upload
143
- // `statStaging`). Safe without a lock because staging ids are
144
- // random — no other request writes this blob — and the sole writer
145
- // (this PUT) has already completed. Tests that drive commitPut
146
- // directly without the REST layer should omit this and let
147
- // commitPut stat for itself.
134
+ // Optional storage-side byte count the caller already verified
135
+ // post-upload. When provided, commitPut skips the redundant
136
+ // `statStaging` round-trip — one fewer Vercel HEAD per PUT. Caller
137
+ // must ONLY pass a value it observed for THIS stagingId after its
138
+ // upload finished (the REST PUT path's post-upload `statStaging`).
139
+ // Safe without a lock: staging ids are random (no other request
140
+ // writes this blob) and the sole writer (this PUT) has finished.
141
+ // Tests driving commitPut directly omit this and let it stat.
148
142
  observedSize?: number
149
143
  }
150
144
 
@@ -181,9 +175,9 @@ type DbRow = {
181
175
  // - the reaper's age grace window + an atomic conditional staging
182
176
  // delete (`deleteStagingIfStale`) so the stale-staging sweep
183
177
  // can't race an upload that just finished.
184
- // These hold both within a single process AND across replicas — the
185
- // old per-(tag, resourceTag) in-process mutex added nothing the CAS +
186
- // content-addressing didn't already give, so it was removed.
178
+ // These hold both within a single process AND across replicas, so no
179
+ // per-(tag, resourceTag) in-process mutex is needed — the CAS +
180
+ // content-addressing already cover everything one would.
187
181
  export type Handle = {
188
182
  // SQLite-only: the underlying `DatabaseSync`. Unset on the Neon
189
183
  // backend (see ./store-neon.ts). Test-only fixture SQL routes
@@ -212,15 +206,14 @@ export type Handle = {
212
206
  selectStagingByWsSid: GetStmt<[string, string], unknown>
213
207
  refreshStagingBegunAt: RunStmt<[number, string, string, string]>
214
208
  deleteStaging: RunStmt<[string, string, string]>
215
- // Atomic conditional staging delete used by the reaper's stale-row
209
+ // Atomic conditional staging delete for the reaper's stale-row
216
210
  // sweep. `deleteStagingIfStale(tag, res, sid, staleBefore)` deletes
217
211
  // the row IFF its `begun_at < staleBefore`, returning `{ ok: 1 }`
218
- // when a row was actually removed and `undefined` otherwise. This is
219
- // the lock-free replacement for the old in-lock begun_at re-read
220
- // (PR #4 "F1"): a slow PUT that finishes and calls
221
- // `refreshStagingBegunAt` bumps `begun_at` fresh, so a concurrent
222
- // reaper's conditional delete simply doesn't match (its predicate
223
- // fails atomically) and the row survives for the commit. Mirrors the
212
+ // when a row was actually removed and `undefined` otherwise. Lock-
213
+ // free race guard (PR #4 "F1"): a slow PUT that finishes calls
214
+ // `refreshStagingBegunAt` to bump `begun_at` fresh, so a concurrent
215
+ // reaper's conditional delete doesn't match (predicate fails
216
+ // atomically) and the row survives for the commit. Mirrors the
224
217
  // `insertLiveIfAbsent` RETURNING pattern.
225
218
  deleteStagingIfStale: GetStmt<[string, string, string, number], { ok: number }>
226
219
  selectLive: AllStmt<[string], DbRow>
@@ -279,9 +272,6 @@ export type SqliteHandle = Handle & { db: DatabaseSync }
279
272
  // transient over-shoot under high concurrency across DIFFERENT
280
273
  // resources is bounded by `(parallel new-resource begins - 1)` and is
281
274
  // accepted (the cap is a soft policy bound, not a security invariant).
282
- // This was already the contract under the old per-resource lock —
283
- // that lock keyed on (tag, resourceTag), so concurrent NEW-resource
284
- // begins held DIFFERENT locks and raced the count anyway.
285
275
  export const MAX_RESOURCES_PER_WORKSPACE = 100
286
276
 
287
277
  // Per-upload byte cap, shared by the WS plane (rejects oversize
@@ -372,12 +362,10 @@ export function openObjstore(db: DatabaseSync, dir: string): SqliteHandle {
372
362
  const blob = openFsBlobBackend(dir)
373
363
  // No `close` method on the returned Handle: the underlying
374
364
  // `DatabaseSync` is owned by the caller (in production, the
375
- // workspace_revision handle in `server/db.ts`, which closes it
376
- // from `shutdown()`). Exposing `close()` here was misleading —
377
- // a callsite reading `await objstoreHandle.close()` would
378
- // reasonably assume it closes something, when in practice it
379
- // either no-op'd (production) or left the connection open
380
- // (tests construct their own DB and `db.close()` separately).
365
+ // workspace_revision handle in `server/db.ts`, closed from
366
+ // `shutdown()`; tests close their own DB). A `close()` here would
367
+ // mislead — it could only no-op or leak, never close the caller-
368
+ // owned connection.
381
369
  return {
382
370
  db,
383
371
  blob,
@@ -522,11 +510,10 @@ export async function beginPut(handle: Handle, input: BeginPutInput): Promise<Be
522
510
  if (liveVersion !== input.prevVersion || liveIncarnation !== input.prevIncarnation) {
523
511
  return { ok: false, reason: 'conflict', conflict: live }
524
512
  }
525
- // Per-workspace resource cap. Only enforced for NEW resources —
526
- // re-uploads of an existing resourceTag (live != null) don't
527
- // change the count, so they're always allowed. Not atomic with the
528
- // insert below (see MAX_RESOURCES_PER_WORKSPACE) — a soft policy
529
- // bound, accepted to over-shoot under concurrent NEW-resource begins.
513
+ // Per-workspace resource cap, NEW resources only — re-uploads
514
+ // (live != null) don't change the count, so they're always allowed.
515
+ // Not atomic with the insert below; soft bound, see
516
+ // MAX_RESOURCES_PER_WORKSPACE.
530
517
  if (!live) {
531
518
  const count = await handle.countLive.get(input.workspaceTag) as { c: number } | undefined
532
519
  if ((count?.c ?? 0) >= MAX_RESOURCES_PER_WORKSPACE) {
@@ -557,51 +544,48 @@ export async function beginPut(handle: Handle, input: BeginPutInput): Promise<Be
557
544
  // IF ABSENT; a re-upload bumps the version IFF it still matches the
558
545
  // precondition we read. Exactly one of N racing commits wins the CAS;
559
546
  // the losers get `conflict` (with the current live row) and rebase.
560
- // Because each PUT is content-addressed at its OWN hash (distinct PUTs
561
- // get distinct hashes — random nonce per encrypt), N racing commits
562
- // promote to N DIFFERENT immutable paths: no promote clobbers another's
563
- // bytes, and a loser's blob is just left unreferenced for the GC. There
564
- // is no metadata-vs-bytes desync to guard against. The
565
- // CAS provides the commit's atomicity both within a process and across
566
- // replicas, so NO in-process lock is taken. A crash between the
567
- // promote and the CAS leaves the staging blob/row intact alongside (at
568
- // most) a stranded, unreferenced live blob — the reaper's stale-staging
569
- // sweep cleans the row, and the GC reaps the unreferenced live blob
570
- // once it's past the grace window, matching the "stranded state,
571
- // reaper-cleaned, never row-pointing-at-nothing" crash-safety contract.
547
+ // Each PUT is content-addressed at its OWN hash (distinct PUTs get
548
+ // distinct hashes — random nonce per encrypt), so N racers promote to
549
+ // N DIFFERENT immutable paths: no promote clobbers another's bytes, a
550
+ // loser's blob is just left unreferenced for the GC, and there's no
551
+ // metadata-vs-bytes desync to guard. The CAS gives atomicity both
552
+ // within a process and across replicas, so NO in-process lock is taken.
553
+ // A crash between the promote and the CAS leaves the staging blob/row
554
+ // intact alongside (at most) a stranded, unreferenced live blob — the
555
+ // stale-staging sweep cleans the row, the GC reaps the blob once past
556
+ // the grace window: the "stranded state, reaper-cleaned, never row-
557
+ // pointing-at-nothing" crash-safety contract.
572
558
  //
573
- // ACCEPTED TRADEOFF (lock removal): with no lock, the reaper no longer
574
- // WAITS for an in-flight upload on this key. An upload taking >1h FROM
575
- // BEGIN (i.e. exceeding the staging TTL during the body) can have its
576
- // staging row reaped mid-flight by `deleteStagingIfStale`; this commit
577
- // then sees no staging row and returns `no-staging` → REST 410. The
578
- // previous lock made the reaper block on any in-flight upload
579
- // (unbounded). Sub-1h uploads are unaffected: `begun_at` (set at begin)
580
- // stays within the TTL through the body, so the conditional delete
581
- // can't match, and the after-body `refreshStagingBegunAt` re-extends
582
- // the TTL to cover this commit step. This matches the staging TTL's
583
- // documented intent ("1h, comfortably over a 50 MiB upload on a slow
584
- // line").
559
+ // ACCEPTED TRADEOFF (lock removal): without a lock the reaper no longer
560
+ // waits for an in-flight upload on this key (the old lock blocked it on
561
+ // any in-flight upload, unbounded). An upload taking >1h FROM BEGIN
562
+ // (exceeding the staging TTL during the body) can have its staging row
563
+ // reaped mid-flight by `deleteStagingIfStale` → this commit sees no
564
+ // staging row → `no-staging` → REST 410. Sub-1h uploads are unaffected:
565
+ // `begun_at` (set at begin) stays within the TTL through the body, so
566
+ // the conditional delete can't match, and the after-body
567
+ // `refreshStagingBegunAt` re-extends the TTL to cover this commit step —
568
+ // matching the staging TTL's intent ("1h, comfortably over a 50 MiB
569
+ // upload on a slow line").
585
570
  export async function commitPut(handle: Handle, input: CommitPutInput): Promise<CommitPutResult> {
586
571
  const staging = await handle.selectStaging.get(input.workspaceTag, input.resourceTag, input.stagingId)
587
572
  if (!staging) return { ok: false, reason: 'no-staging' }
588
573
  let stagedSize: number | null
589
574
  // statStaging failure here is a server-side issue (staging file
590
- // was unlinked by a racing abort / reaper, EACCES, EIO, backend
575
+ // unlinked by a racing abort / reaper, EACCES, EIO, backend
591
576
  // unreachable, …) — not a client length-mismatch. Route through
592
577
  // `io-error` so the REST layer returns 5xx, not 400. PR #4 review.
593
578
  //
594
579
  // The REST PUT layer already statted the staging blob post-upload
595
580
  // and threads the result in via `observedSize` — skipping the
596
- // round-trip saves one Vercel HEAD per PUT. The staging blob can't
597
- // have been resized between that stat and here: staging ids are
598
- // 16-byte random, so no other request targets this blob, and the
599
- // sole writer (this PUT's upload pipeline) has already finished
600
- // before the REST stat ran. The only other actor that touches a
601
- // staging blob is the reaper, which UNLINKS (it never resizes); a
602
- // racing reaper unlink surfaces below as statStaging→io-error or a
603
- // promote failure, not a wrong size. WS / test paths that omit
604
- // `observedSize` fall through to the explicit stat.
581
+ // round-trip saves one Vercel HEAD per PUT. The blob can't have been
582
+ // resized between that stat and here: staging ids are 16-byte random
583
+ // (no other request targets this blob) and the sole writer (this
584
+ // PUT's upload pipeline) has finished. The only other actor on a
585
+ // staging blob is the reaper, which UNLINKS (never resizes); a racing
586
+ // reaper unlink surfaces below as statStaging→io-error or a promote
587
+ // failure, not a wrong size. WS / test paths that omit `observedSize`
588
+ // fall through to the explicit stat.
605
589
  if (input.observedSize === undefined) {
606
590
  try { stagedSize = await handle.blob.statStaging(input.workspaceTag, input.stagingId) }
607
591
  catch { return { ok: false, reason: 'io-error' } }
@@ -637,15 +621,14 @@ export async function commitPut(handle: Handle, input: CommitPutInput): Promise<
637
621
  }
638
622
  // Promote to the CONTENT-ADDRESSED live path `${tag}/${hash}.bin`.
639
623
  // `promoteStagingToLive` returns false on any backend error (FS:
640
- // EACCES / ENOSPC / EIO / a racing abort that already unlinked
641
- // the staging file; Vercel: copy failure). 'io-error' is mapped
642
- // to HTTP 500 by the REST layer — it's a server-side fault, not
643
- // a client-fixable one. Because the destination path IS the content
644
- // hash, any write to it is byte-identical BY CONSTRUCTION, so a
645
- // retried or racing promote to the same path is an idempotent
646
- // rewrite, never a clobber. (Distinct PUTs get distinct hashes — a
647
- // fresh random nonce per encrypt makes each ciphertext unique — so
648
- // concurrent commits to the same resource write to DIFFERENT paths.)
624
+ // EACCES / ENOSPC / EIO / a racing abort that already unlinked the
625
+ // staging file; Vercel: copy failure) → 'io-error' → REST HTTP 500
626
+ // (server-side fault, not client-fixable). The destination path IS
627
+ // the content hash, so any write to it is byte-identical BY
628
+ // CONSTRUCTION: a retried or racing promote to the same path is an
629
+ // idempotent rewrite, never a clobber. (Distinct PUTs get distinct
630
+ // hashes — fresh random nonce per encrypt — so concurrent commits to
631
+ // the same resource write to DIFFERENT paths.)
649
632
  if (!await handle.blob.promoteStagingToLive(input.workspaceTag, input.stagingId, staging.content_hash)) {
650
633
  return { ok: false, reason: 'io-error' }
651
634
  }
@@ -658,13 +641,12 @@ export async function commitPut(handle: Handle, input: CommitPutInput): Promise<
658
641
  const committedIncarnation = staging.prev_version == null ? freshIncarnation : staging.prev_incarnation!
659
642
  const putAt = Date.now()
660
643
  // Version-CAS in try/catch so a Neon transient (5xx, network
661
- // hiccup, connection-pool exhaustion) doesn't bypass the abortPut
662
- // ladder by throwing out of commitPut. Without this guard, a thrown
663
- // rejection skips the REST layer's `if (!r.ok) abortPut` branch and
664
- // bubbles to handleRest's outer catch — the live blob is already
665
- // promoted, the staging blob + row stay, and the client sees a 500.
666
- // The reaper reconciles (stale-staging sweep + unreferenced-blob
667
- // GC) but the surface is a 500 the caller can retry.
644
+ // hiccup, pool exhaustion) doesn't throw out of commitPut and bypass
645
+ // the abortPut ladder. A thrown rejection would skip the REST layer's
646
+ // `if (!r.ok) abortPut` branch and bubble to handleRest's outer catch
647
+ // — live blob already promoted, staging blob + row left behind. The
648
+ // reaper reconciles (stale-staging sweep + unreferenced-blob GC); the
649
+ // surface is a 500 the caller can retry.
668
650
  let won: { ok: number } | undefined
669
651
  try {
670
652
  if (staging.prev_version == null) {
@@ -753,30 +735,28 @@ export async function abortPut(handle: Handle, tag: string, resourceTag: string,
753
735
  // `deletedVersion = 0` sentinel tells the broadcast path to skip.
754
736
  //
755
737
  // No lock: the drop is a version-CAS (`deleteLiveCAS` — DELETE WHERE
756
- // version = prev), so every race resolves to exactly one winner, the
757
- // same as the old lock did, with no lost update:
738
+ // version = prev), so every race resolves to exactly one winner with
739
+ // no lost update:
758
740
  // - delete vs. a concurrent COMMIT on the same resource: whichever
759
741
  // CAS lands first wins. A stale delete can NOT remove a row the
760
- // commit just bumped — the `WHERE version = prev` no longer matches,
761
- // so the delete gets `conflict` (re-read sees the bumped version).
762
- // If the delete wins, the commit's `updateLiveCAS` matches no row →
763
- // `conflict`. (The earlier draft used an UNCONDITIONAL delete here,
764
- // which — without the lock — let `getLive` read v1, a commit bump
765
- // to v2 slip in, and the delete then destroy v2: a lost update. The
766
- // version-CAS closes that.)
742
+ // commit just bumped — `WHERE version = prev` no longer matches, so
743
+ // the delete gets `conflict` (re-read sees the bumped version). If
744
+ // the delete wins, the commit's `updateLiveCAS` matches no row →
745
+ // `conflict`. (An UNCONDITIONAL delete here would let `getLive`
746
+ // read v1, a commit bump to v2 slip in, then the delete destroy v2:
747
+ // a lost update. The version-CAS closes that.)
767
748
  // - two concurrent deletes with the same prevVersion: one CAS removes
768
749
  // the row; the other matches no row → re-read → `not-found`.
769
750
  //
770
751
  // On success we ONLY drop the live row — we do NOT unlink the live
771
- // blob. Blob reclamation is deferred to the reaper's grace-window GC
752
+ // blob. Reclamation is deferred to the reaper's grace-window GC
772
753
  // (unlinks once no live row references the hash AND it's older than the
773
- // grace window) rather than done inline, so the drop stays lock-free
774
- // and can't race two things: a concurrent commit's promote→CAS window
775
- // (a just-promoted blob isn't referenced yet — the age grace protects
776
- // it) and an in-flight GET still streaming the bytes. NOT because the
777
- // hash might be shared — distinct PUTs get distinct hashes (random
778
- // nonce per encrypt → unique ciphertext), so the hash↔row mapping is
779
- // effectively 1:1; this delete simply orphans the blob for the GC.
754
+ // grace window) so the drop stays lock-free and can't race a concurrent
755
+ // commit's promote→CAS window (a just-promoted blob isn't referenced
756
+ // yet — the age grace protects it) or an in-flight GET still streaming
757
+ // the bytes. NOT because the hash might be shared — distinct PUTs get
758
+ // distinct hashes (random nonce → unique ciphertext), so the hash↔row
759
+ // mapping is effectively 1:1; this delete simply orphans the blob.
780
760
  export async function deleteObject(
781
761
  handle: Handle, tag: string, resourceTag: string, prevVersion: number | null, prevIncarnation: string | null,
782
762
  ): Promise<DeleteResult> {