@preventive/triage 1.0.0-alpha.2 → 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.
@@ -119,21 +119,19 @@ type VercelBlobSdk = {
119
119
 
120
120
  // Recognise "blob is gone" errors uniformly across read/write/delete
121
121
  // paths so callers can treat them as success (delete) or
122
- // not-found (read). The SDK exposes BlobNotFoundError as a class
123
- // with `.name === 'BlobNotFoundError'`; checking the name string
124
- // avoids importing the class at the top level (which would force
125
- // the optional peer dep to resolve).
122
+ // not-found (read). The SDK exposes BlobNotFoundError as a class with
123
+ // `.name === 'BlobNotFoundError'`; checking the name string avoids
124
+ // importing the class at the top level (which would force the optional
125
+ // peer dep to resolve).
126
126
  //
127
- // Class-name check ONLY. The SDK's internal mapper translates every
128
- // API `not_found` code into BlobNotFoundError-by-name; a bare-404
129
- // transport leak doesn't reach here. A prior version of this
130
- // function had a `/does not exist|\b404\b/` fallback that
131
- // DANGEROUSLY matched BlobStoreNotFoundError's message "This store
132
- // does not exist." — a config fault (revoked token, deleted store)
133
- // would silently surface as every-blob-missing across reads and
134
- // unlinks, masking the fatal misconfiguration. The tight name check
135
- // lets BlobStoreNotFoundError / other classes propagate as real
136
- // exceptions.
127
+ // Class-name check ONLY — the SDK's internal mapper turns every API
128
+ // `not_found` into BlobNotFoundError-by-name, so a bare-404 transport
129
+ // leak doesn't reach here. A broader `/does not exist|\b404\b/` match
130
+ // is DANGEROUS: it also matches BlobStoreNotFoundError's "This store
131
+ // does not exist.", so a config fault (revoked token, deleted store)
132
+ // would silently surface as every-blob-missing across reads/unlinks,
133
+ // masking the fatal misconfiguration. The tight name check lets
134
+ // BlobStoreNotFoundError / other classes propagate as real exceptions.
137
135
  function isNotFound(err: unknown): boolean {
138
136
  if (err == null || typeof err !== 'object') return false
139
137
  const name = (err as { name?: unknown }).name
@@ -242,20 +240,17 @@ function buildOpenStagingWriter(sdk: VercelBlobSdk, token: string): BlobBackend[
242
240
  abortSignal: ac.signal,
243
241
  })
244
242
  // Defuse a possible unhandled-rejection if abort() is called
245
- // BEFORE finalize() (the REST layer's error path). Attach a
246
- // detached `.catch` on the original promise so an early
247
- // rejection has a handler; finalize() awaits `putPromise`
248
- // directly, which still re-throws the original rejection
249
- // (the .catch returns a separate chain that doesn't replace
250
- // putPromise's state).
243
+ // BEFORE finalize() (the REST error path). The detached `.catch`
244
+ // gives an early rejection a handler; finalize() still awaits
245
+ // `putPromise` directly and re-throws the original rejection (the
246
+ // .catch is a separate chain, not a replacement of putPromise).
251
247
  putPromise.catch(() => {})
252
248
  return {
253
249
  writable: pt,
254
250
  // Await the upload's completion. After pipeline(req, counter,
255
- // pt) resolves, pt has emitted 'end' on the read side and
256
- // `put` is finalising the last multipart part. Awaiting here
257
- // gives us the same "bytes durable" guarantee that
258
- // pipeline-to-WriteStream gives the FS backend.
251
+ // pt) resolves, pt has emitted 'end' and `put` is finalising the
252
+ // last multipart part. Awaiting here gives the same "bytes
253
+ // durable" guarantee pipeline-to-WriteStream gives the FS backend.
259
254
  finalize: async () => { await putPromise },
260
255
  // Await the SDK's put-promise settlement (rejected via the
261
256
  // AbortController). Without this await, a slow upload that
@@ -315,11 +310,11 @@ function buildPromoteStagingToLive(sdk: VercelBlobSdk, token: string): BlobBacke
315
310
  //
316
311
  // `allowOverwrite: true` because the content-addressed live
317
312
  // pathname `${tag}/${contentHash}.bin` can be (re)written by a
318
- // retried or racing promote of the same blob. The destination
319
- // bytes are identical by construction (the path IS the hash), so
320
- // the overwrite is idempotent — never a clobber of DIFFERENT
321
- // bytes. Without the flag, the SDK sends `x-allow-overwrite: 0`
322
- // and Vercel rejects any such re-promote with BlobAccessError.
313
+ // retried or racing promote of the same blob. Destination bytes
314
+ // are identical by construction (the path IS the hash), so the
315
+ // overwrite is idempotent — never a clobber of DIFFERENT bytes.
316
+ // Without the flag the SDK sends `x-allow-overwrite: 0` and
317
+ // Vercel rejects the re-promote with BlobAccessError.
323
318
  await sdk.copy(from, to, {
324
319
  access: 'private',
325
320
  allowOverwrite: true,
@@ -356,10 +351,26 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
356
351
  // origin truth. Origin fetch is the right default for a store
357
352
  // where freshness > latency.
358
353
  try { res = await sdk.get(path, { access: 'private', useCache: false, token }) } catch (err) {
359
- if (isNotFound(err)) return { ok: false, reason: 'not-found' }
354
+ // A missing blob HERE is never "the resource doesn't exist" — the
355
+ // REST layer (rest.ts openLiveSnapshot) already confirmed a live row
356
+ // whose (version, incarnation) matches the GET token before calling
357
+ // us. So BlobNotFoundError means the bytes for a still-live row are
358
+ // momentarily gone: the reaper GC'd a hash a racing version-bump just
359
+ // unreferenced, or Vercel's read-after-write / edge propagation hasn't
360
+ // caught up to a freshly-promoted private blob. That is the documented
361
+ // `unavailable` (HTTP 503) transient — reconciled by reaper /
362
+ // propagation, retried by the client — NOT a 404. Returning
363
+ // `not-found` would emit a 404 the FS backend never emits for the
364
+ // same condition (blob-fs.ts maps ENOENT → `unavailable`), telling
365
+ // the client the resource is gone for good when it should refetch.
366
+ // See server/README.md's GET status table.
367
+ if (isNotFound(err)) return { ok: false, reason: 'unavailable' }
360
368
  throw err
361
369
  }
362
- if (res == null) return { ok: false, reason: 'not-found' }
370
+ // SDK returned null (no blob) — same "bytes missing for a live row"
371
+ // transient as the BlobNotFoundError branch above → `unavailable`, not
372
+ // `not-found`.
373
+ if (res == null) return { ok: false, reason: 'unavailable' }
363
374
  // statusCode 304 doesn't reach here in practice — the REST
364
375
  // GET layer doesn't pass If-None-Match — but a future call
365
376
  // site could. Treat as unavailable rather than streaming a
@@ -367,23 +378,24 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
367
378
  if (res.statusCode !== 200 || res.stream == null) {
368
379
  return { ok: false, reason: 'unavailable' }
369
380
  }
370
- // `@vercel/blob@2.x`'s streaming `get()` for private blobs
371
- // returns the body but does NOT populate `blob.size` and does
372
- // NOT pass a `content-length` header through (verified
373
- // empirically: get.size=0, content-length-hdr=null while
374
- // head.size reports the true byte count). The REST layer
375
- // depends on a size to set `content-length` on its response
376
- // and for the integrity check against the DB row, so when
377
- // get() leaves it 0/null we fall back to a head() lookup.
378
- // Two round-trips per private read on Vercel until the SDK is
379
- // fixed — small price vs. the alternative of 503ing every read.
381
+ // `@vercel/blob@2.x`'s streaming `get()` for private blobs returns
382
+ // the body but does NOT populate `blob.size` nor pass a
383
+ // `content-length` header (verified empirically: get.size=0,
384
+ // content-length-hdr=null, while head.size reports the true count).
385
+ // The REST layer needs a size to set `content-length` and for the
386
+ // integrity check against the DB row, so on 0/null we fall back to
387
+ // head(). Two round-trips per private read until the SDK is fixed —
388
+ // small price vs. 503ing every read.
380
389
  let size: number | null | undefined = res.blob?.size
381
390
  if (size == null || size === 0) {
382
391
  try {
383
392
  const h = await sdk.head(path, { token })
384
393
  size = (h as { size?: number })?.size
385
394
  } catch (headErr) {
386
- if (isNotFound(headErr)) return { ok: false, reason: 'not-found' }
395
+ // Blob vanished between get() and the head() size fallback (a
396
+ // racing reaper GC) — still the "live row present, bytes gone"
397
+ // transient, so `unavailable` (503), matching the get() path above.
398
+ if (isNotFound(headErr)) return { ok: false, reason: 'unavailable' }
387
399
  throw headErr
388
400
  }
389
401
  }
@@ -83,13 +83,20 @@ export type LiveReader = {
83
83
  close(): Promise<void>
84
84
  }
85
85
 
86
- // `not-found` maps to HTTP 404 (the live blob is gone or never
87
- // existed); `unavailable` maps to HTTP 503 (transient backend issue
88
- // the reaper will eventually sort out). The REST layer uses this
89
- // discrimination to set the right status code.
86
+ // A missing blob is always `unavailable` (→ HTTP 503), never a 404. The
87
+ // byte plane has NO view of the metadata row, so it can't decide whether
88
+ // a resource "doesn't exist" — only whether specific bytes are present
89
+ // right now. The authoritative "this resource/version is gone" 404 is the
90
+ // REST layer's call, made from the live row BEFORE it opens a reader
91
+ // (rest.ts openLiveSnapshot). By the time `openLiveReader` runs the row is
92
+ // already confirmed, so an absent blob means a transient bytes/metadata
93
+ // desync the reaper (or store propagation) reconciles — exactly the
94
+ // `unavailable`/503 contract, which the client retries. Both backends MUST
95
+ // map a missing blob to `unavailable` (FS: ENOENT; Vercel: BlobNotFoundError
96
+ // / null get()). No 404-mapping variant exists here so that bug can't recur.
90
97
  export type OpenLiveResult =
91
98
  | { ok: true; reader: LiveReader }
92
- | { ok: false; reason: 'not-found' | 'unavailable' }
99
+ | { ok: false; reason: 'unavailable' }
93
100
 
94
101
  export type BlobBackend = {
95
102
  // Per-workspace setup. FS creates the on-disk staging directory;
@@ -128,9 +135,10 @@ export type BlobBackend = {
128
135
  promoteStagingToLive(tag: string, stagingId: string, contentHash: string): Promise<boolean>
129
136
 
130
137
  // Open a streaming reader for the content-addressed live blob.
131
- // `not-found` lets the REST layer return 404; `unavailable` returns
132
- // 503 for a transient state (file/blob missing while the row still
133
- // exists — reaper will reconcile on the next sweep).
138
+ // Called only after the REST layer has confirmed the live row, so a
139
+ // missing blob is the transient "row present, bytes gone" state →
140
+ // `unavailable` (HTTP 503), which the reaper reconciles and the client
141
+ // retries. Never a 404 from here — see OpenLiveResult above.
134
142
  openLiveReader(tag: string, contentHash: string): Promise<OpenLiveResult>
135
143
 
136
144
  // Idempotent deletes. Backends MUST tolerate "already gone" as
@@ -68,11 +68,11 @@ function urlPathFor(tag: string, resourceTag: string): string {
68
68
  // Shared gate every objstore handler runs after its message-specific
69
69
  // field checks: fetch the socket's challenge nonce, verify the signed
70
70
  // message against it, then re-confirm the socket is still OPEN — the
71
- // close handler may have fired during the verify await, and attaching
72
- // to / replying on a closed socket is the half-handshake leak case
73
- // (PR #4 review F4). Returns true when the caller may proceed.
74
- // Centralising the post-await readyState recheck keeps that
75
- // easy-to-forget invariant in one auditable place.
71
+ // close handler may have fired during the verify await, and replying
72
+ // on a closed socket is the half-handshake leak case (PR #4 review
73
+ // F4). Centralising the post-await readyState recheck keeps that
74
+ // invariant in one auditable place. Returns true when the caller may
75
+ // proceed.
76
76
  async function verified<M extends { workspaceTag?: unknown }>(
77
77
  deps: ObjstoreDeps, socket: WebSocket, msg: M, label: string,
78
78
  verify: (m: M, nonce: string) => Promise<boolean>,
@@ -96,12 +96,10 @@ async function handlePutBegin(deps: ObjstoreDeps, socket: WebSocket, msg: Objsto
96
96
  // signature to fail. Cheaper to reject up-front, and consistent
97
97
  // with `verifyObjstorePutSig`'s `isSafeNonNegativeInt` gate.
98
98
  if (!Number.isSafeInteger(msg.expectedLength) || (msg.expectedLength as number) < 0 || (msg.expectedLength as number) > MAX_CONTENT_LENGTH) return
99
- // Symmetric with `handleDelete`'s prevVersion gate (line 116) and
100
- // `verifyObjstorePutSig`'s `isSafeIntOrNull` (sign.ts:119). Without
101
- // this, a non-safe-integer `prevVersion` (NaN, 2^53+1, ...) would
102
- // pass the typeof check below and reach sig verify, burning a
103
- // hash + Ed25519 round-trip on a guaranteed-fail input. Input-
104
- // validation audit `server/objstore/handlers.ts:76`.
99
+ // Same up-front reject for `prevVersion` (symmetric with handleDelete
100
+ // and `verifyObjstorePutSig`'s `isSafeIntOrNull`): a non-safe-integer
101
+ // (NaN, 2^53+1, ...) would pass the typeof check below and reach sig
102
+ // verify, burning a hash + Ed25519 round-trip on a guaranteed fail.
105
103
  if (msg.prevVersion != null && (typeof msg.prevVersion !== 'number' || !Number.isSafeInteger(msg.prevVersion))) return
106
104
  if (!await verified(deps, socket, msg, 'put-begin', verifyObjstorePutSig)) return
107
105
  const tag = msg.workspaceTag
@@ -110,8 +108,8 @@ async function handlePutBegin(deps: ObjstoreDeps, socket: WebSocket, msg: Objsto
110
108
  // workspace tag (no rows in workspace_revision AND none in
111
109
  // workspace_object). Mirrors handleSave in server/index.ts; runs
112
110
  // AFTER sig verify so `unauthorized` only reaches a legitimate
113
- // signer. The gate is config-driven (server/config.json
114
- // `password`) and is a no-op when no password is configured.
111
+ // signer. Config-driven (server/config.json `password`), no-op when
112
+ // unconfigured.
115
113
  if (deps.authGate && deps.sendUnauthorized && await deps.authGate(socket, tag)) {
116
114
  if (socket.readyState !== socket.OPEN) return
117
115
  if (deps.debug) console.warn(`reject objstore-put-begin: unauthorized (new workspace ${debugTag(tag)})`)
@@ -56,6 +56,16 @@ export type ObjstoreInit = {
56
56
  }
57
57
 
58
58
  export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
59
+ // Fail loud on a lopsided auth config. The put-begin gate in
60
+ // handlers.ts only fires when BOTH authGate and sendUnauthorized are
61
+ // present (it needs the reporter to emit the `unauthorized` frame),
62
+ // so wiring authGate WITHOUT sendUnauthorized silently fails OPEN —
63
+ // unauthenticated first-writes to unknown workspaces would be accepted
64
+ // despite the operator's intent to gate them. Reject at boot rather
65
+ // than regress access control silently.
66
+ if (deps.authGate && !deps.sendUnauthorized) {
67
+ throw new Error('initObjstore: authGate requires sendUnauthorized (the put-begin gate needs it to emit the unauthorized frame)')
68
+ }
59
69
  const handle = deps.handle
60
70
  const secret = deps.tokenSecret ?? newTokenSecret()
61
71
  const handlers = createObjstoreHandlers({
@@ -93,15 +103,14 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
93
103
  // on-disk state still has stranded files from a prior crash.
94
104
  const startupReap = enqueueSweep()
95
105
  // Jittered start of the periodic timer. Multi-replica deploys
96
- // (Neon + Vercel Blob) commonly boot N replicas in tight lock-
97
- // step (deploy rollout, cluster restart) and would otherwise
98
- // sync every replica's reaper at the same wall-clock tick,
99
- // hammering the DB + blob store with N×readdir+lock-acquire
100
- // bursts. A random first-interval delay deconcurrencies the
101
- // cluster without changing the long-term sweep cadence.
102
- // Jitter range is 0…1× reapIntervalMs (i.e., the next sweep
103
- // happens at [interval, 2×interval] after boot); subsequent
104
- // sweeps stay at exactly `reapIntervalMs` apart.
106
+ // (Neon + Vercel Blob) commonly boot N replicas in tight lock-step
107
+ // (deploy rollout, cluster restart), which would otherwise sync every
108
+ // replica's reaper to the same wall-clock tick and hammer the DB +
109
+ // blob store with N×readdir+lock-acquire bursts. A random
110
+ // first-interval delay spreads the cluster out without changing the
111
+ // long-term cadence. Jitter is 0…1× reapIntervalMs (first sweep at
112
+ // [interval, 2×interval] after boot); subsequent sweeps stay exactly
113
+ // `reapIntervalMs` apart.
105
114
  let reapTimer: ReturnType<typeof setInterval> | null = null
106
115
  const jitterMs = Math.floor(Math.random() * deps.reapIntervalMs)
107
116
  const firstTimer = setTimeout(() => {
@@ -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,