@preventive/triage 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/common/save-error-reason.ts +53 -0
  3. package/common/utf8.d.ts +13 -0
  4. package/common/utf8.js +57 -0
  5. package/out/brotli-fallback.js +3 -0
  6. package/out/client-sync.js +15 -0
  7. package/out/graph.js +4 -0
  8. package/out/icon-maskable.svg +5 -0
  9. package/out/icon.svg +5 -0
  10. package/out/index.html +78 -0
  11. package/out/manifest.webmanifest +30 -0
  12. package/out/prism.js +14 -0
  13. package/out/terminal.js +39 -0
  14. package/out/view.css +1 -0
  15. package/out/view.html +12 -0
  16. package/out/view.js +138 -0
  17. package/package.json +129 -0
  18. package/server/auth.ts +99 -0
  19. package/server/config.example.json +3 -0
  20. package/server/config.ts +196 -0
  21. package/server/db-neon.ts +374 -0
  22. package/server/db-revision-sql.ts +152 -0
  23. package/server/db-stmt.ts +53 -0
  24. package/server/db.ts +577 -0
  25. package/server/http.ts +142 -0
  26. package/server/hub.ts +98 -0
  27. package/server/index.ts +353 -0
  28. package/server/lifecycle.ts +177 -0
  29. package/server/neon-driver.ts +26 -0
  30. package/server/objstore/blob-fs.ts +164 -0
  31. package/server/objstore/blob-vercel.ts +508 -0
  32. package/server/objstore/blob.ts +169 -0
  33. package/server/objstore/fs.ts +67 -0
  34. package/server/objstore/handlers.ts +235 -0
  35. package/server/objstore/init.ts +118 -0
  36. package/server/objstore/reaper.ts +199 -0
  37. package/server/objstore/rest.ts +484 -0
  38. package/server/objstore/sign.ts +164 -0
  39. package/server/objstore/store-neon.ts +351 -0
  40. package/server/objstore/store.ts +799 -0
  41. package/server/objstore/tokens.ts +168 -0
  42. package/server/origin.ts +68 -0
  43. package/server/peer.ts +38 -0
  44. package/server/sign.ts +231 -0
  45. package/server/static.ts +374 -0
  46. package/server/sync-handlers.ts +311 -0
  47. package/server/util.ts +27 -0
  48. package/server/validation.ts +36 -0
  49. package/server/ws-server.ts +245 -0
