@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.
- package/LICENSE +21 -0
- package/common/save-error-reason.ts +53 -0
- package/common/utf8.d.ts +13 -0
- package/common/utf8.js +57 -0
- package/out/brotli-fallback.js +3 -0
- package/out/client-sync.js +15 -0
- package/out/graph.js +4 -0
- package/out/icon-maskable.svg +5 -0
- package/out/icon.svg +5 -0
- package/out/index.html +78 -0
- package/out/manifest.webmanifest +30 -0
- package/out/prism.js +14 -0
- package/out/terminal.js +39 -0
- package/out/view.css +1 -0
- package/out/view.html +12 -0
- package/out/view.js +138 -0
- package/package.json +129 -0
- package/server/auth.ts +99 -0
- package/server/config.example.json +3 -0
- package/server/config.ts +196 -0
- package/server/db-neon.ts +374 -0
- package/server/db-revision-sql.ts +152 -0
- package/server/db-stmt.ts +53 -0
- package/server/db.ts +577 -0
- package/server/http.ts +142 -0
- package/server/hub.ts +98 -0
- package/server/index.ts +353 -0
- package/server/lifecycle.ts +177 -0
- package/server/neon-driver.ts +26 -0
- package/server/objstore/blob-fs.ts +164 -0
- package/server/objstore/blob-vercel.ts +508 -0
- package/server/objstore/blob.ts +169 -0
- package/server/objstore/fs.ts +67 -0
- package/server/objstore/handlers.ts +235 -0
- package/server/objstore/init.ts +118 -0
- package/server/objstore/reaper.ts +199 -0
- package/server/objstore/rest.ts +484 -0
- package/server/objstore/sign.ts +164 -0
- package/server/objstore/store-neon.ts +351 -0
- package/server/objstore/store.ts +799 -0
- package/server/objstore/tokens.ts +168 -0
- package/server/origin.ts +68 -0
- package/server/peer.ts +38 -0
- package/server/sign.ts +231 -0
- package/server/static.ts +374 -0
- package/server/sync-handlers.ts +311 -0
- package/server/util.ts +27 -0
- package/server/validation.ts +36 -0
- 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
|
+
}
|