@@ -0,0 +1,508 @@
1
+ // Vercel Blob Private Storage BlobBackend. Paired with the Neon DB
2
+ // plane in `server/index.ts` when `BLOB_READ_WRITE_TOKEN` is set;
3
+ // the combination is the supported multi-replica deployment shape
4
+ // (Neon for metadata, Vercel Blob for bytes — both serverless, both
5
+ // HTTP-backed, no shared filesystem required).
6
+ //
7
+ // `@vercel/blob` is an OPTIONAL peer dep — loaded lazily inside
8
+ // `openVercelBlobBackend` below so a SQLite/FS deployment never
9
+ // reaches the import. Same dynamic-import pattern as db-neon.ts.
10
+ //
11
+ // Pathname layout (mirrors the FS backend's directory layout so the
12
+ // reaper logic stays identical). Live blobs are content-addressed —
13
+ // the pathname's filename is the content hash, not the resourceTag:
14
+ // ${tag}/${contentHash}.bin — live
15
+ // ${tag}/.staging/${stagingId}.bin — staging
16
+ //
17
+ // All blobs are created with `access: 'private'` so the URL alone
18
+ // is not sufficient to fetch them; every read/write goes through the
19
+ // token. The downstream REST GET layer pipes the SDK's `get()`
20
+ // stream straight to the response without ever materialising the
21
+ // public URL.
22
+ //
23
+ // Crash-safety contract (mirrors blob-fs.ts but via copy+del instead
24
+ // of fsync+rename):
25
+ // PUT commit: put(staging) → copy(staging → live) → DB version-CAS
26
+ // → del(staging)
27
+ // DELETE: DB row drop (the reaper GCs the now-unreferenced blob)
28
+ // A crash mid-`copy` leaves NO live blob (Vercel's copy is atomic
29
+ // per-blob). A crash between `copy` and the CAS leaves a stranded,
30
+ // unreferenced live blob; a crash after the CAS but before
31
+ // `del(staging)` leaves a stranded staging blob. The reaper reconciles
32
+ // both: `reapUnreferencedForTag` GCs a live blob that no live row
33
+ // references (once past the grace window — content blobs are immutable
34
+ // and may be shared, so GC is keyed on the referenced-hash set, not on
35
+ // any single resource), and the stale-staging TTL sweep drops orphan
36
+ // staging blobs.
37
+
38
+ import { PassThrough, type Readable } from 'node:stream'
39
+ import { Buffer } from 'node:buffer'
40
+ import type { BlobBackend, OpenLiveResult, StagingWriter } from './blob.ts'
41
+ import { errMsg } from '../util.ts'
42
+
43
+ // Minimal structural shape of the bits of `@vercel/blob` we use.
44
+ // Kept local so the optional peer dep doesn't have to type-resolve
45
+ // for SQLite-only deployments. Real type details live in the
46
+ // installed package; the fields/parameters we touch here are stable
47
+ // per the SDK v2 public surface.
48
+ //
49
+ // `put` accepts the SDK's full `PutBody` union (string | Readable |
50
+ // Buffer | Blob | ArrayBuffer | ReadableStream | File). We only ever
51
+ // pass a Node `PassThrough` (a Readable), but the wider type lets
52
+ // callers reuse this signature for future buffer/blob bodies without
53
+ // type gymnastics. `copy` accepts `allowOverwrite` — REQUIRED for
54
+ // version bumps since the live pathname is reused on re-upload.
55
+ type VercelBlobBody = Readable | Buffer | string | Blob | ArrayBuffer | ReadableStream<Uint8Array>
56
+ type VercelBlobSdk = {
57
+ put: (
58
+ pathname: string,
59
+ body: VercelBlobBody,
60
+ options: {
61
+ access: 'private' | 'public'
62
+ allowOverwrite?: boolean
63
+ contentType?: string
64
+ token?: string
65
+ multipart?: boolean
66
+ abortSignal?: AbortSignal
67
+ cacheControlMaxAge?: number
68
+ },
69
+ ) => Promise<{ url: string; pathname: string }>
70
+ head: (
71
+ pathname: string,
72
+ options?: { token?: string; abortSignal?: AbortSignal },
73
+ ) => Promise<{ size: number; pathname: string; url: string }>
74
+ get: (
75
+ pathname: string,
76
+ options: {
77
+ access: 'private' | 'public'
78
+ token?: string
79
+ useCache?: boolean
80
+ abortSignal?: AbortSignal
81
+ },
82
+ ) => Promise<{
83
+ statusCode: 200 | 304
84
+ stream: ReadableStream<Uint8Array> | null
85
+ blob: { size: number | null }
86
+ } | null>
87
+ copy: (
88
+ fromPathname: string,
89
+ toPathname: string,
90
+ options: {
91
+ access: 'private' | 'public'
92
+ allowOverwrite?: boolean
93
+ token?: string
94
+ contentType?: string
95
+ cacheControlMaxAge?: number
96
+ },
97
+ ) => Promise<{ url: string; pathname: string }>
98
+ del: (
99
+ urlOrPathname: string | string[],
100
+ options?: { token?: string; abortSignal?: AbortSignal },
101
+ ) => Promise<void>
102
+ list: (options: {
103
+ prefix?: string
104
+ cursor?: string
105
+ limit?: number
106
+ mode?: 'expanded' | 'folded'
107
+ token?: string
108
+ }) => Promise<{
109
+ // `uploadedAt` (a Date per the SDK v2 surface) is the blob's
110
+ // creation time — used by the reaper's GC grace window. Optional
111
+ // in the type because the `folded` listing path ignores it (only
112
+ // `listLiveBlobs` reads it, and it uses the default expanded mode).
113
+ blobs: Array<{ pathname: string; size: number; uploadedAt?: Date | string | number }>
114
+ folders?: string[]
115
+ cursor?: string
116
+ hasMore: boolean
117
+ }>
118
+ }
119
+
120
+ // Recognise "blob is gone" errors uniformly across read/write/delete
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).
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.
137
+ function isNotFound(err: unknown): boolean {
138
+ if (err == null || typeof err !== 'object') return false
139
+ const name = (err as { name?: unknown }).name
140
+ return typeof name === 'string' && name === 'BlobNotFoundError'
141
+ }
142
+
143
+ function liveBlobPath(tag: string, contentHash: string): string {
144
+ return `${tag}/${contentHash}.bin`
145
+ }
146
+ function stagingBlobPath(tag: string, stagingId: string): string {
147
+ return `${tag}/.staging/${stagingId}.bin`
148
+ }
149
+
150
+ // Coerce the SDK's `uploadedAt` (Date | ISO string | epoch-ms number)
151
+ // into epoch-ms for the reaper's grace-window comparison. The real SDK
152
+ // always returns a valid `uploadedAt`; a missing / unparseable value
153
+ // fails CLOSED to "just now" so the grace window still shields the
154
+ // blob from GC. This matters because the grace window is the ONLY guard
155
+ // during a commit's promote→CAS gap: no live row exists yet, so the
156
+ // reaper's live-set re-read can't cover it. Reading an unknown
157
+ // timestamp as "ancient" would let a sweep collect a just-promoted blob
158
+ // out from under an in-flight commit (a metadata→bytes desync). We
159
+ // prefer a (theoretical, real-SDK-unreachable) storage leak over that.
160
+ function uploadedAtMs(uploadedAt: Date | string | number | undefined): number {
161
+ if (uploadedAt == null) return Date.now()
162
+ if (typeof uploadedAt === 'number') return Number.isFinite(uploadedAt) ? uploadedAt : Date.now()
163
+ const t = new Date(uploadedAt).getTime()
164
+ return Number.isFinite(t) ? t : Date.now()
165
+ }
166
+
167
+ // Strip a `.bin` suffix and reject anything else. Same defensive
168
+ // shape the FS backend uses (the reaper refuses to touch foreign
169
+ // files). The validator in store.ts rejects malformed tags at the
170
+ // wire boundary; this is belt-and-braces for the listing path.
171
+ function stripBinSuffix(pathname: string, prefix: string): string | null {
172
+ if (!pathname.startsWith(prefix) || !pathname.endsWith('.bin')) return null
173
+ return pathname.slice(prefix.length, -4)
174
+ }
175
+
176
+ // Exhaust a paginated list() call. Vercel returns a cursor when
177
+ // hasMore is true; we walk it to completion so the reaper gets every
178
+ // blob. The 1000-per-page default limit means a workspace with N
179
+ // resources costs ceil(N/1000) round-trips — acceptable for the
180
+ // periodic sweep cadence (10 minutes by default).
181
+ async function listAll(
182
+ sdk: VercelBlobSdk,
183
+ opts: { prefix?: string; mode?: 'expanded' | 'folded'; token: string },
184
+ ): Promise<{ blobs: Array<{ pathname: string; uploadedAt?: Date | string | number | undefined }>; folders: string[] }> {
185
+ const blobs: Array<{ pathname: string; uploadedAt?: Date | string | number | undefined }> = []
186
+ const folders: string[] = []
187
+ let cursor: string | undefined
188
+ // Bounded loop to defend against a misbehaving driver that returns
189
+ // `hasMore: true` with no cursor (would infinite-loop otherwise).
190
+ // 10k pages × 1000 entries = 10M entries — far past the 100-
191
+ // resource per-workspace cap. Hitting this is a server-side bug;
192
+ // log and stop rather than spinning.
193
+ for (let i = 0; i < 10_000; i++) {
194
+ const callOpts: Parameters<VercelBlobSdk['list']>[0] = { token: opts.token }
195
+ if (opts.prefix !== undefined) callOpts.prefix = opts.prefix
196
+ if (opts.mode !== undefined) callOpts.mode = opts.mode
197
+ if (cursor !== undefined) callOpts.cursor = cursor
198
+ const page = await sdk.list(callOpts)
199
+ for (const b of page.blobs) blobs.push({ pathname: b.pathname, uploadedAt: b.uploadedAt })
200
+ if (page.folders) for (const f of page.folders) folders.push(f)
201
+ if (!page.hasMore) return { blobs, folders }
202
+ if (!page.cursor) {
203
+ console.warn('vercel-blob list: hasMore=true with no cursor; stopping')
204
+ return { blobs, folders }
205
+ }
206
+ cursor = page.cursor
207
+ }
208
+ console.warn('vercel-blob list: exceeded 10k pages; returning partial result')
209
+ return { blobs, folders }
210
+ }
211
+
212
+ // Per-method builders — extracted so `openVercelBlobBackend` stays
213
+ // within the max-lines-per-function budget. Each closes over the
214
+ // SDK instance + token. Same shape as the per-statement builders
215
+ // in store-neon.ts.
216
+
217
+ function buildOpenStagingWriter(sdk: VercelBlobSdk, token: string): BlobBackend['openStagingWriter'] {
218
+ // eslint-disable-next-line require-await
219
+ return async (tag, stagingId): Promise<StagingWriter> => {
220
+ // PassThrough is the bridge — the REST PUT pipeline writes
221
+ // into it (Node-stream side), and the SDK's `put` reads from
222
+ // it (web-stream-or-Node-stream, the SDK supports both). When
223
+ // the pipeline ends the PT, `put` sees EOF and finalises its
224
+ // upload.
225
+ const pt = new PassThrough()
226
+ const ac = new AbortController()
227
+ const putPromise = sdk.put(stagingBlobPath(tag, stagingId), pt, {
228
+ access: 'private',
229
+ // Staging keys are 16-byte random base64url; a collision is
230
+ // 1/2^128. `allowOverwrite: true` keeps a retry of the same
231
+ // stagingId from failing with the SDK's default "exists"
232
+ // error — the REST layer's `inFlightSids` set already
233
+ // prevents concurrent writes to the same sid.
234
+ allowOverwrite: true,
235
+ contentType: 'application/octet-stream',
236
+ token,
237
+ // Multipart for streaming uploads with unknown / large size.
238
+ // Required so the SDK doesn't try to buffer the whole body
239
+ // before initiating the upload — we cap at 100 MiB but the
240
+ // SDK shouldn't hold that in memory either.
241
+ multipart: true,
242
+ abortSignal: ac.signal,
243
+ })
244
+ // 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).
251
+ putPromise.catch(() => {})
252
+ return {
253
+ writable: pt,
254
+ // 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.
259
+ finalize: async () => { await putPromise },
260
+ // Await the SDK's put-promise settlement (rejected via the
261
+ // AbortController). Without this await, a slow upload that
262
+ // the REST layer thinks it canceled can KEEP UPLOADING in
263
+ // the background for several seconds, racing the post-abort
264
+ // staging-blob deletion and silently recreating it. The
265
+ // putPromise.catch() above swallows the abort rejection so
266
+ // awaiting the .catch chain here doesn't re-throw.
267
+ abort: async (err) => {
268
+ // Order: tear down the source FIRST so the SDK sees an
269
+ // immediate end of body, then signal AbortController so
270
+ // the SDK can cancel in-flight HTTP requests. Destroying
271
+ // the PassThrough alone isn't enough on every SDK version
272
+ // — the AbortSignal is the documented cancel surface.
273
+ pt.destroy(err as Error)
274
+ try { ac.abort(err) } catch {}
275
+ // Wait for the SDK to actually settle. Expected rejection
276
+ // is BlobRequestAbortedError from the SDK's internal abort
277
+ // wiring. Anything ELSE — e.g. BlobServiceNotAvailable or
278
+ // a multipart upload that hit a quota before abort fired —
279
+ // is operationally interesting and would otherwise be lost
280
+ // since the caller already routed to its catch block on
281
+ // the original pipeline error. Log it (truncated) instead
282
+ // of silently swallowing.
283
+ try { await putPromise }
284
+ catch (settle: unknown) {
285
+ const settleErr = settle as { name?: unknown; message?: unknown }
286
+ if (settleErr?.name !== 'BlobRequestAbortedError') {
287
+ console.warn(`vercel-blob put settled with non-abort error after cancel: ${String(settleErr?.name ?? '<unknown>')} ${String(settleErr?.message ?? '').slice(0, 200)}`)
288
+ }
289
+ }
290
+ },
291
+ }
292
+ }
293
+ }
294
+
295
+ function buildStatStaging(sdk: VercelBlobSdk, token: string): BlobBackend['statStaging'] {
296
+ return async (tag, stagingId): Promise<number | null> => {
297
+ try {
298
+ const h = await sdk.head(stagingBlobPath(tag, stagingId), { token })
299
+ return h.size
300
+ } catch (err) {
301
+ if (isNotFound(err)) return null
302
+ throw err
303
+ }
304
+ }
305
+ }
306
+
307
+ function buildPromoteStagingToLive(sdk: VercelBlobSdk, token: string): BlobBackend['promoteStagingToLive'] {
308
+ return async (tag, stagingId, contentHash): Promise<boolean> => {
309
+ const from = stagingBlobPath(tag, stagingId)
310
+ const to = liveBlobPath(tag, contentHash)
311
+ try {
312
+ // `copy` is atomic per the SDK contract — either the new
313
+ // pathname carries the full source bytes, or it fails. No
314
+ // partial state at the destination.
315
+ //
316
+ // `allowOverwrite: true` because the content-addressed live
317
+ // 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.
323
+ await sdk.copy(from, to, {
324
+ access: 'private',
325
+ allowOverwrite: true,
326
+ token,
327
+ contentType: 'application/octet-stream',
328
+ // Short cache TTL so the CDN can't serve a stale version of
329
+ // a private blob after a re-upload. Private blobs use the
330
+ // CDN by default (see openLiveReader's useCache: false);
331
+ // belt-and-braces here for any path that pulls bypassing
332
+ // the get() helper.
333
+ cacheControlMaxAge: 60,
334
+ })
335
+ } catch (err) {
336
+ console.warn('vercel-blob promote: copy failed:', errMsg(err))
337
+ return false
338
+ }
339
+ // Staging-blob cleanup runs in the caller AFTER upsertLive so a
340
+ // crash between copy() and the DB write leaves the staging blob
341
+ // intact (commit can be retried). Same ordering principle the
342
+ // FS backend gets implicitly from rename's atomicity.
343
+ return true
344
+ }
345
+ }
346
+
347
+ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['openLiveReader'] {
348
+ return async (tag, contentHash): Promise<OpenLiveResult> => {
349
+ const path = liveBlobPath(tag, contentHash)
350
+ let res
351
+ // `useCache: false` — the content-addressed pathname is immutable
352
+ // (the hash names exactly one byte-string), so cache staleness
353
+ // across versions is no longer a correctness concern; but a CDN
354
+ // edge could still cache a 404 from a brief window before the
355
+ // copy landed, so bypassing the cache keeps the read anchored to
356
+ // origin truth. Origin fetch is the right default for a store
357
+ // where freshness > latency.
358
+ try { res = await sdk.get(path, { access: 'private', useCache: false, token }) } catch (err) {
359
+ if (isNotFound(err)) return { ok: false, reason: 'not-found' }
360
+ throw err
361
+ }
362
+ if (res == null) return { ok: false, reason: 'not-found' }
363
+ // statusCode 304 doesn't reach here in practice — the REST
364
+ // GET layer doesn't pass If-None-Match — but a future call
365
+ // site could. Treat as unavailable rather than streaming a
366
+ // null body.
367
+ if (res.statusCode !== 200 || res.stream == null || res.blob.size == null) {
368
+ return { ok: false, reason: 'unavailable' }
369
+ }
370
+ // SDK returns a web ReadableStream<Uint8Array>; the REST layer
371
+ // expects a Node Readable for pipeline(). Convert via
372
+ // Readable.fromWeb — built-in and zero-copy where possible.
373
+ const { Readable: NodeReadable } = await import('node:stream')
374
+ const nodeStream = NodeReadable.fromWeb(res.stream as Parameters<typeof NodeReadable.fromWeb>[0])
375
+ return {
376
+ ok: true,
377
+ reader: {
378
+ stream: nodeStream,
379
+ size: res.blob.size,
380
+ // eslint-disable-next-line require-await
381
+ close: async () => {
382
+ // Destroying the Node wrapper also cancels the underlying
383
+ // web stream reader (Readable.fromWeb installs the
384
+ // cleanup). Tolerate errors — close() is idempotent and
385
+ // may be called after the stream already finished.
386
+ try { nodeStream.destroy() } catch {}
387
+ },
388
+ },
389
+ }
390
+ }
391
+ }
392
+
393
+ function buildUnlink(sdk: VercelBlobSdk, token: string, op: 'staging' | 'live', toPath: (tag: string, id: string) => string): (tag: string, id: string) => Promise<void> {
394
+ return async (tag, id) => {
395
+ try { await sdk.del(toPath(tag, id), { token }) } catch (err) {
396
+ if (isNotFound(err)) return
397
+ // Don't propagate — the DB-side row drop has already
398
+ // committed by the time the caller reaches here, and the
399
+ // reaper picks up the stranded blob on its next sweep. Same
400
+ // policy as FS unlinkIfExists (PR #4 review).
401
+ console.warn(`vercel-blob unlink ${op} failed:`, errMsg(err))
402
+ }
403
+ }
404
+ }
405
+
406
+ function buildListWorkspaceTags(sdk: VercelBlobSdk, token: string): BlobBackend['listWorkspaceTags'] {
407
+ return async (): Promise<string[]> => {
408
+ const { folders } = await listAll(sdk, { mode: 'folded', token })
409
+ const out: string[] = []
410
+ for (const f of folders) {
411
+ // Folder names come back with trailing slash, e.g. "ws-1/".
412
+ // Strip it to get the bare tag.
413
+ const tag = f.endsWith('/') ? f.slice(0, -1) : f
414
+ if (tag.length > 0) out.push(tag)
415
+ }
416
+ return out
417
+ }
418
+ }
419
+
420
+ // Builder for `listStagingIds`. Walks `list({ prefix })` and strips
421
+ // the prefix + `.bin` suffix from each blob's pathname to recover the
422
+ // bare staging id. Entries whose remainder contains a `/` are skipped
423
+ // — they're inside a deeper sub-prefix.
424
+ function buildListStagingIds(sdk: VercelBlobSdk, token: string, mkPrefix: (tag: string) => string): (tag: string) => Promise<string[]> {
425
+ return async (tag) => {
426
+ const prefix = mkPrefix(tag)
427
+ const { blobs } = await listAll(sdk, { prefix, token })
428
+ const out: string[] = []
429
+ for (const b of blobs) {
430
+ const id = stripBinSuffix(b.pathname, prefix)
431
+ if (id == null || id.includes('/')) continue
432
+ out.push(id)
433
+ }
434
+ return out
435
+ }
436
+ }
437
+
438
+ // Builder for `listLiveBlobs`. Lists the workspace prefix in `folded`
439
+ // mode so the `.staging/` sub-prefix rolls up as a folder entry (not
440
+ // expanded into the blobs[] array) — without it, listing `${tag}/`
441
+ // would surface every staging blob as a live blob. Each surviving
442
+ // entry is a top-level content-addressed live blob; we recover its
443
+ // hash from the pathname and its `modifiedMs` from the SDK's
444
+ // `uploadedAt` for the reaper's GC grace window.
445
+ function buildListLiveBlobs(sdk: VercelBlobSdk, token: string): BlobBackend['listLiveBlobs'] {
446
+ return async (tag) => {
447
+ const prefix = `${tag}/`
448
+ const { blobs } = await listAll(sdk, { prefix, mode: 'folded', token })
449
+ const out: Array<{ hash: string; modifiedMs: number }> = []
450
+ for (const b of blobs) {
451
+ const hash = stripBinSuffix(b.pathname, prefix)
452
+ if (hash == null || hash.includes('/')) continue
453
+ out.push({ hash, modifiedMs: uploadedAtMs(b.uploadedAt) })
454
+ }
455
+ return out
456
+ }
457
+ }
458
+
459
+ export type VercelBlobBackendOptions = {
460
+ // Vercel Blob R/W token, typically from BLOB_READ_WRITE_TOKEN.
461
+ // The SDK also reads it from process.env, but passing it
462
+ // explicitly here keeps the env-var → boot config path single-
463
+ // sourced through server/index.ts (matches the Neon DATABASE_URL
464
+ // handling — env-read at boot, threaded as a parameter).
465
+ token: string
466
+ // Test seam: inject a stub of the @vercel/blob module to avoid
467
+ // pulling the real SDK / hitting the network in unit tests. When
468
+ // absent, the real package is dynamic-imported.
469
+ sdk?: VercelBlobSdk
470
+ }
471
+
472
+ export async function openVercelBlobBackend(opts: VercelBlobBackendOptions): Promise<BlobBackend> {
473
+ // Dynamic import so a SQLite/FS deployment never reaches the peer
474
+ // dep. `@ts-ignore` (not `@ts-expect-error`) so an operator who
475
+ // DOES install `@vercel/blob` doesn't trip TS2578 "unused
476
+ // directive" — same pattern as db-neon.ts.
477
+ const sdk: VercelBlobSdk = opts.sdk ?? (await loadSdk())
478
+ const token = opts.token
479
+ return {
480
+ // No-op: Vercel Blob has no folder concept. The pathname's
481
+ // slashes are presentational only — listing with mode: 'folded'
482
+ // synthesises the "directory" view client-side.
483
+ // eslint-disable-next-line require-await
484
+ ensureWorkspace: async () => {},
485
+ openStagingWriter: buildOpenStagingWriter(sdk, token),
486
+ statStaging: buildStatStaging(sdk, token),
487
+ promoteStagingToLive: buildPromoteStagingToLive(sdk, token),
488
+ openLiveReader: buildOpenLiveReader(sdk, token),
489
+ unlinkStaging: buildUnlink(sdk, token, 'staging', stagingBlobPath),
490
+ unlinkLive: buildUnlink(sdk, token, 'live', liveBlobPath),
491
+ // Workspace tags = top-level folders. `mode: 'folded'` with no
492
+ // prefix returns folder names at the store root; each folder is
493
+ // one workspace.
494
+ listWorkspaceTags: buildListWorkspaceTags(sdk, token),
495
+ // Content-addressed live blobs under `${tag}/`, with each blob's
496
+ // `uploadedAt` surfaced for the reaper's GC grace window. `folded`
497
+ // mode keeps `.staging/` rolled up as a folder entry rather than
498
+ // expanded into the live-blob list.
499
+ listLiveBlobs: buildListLiveBlobs(sdk, token),
500
+ listStagingIds: buildListStagingIds(sdk, token, (tag) => `${tag}/.staging/`),
501
+ }
502
+ }
503
+
504
+ async function loadSdk(): Promise<VercelBlobSdk> {
505
+ // @ts-ignore optional peer dep: '@vercel/blob'
506
+ const mod = (await import('@vercel/blob')) as VercelBlobSdk
507
+ return mod
508
+ }
@@ -0,0 +1,169 @@
1
+ // Byte-plane abstraction for the v1.objstore module. The DB plane
2
+ // (Handle's statement set in ./store.ts) holds the metadata
3
+ // (workspace_object + workspace_object_staging); this interface
4
+ // holds the bytes those rows point at.
5
+ //
6
+ // Two implementations:
7
+ // - ./blob-fs.ts local filesystem (default; the only option
8
+ // for the single-process SQLite-backed DB
9
+ // plane)
10
+ // - ./blob-vercel.ts Vercel Blob Private Storage (paired with
11
+ // the Neon DB plane for multi-replica
12
+ // deployments)
13
+ //
14
+ // Selected at boot in `server/index.ts` and passed to `openObjstore`
15
+ // / `openNeonObjstore`. The DB-plane code in ./store.ts, ./rest.ts,
16
+ // and ./reaper.ts goes through `handle.blob.*` and is backend-
17
+ // agnostic — no `if (vercel) … else` branching in consumers.
18
+ //
19
+ // Live blobs are CONTENT-ADDRESSED: a live blob lives at
20
+ // `${tag}/${contentHash}.bin`, where `contentHash` is the client-
21
+ // supplied, signed, server-opaque hash on the staging row. Because a
22
+ // hash names exactly one byte-string, the live blob is immutable —
23
+ // two racing commits write to DIFFERENT addresses, neither overwrites
24
+ // the other, and the DB row's `content_hash` literally NAMES its blob
25
+ // file (so "row says hash B, blob holds bytes A" is impossible). The
26
+ // hash is workspace-namespaced under `${tag}/`, so this is never a
27
+ // global content-addressed store. `unlinkLive` is therefore only ever
28
+ // called by the reaper's GC — commit + delete never unlink a live blob
29
+ // inline. Reclamation is deferred (not because a blob is shared — a
30
+ // random nonce per encrypt makes each PUT's hash unique) so it can't
31
+ // race a concurrent commit's promote→CAS window or an in-flight GET;
32
+ // see the grace-window rationale in reaper.ts.
33
+ //
34
+ // Crash-safety contract every backend MUST preserve, so the reaper's
35
+ // "stranded file, never row-points-at-nothing" guarantee holds:
36
+ // PUT commit: bytes durable in staging → promote → DB write
37
+ // DELETE: DB write (the reaper GCs the now-unreferenced blob)
38
+ // A crash at the worst moment leaves at most a stranded blob (the
39
+ // reaper cleans these on its periodic sweep, once the blob is both
40
+ // unreferenced and older than the GC grace window). The FS backend
41
+ // uses fsync + rename; the Vercel backend uses copy + delete (Vercel's
42
+ // copy is atomic per-blob, so the cross-blob staging→live transition
43
+ // can produce a stranded staging blob if del fails, but never a
44
+ // half-written live blob).
45
+
46
+ import type { Readable, Writable } from 'node:stream'
47
+
48
+ // Streaming writer for a staging slot. The REST PUT handler pipes
49
+ // the request body through `writable`, then awaits `finalize()` to
50
+ // confirm the upload landed. `abort(err)` is the cancel path the
51
+ // REST layer invokes on body error / overrun / length mismatch;
52
+ // implementations propagate the cancel to the underlying upload
53
+ // (createWriteStream.destroy for FS, AbortController.abort for
54
+ // fetch-based remote stores).
55
+ //
56
+ // `abort` returns a Promise that resolves once the underlying
57
+ // upload has actually torn down — for the FS backend this is
58
+ // immediate, but for fetch-based stores the SDK's in-flight HTTP
59
+ // request may take several seconds to register the abort. The REST
60
+ // layer MUST await `abort()` before running cleanup (e.g. blob
61
+ // del) so a late-arriving upload chunk can't recreate the staging
62
+ // blob after we've cleaned it up.
63
+ //
64
+ // The writable is NOT auto-flushed on return — the caller is
65
+ // responsible for either ending it through pipeline() (which awaits
66
+ // 'finish' on Writables / 'end' on Transforms) or calling
67
+ // writable.end() manually before finalize().
68
+ export type StagingWriter = {
69
+ writable: Writable
70
+ finalize(): Promise<void>
71
+ abort(err: unknown): Promise<void>
72
+ }
73
+
74
+ // Readable handle for a live blob. `size` is the byte length the
75
+ // backend confirmed on open (used to set Content-Length on the GET
76
+ // response and to validate against the live row's content_length
77
+ // before piping). `close()` releases backend-side resources
78
+ // (file descriptor for FS, fetch reader for remote stores) and is
79
+ // called by the REST GET handler on error before pipelining.
80
+ export type LiveReader = {
81
+ stream: Readable
82
+ size: number
83
+ close(): Promise<void>
84
+ }
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.
90
+ export type OpenLiveResult =
91
+ | { ok: true; reader: LiveReader }
92
+ | { ok: false; reason: 'not-found' | 'unavailable' }
93
+
94
+ export type BlobBackend = {
95
+ // Per-workspace setup. FS creates the on-disk staging directory;
96
+ // blob stores have no real folder concept (the pathname's slashes
97
+ // are presentational) so this is a no-op there.
98
+ ensureWorkspace(tag: string): Promise<void>
99
+
100
+ // Open a streaming writer to the staging slot identified by
101
+ // (tag, stagingId). The returned `writable` can absorb up to
102
+ // MAX_CONTENT_LENGTH bytes; the REST layer's counter enforces the
103
+ // declared length upstream.
104
+ openStagingWriter(tag: string, stagingId: string): Promise<StagingWriter>
105
+
106
+ // Return the storage-side byte count of the staging slot, or null
107
+ // if it's missing. The REST PUT layer uses this as the belt-and-
108
+ // braces size verification after the request body has been fully
109
+ // consumed; commitPut re-stats as a last line of defense against a
110
+ // short/truncated upload before promotion (the staging slot is
111
+ // single-writer — its stagingId is freshly random per begin).
112
+ statStaging(tag: string, stagingId: string): Promise<number | null>
113
+
114
+ // Promote staging → live at the CONTENT-ADDRESSED live path
115
+ // `${tag}/${contentHash}.bin`. Returns true on success, false on
116
+ // any I/O error. The caller (commitPut) has already validated size;
117
+ // this method just performs the bytes-side transition. Because the
118
+ // live address IS the content hash, any write to that path is
119
+ // byte-identical by construction, so a retried or racing promote to
120
+ // the same path is an idempotent rewrite, never a clobber. (Distinct
121
+ // PUTs get distinct hashes — a random nonce per encrypt makes each
122
+ // ciphertext unique — so they write distinct paths.)
123
+ //
124
+ // Crash safety: implementations MUST ensure that a crash mid-
125
+ // promotion leaves at most a stranded staging blob (reaper-
126
+ // cleanable), never a partial live blob. FS uses fsync+rename;
127
+ // Vercel uses atomic-copy followed by best-effort staging delete.
128
+ promoteStagingToLive(tag: string, stagingId: string, contentHash: string): Promise<boolean>
129
+
130
+ // 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).
134
+ openLiveReader(tag: string, contentHash: string): Promise<OpenLiveResult>
135
+
136
+ // Idempotent deletes. Backends MUST tolerate "already gone" as
137
+ // success (FS: ENOENT; Vercel: BlobNotFoundError) — abortPut relies
138
+ // on this for retry idempotence, and the reaper races against
139
+ // concurrent operations on the same key. `unlinkLive` is called
140
+ // ONLY by the reaper's GC (commit / delete never unlink a live blob
141
+ // inline — reclamation is deferred to the grace-window GC so it can't
142
+ // race a commit's promote→CAS window or an in-flight GET; not because
143
+ // the blob is shared — hashes are unique per PUT).
144
+ unlinkStaging(tag: string, stagingId: string): Promise<void>
145
+ unlinkLive(tag: string, contentHash: string): Promise<void>
146
+
147
+ // Reaper enumeration helpers. Implementations MAY paginate
148
+ // internally; the reaper consumes the returned full list per
149
+ // workspace.
150
+ //
151
+ // `listWorkspaceTags()` returns every tag with ANY trace of state
152
+ // (live or staging). The reaper uses it to find dirs/prefixes the
153
+ // live table no longer knows about (whole-workspace deletes leave
154
+ // residue the per-tag sweep would otherwise miss).
155
+ //
156
+ // `listLiveBlobs(tag)` returns every live blob under the tag with
157
+ // its `hash` (the `.bin`-stripped content hash) AND `modifiedMs`
158
+ // (last-modified epoch-ms: FS mtime / Vercel `uploadedAt`). The GC
159
+ // needs the timestamp for the age grace window — a blob is only
160
+ // eligible for unlink once it's BOTH unreferenced by any live row
161
+ // AND older than the grace, so a just-promoted but not-yet-
162
+ // referenced blob isn't reaped out from under an in-flight commit.
163
+ //
164
+ // `listStagingIds(tag)` returns staging ids stripped of the `.bin`
165
+ // suffix — the reaper compares them against the DB's `staging_id`.
166
+ listWorkspaceTags(): Promise<string[]>
167
+ listLiveBlobs(tag: string): Promise<Array<{ hash: string; modifiedMs: number }>>
168
+ listStagingIds(tag: string): Promise<string[]>
169
+ }