bunderstack 0.1.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.
@@ -0,0 +1,532 @@
1
+ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
2
+ // src/storage/router.ts
3
+ import type { Context } from 'hono'
4
+
5
+ import { Hono } from 'hono'
6
+ import { randomUUID } from 'node:crypto'
7
+ import { extname } from 'node:path'
8
+
9
+ import type {
10
+ AccessContext,
11
+ AuthSessionResolver,
12
+ OperationRule,
13
+ } from '../access'
14
+ import type { BucketStorageRegistry } from './registry'
15
+
16
+ import { checkAccess, resolveSession } from '../access'
17
+ import { ErrorCode, apiError } from '../errors'
18
+ import { deleteFileWithDerivatives } from './delete'
19
+ import {
20
+ deleteFileMetaRow,
21
+ fileMatchesScope,
22
+ getFileMeta,
23
+ insertPendingFile,
24
+ insertReadyFile,
25
+ markFileReady,
26
+ scopeToJson,
27
+ sumReadySize,
28
+ type FileMetaRow,
29
+ } from './file-meta'
30
+ import {
31
+ parseTransformSpec,
32
+ transformHash,
33
+ transformImage,
34
+ } from './thumbnails'
35
+
36
+ export interface BucketStorageRouterOptions {
37
+ registry: BucketStorageRegistry
38
+ db: LibSQLDatabase<Record<string, unknown>>
39
+ auth: AuthSessionResolver | undefined
40
+ /** Default presign TTL (seconds) for PUT/GET URLs. */
41
+ presignExpiresSec?: number
42
+ }
43
+
44
+ const FILE_OWNER_COLUMN = 'ownerId'
45
+
46
+ // ─── Helpers ────────────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Wildcard-aware MIME match. No accept list → always true. Matches an exact
50
+ * type or a `prefix/*` wildcard (e.g. `image/*`). An empty `type` fails a
51
+ * non-empty accept list.
52
+ */
53
+ function matchMime(type: string, accept?: string[]): boolean {
54
+ if (!accept || accept.length === 0) return true
55
+ if (!type) return false
56
+ for (const pattern of accept) {
57
+ if (pattern === type) return true
58
+ if (pattern.endsWith('/*')) {
59
+ const prefix = pattern.slice(0, -1) // keep trailing slash, e.g. "image/"
60
+ if (type.startsWith(prefix)) return true
61
+ }
62
+ }
63
+ return false
64
+ }
65
+
66
+ /**
67
+ * Run an access rule. Returns the apiError Response when denied (401 if the
68
+ * rule failed for lack of a session, else 403), or null when allowed.
69
+ */
70
+ async function gate(
71
+ rule: OperationRule,
72
+ ctx: AccessContext,
73
+ c: Context,
74
+ ): Promise<Response | null> {
75
+ const result = await checkAccess(rule, ctx, FILE_OWNER_COLUMN)
76
+ if (result.allowed) return null
77
+ return apiError(
78
+ c,
79
+ ErrorCode.FORBIDDEN,
80
+ 'Forbidden',
81
+ result.status === 401 ? 401 : 403,
82
+ )
83
+ }
84
+
85
+ /** Build the AccessContext for a request. */
86
+ function buildCtx(
87
+ c: Context,
88
+ user: AccessContext['user'],
89
+ activeOrganizationId: string | null,
90
+ extra: { row?: FileMetaRow; body?: Record<string, unknown> } = {},
91
+ ): AccessContext {
92
+ return {
93
+ user,
94
+ request: c.req.raw,
95
+ row: extra.row,
96
+ body: extra.body,
97
+ session: { activeOrganizationId },
98
+ }
99
+ }
100
+
101
+ /** Strip dangerous quote chars from a filename used in a header. */
102
+ function sanitizeFilename(name: string): string {
103
+ return name.replace(/["\\\r\n]/g, '')
104
+ }
105
+
106
+ function isRecord(value: unknown): value is Record<string, unknown> {
107
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
108
+ }
109
+
110
+ // ─── Factory ────────────────────────────────────────────────────────────────
111
+
112
+ export function buildBucketStorageRouter(
113
+ opts: BucketStorageRouterOptions,
114
+ ): Hono {
115
+ const { registry, db, auth } = opts
116
+ const presignExpiresSec = opts.presignExpiresSec ?? 60
117
+ const router = new Hono()
118
+
119
+ // DECISION — upload path is auto-selected by backend capability, not a user
120
+ // toggle. Presign-capable backends (S3, R2) advertise `presignPut`, so
121
+ // `POST /:bucket/presign` returns `mode:'presign'`: the client PUTs bytes
122
+ // directly to object storage, which avoids buffering the whole file in app
123
+ // memory and offloads upload bandwidth from the app server. Backends without
124
+ // presign (local/dev) return `mode:'proxy'` so `POST /:bucket` (the
125
+ // proxy-upload route below) works with zero setup — at the cost of loading
126
+ // the whole file into memory (see the `file.arrayBuffer()` call there). The
127
+ // two-phase presign flow (presign → direct PUT → confirm) intentionally
128
+ // leaves `pending` rows; the T6 orphan sweep reaps any that never confirm.
129
+ // If two-phase proves too fragile in practice the fallback is presign-only
130
+ // (design model B) — do NOT collapse the dual path here. See design §5.
131
+
132
+ // ─── POST /:bucket/presign — upload init / auto-selection ─────────────────
133
+ router.post('/:bucket/presign', async (c) => {
134
+ const entry = registry.get(c.req.param('bucket'))
135
+ if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
136
+ const { bucket, adapter } = entry
137
+
138
+ const { user, activeOrganizationId } = await resolveSession(
139
+ auth,
140
+ c.req.raw.headers,
141
+ )
142
+
143
+ let body: Record<string, unknown> = {}
144
+ try {
145
+ const parsed = await c.req.json()
146
+ if (isRecord(parsed)) body = parsed
147
+ } catch {
148
+ body = {}
149
+ }
150
+
151
+ const ctx = buildCtx(c, user, activeOrganizationId, { body })
152
+ const denied = await gate(bucket.access.create, ctx, c)
153
+ if (denied) return denied
154
+
155
+ // No presign capability (e.g. local) → tell client to proxy-upload.
156
+ if (!adapter.presignPut) {
157
+ return c.json(
158
+ { mode: 'proxy', uploadUrl: `/api/files/${bucket.name}` },
159
+ 200,
160
+ )
161
+ }
162
+
163
+ const filename =
164
+ typeof body.filename === 'string' ? body.filename : undefined
165
+ const contentType =
166
+ typeof body.contentType === 'string' ? body.contentType : undefined
167
+
168
+ const requesterScope = bucket.scope?.(ctx)
169
+ const scopeJson = scopeToJson(requesterScope)
170
+
171
+ // Quota pre-check: reserve the configured max upload size.
172
+ if (bucket.quota) {
173
+ const reservation = bucket.upload?.maxSizeBytes ?? 0
174
+ const over = await quotaExceeded(
175
+ db,
176
+ bucket.name,
177
+ bucket.quota,
178
+ user?.id,
179
+ scopeJson,
180
+ reservation,
181
+ )
182
+ if (over) {
183
+ return apiError(c, ErrorCode.VALIDATION_ERROR, 'Quota exceeded', 413)
184
+ }
185
+ }
186
+
187
+ const fileId = `${bucket.name}/${randomUUID()}${extname(filename ?? '')}`
188
+ await insertPendingFile(db, {
189
+ fileId,
190
+ bucket: bucket.name,
191
+ ownerId: user?.id ?? null,
192
+ scopeJson,
193
+ filename: filename ?? null,
194
+ contentType: contentType ?? null,
195
+ })
196
+
197
+ const uploadUrl = await adapter.presignPut(fileId, {
198
+ contentType,
199
+ expiresIn: presignExpiresSec,
200
+ })
201
+ const id = fileId.slice(`${bucket.name}/`.length)
202
+
203
+ return c.json(
204
+ {
205
+ mode: 'presign',
206
+ fileId,
207
+ uploadUrl,
208
+ method: 'PUT',
209
+ confirmUrl: `/api/files/${bucket.name}/${id}/confirm`,
210
+ },
211
+ 200,
212
+ )
213
+ })
214
+
215
+ // ─── POST /:bucket — proxy upload ─────────────────────────────────────────
216
+ router.post('/:bucket', async (c) => {
217
+ const entry = registry.get(c.req.param('bucket'))
218
+ if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
219
+ const { bucket, adapter } = entry
220
+
221
+ const { user, activeOrganizationId } = await resolveSession(
222
+ auth,
223
+ c.req.raw.headers,
224
+ )
225
+ const ctx = buildCtx(c, user, activeOrganizationId)
226
+ const denied = await gate(bucket.access.create, ctx, c)
227
+ if (denied) return denied
228
+
229
+ const parsed = await c.req.parseBody()
230
+ const file = parsed['file']
231
+ if (!(file instanceof File)) {
232
+ return apiError(
233
+ c,
234
+ ErrorCode.VALIDATION_ERROR,
235
+ 'No file field in request',
236
+ 400,
237
+ )
238
+ }
239
+
240
+ if (bucket.upload?.accept && !matchMime(file.type, bucket.upload.accept)) {
241
+ return apiError(
242
+ c,
243
+ ErrorCode.VALIDATION_ERROR,
244
+ `Content type ${file.type || '(none)'} not allowed`,
245
+ 422,
246
+ )
247
+ }
248
+ if (
249
+ bucket.upload?.maxSizeBytes !== undefined &&
250
+ file.size > bucket.upload.maxSizeBytes
251
+ ) {
252
+ return apiError(c, ErrorCode.VALIDATION_ERROR, 'File too large', 422)
253
+ }
254
+
255
+ const requesterScope = bucket.scope?.(ctx)
256
+ const scopeJson = scopeToJson(requesterScope)
257
+
258
+ if (bucket.quota) {
259
+ const over = await quotaExceeded(
260
+ db,
261
+ bucket.name,
262
+ bucket.quota,
263
+ user?.id,
264
+ scopeJson,
265
+ file.size,
266
+ )
267
+ if (over) {
268
+ return apiError(c, ErrorCode.VALIDATION_ERROR, 'Quota exceeded', 413)
269
+ }
270
+ }
271
+
272
+ const fileId = `${bucket.name}/${randomUUID()}${extname(file.name)}`
273
+ // NOTE: this loads the whole file into app memory — exactly why presign
274
+ // exists for capable backends (see the DECISION block above).
275
+ await adapter.upload(fileId, await file.arrayBuffer(), file.type)
276
+ await insertReadyFile(db, {
277
+ fileId,
278
+ bucket: bucket.name,
279
+ ownerId: user?.id ?? null,
280
+ scopeJson,
281
+ filename: file.name || null,
282
+ contentType: file.type || null,
283
+ size: file.size,
284
+ })
285
+
286
+ const id = fileId.slice(`${bucket.name}/`.length)
287
+ return c.json({ fileId, url: `/api/files/${bucket.name}/${id}` }, 201)
288
+ })
289
+
290
+ // ─── POST /:bucket/:id/confirm ────────────────────────────────────────────
291
+ router.post('/:bucket/:id/confirm', async (c) => {
292
+ const bucketName = c.req.param('bucket')
293
+ const entry = registry.get(bucketName)
294
+ if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
295
+ const { bucket, adapter } = entry
296
+
297
+ const id = c.req.param('id')
298
+ const fileId = `${bucketName}/${id}`
299
+
300
+ const { user } = await resolveSession(auth, c.req.raw.headers)
301
+
302
+ const row = await getFileMeta(db, fileId)
303
+ if (!row || row.bucket !== bucketName) {
304
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
305
+ }
306
+
307
+ // ownerId-only gate: confirm finalizes a pending upload, so it only checks
308
+ // that the caller owns the row (matching who presigned it). The full
309
+ // `access.create`/`get` rules ran at presign and run again on read.
310
+ if (row.ownerId != null && row.ownerId !== (user?.id ?? null)) {
311
+ return apiError(c, ErrorCode.FORBIDDEN, 'Forbidden', 403)
312
+ }
313
+
314
+ const url = `/api/files/${bucketName}/${id}`
315
+
316
+ // Idempotent: already confirmed.
317
+ if (row.status === 'ready') {
318
+ return c.json({ fileId, url }, 200)
319
+ }
320
+
321
+ const info = await adapter.stat?.(fileId)
322
+ if (info == null) {
323
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
324
+ }
325
+
326
+ if (
327
+ bucket.upload?.maxSizeBytes !== undefined &&
328
+ info.size > bucket.upload.maxSizeBytes
329
+ ) {
330
+ await adapter.delete(fileId)
331
+ await deleteFileMetaRow(db, fileId)
332
+ return apiError(c, ErrorCode.VALIDATION_ERROR, 'File too large', 413)
333
+ }
334
+ if (
335
+ bucket.upload?.accept &&
336
+ !matchMime(info.contentType, bucket.upload.accept)
337
+ ) {
338
+ await adapter.delete(fileId)
339
+ await deleteFileMetaRow(db, fileId)
340
+ return apiError(
341
+ c,
342
+ ErrorCode.VALIDATION_ERROR,
343
+ `Content type ${info.contentType || '(none)'} not allowed`,
344
+ 422,
345
+ )
346
+ }
347
+
348
+ // Quota reconciliation: presign reserved the worst-case `maxSize`; now we
349
+ // know the real size. The pending row isn't counted by `sumReadySize`
350
+ // (ready-only), so this compares existing usage + the actual bytes.
351
+ if (bucket.quota) {
352
+ const over = await quotaExceeded(
353
+ db,
354
+ bucket.name,
355
+ bucket.quota,
356
+ row.ownerId ?? undefined,
357
+ row.scopeJson,
358
+ info.size,
359
+ )
360
+ if (over) {
361
+ await adapter.delete(fileId)
362
+ await deleteFileMetaRow(db, fileId)
363
+ return apiError(c, ErrorCode.VALIDATION_ERROR, 'Quota exceeded', 413)
364
+ }
365
+ }
366
+
367
+ await markFileReady(db, fileId, {
368
+ size: info.size,
369
+ contentType: info.contentType,
370
+ })
371
+ return c.json({ fileId, url }, 200)
372
+ })
373
+
374
+ // ─── GET /:bucket/:id ─────────────────────────────────────────────────────
375
+ router.get('/:bucket/:id', async (c) => {
376
+ const bucketName = c.req.param('bucket')
377
+ const entry = registry.get(bucketName)
378
+ if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
379
+ const { bucket, adapter } = entry
380
+
381
+ const id = c.req.param('id')
382
+ const fileId = `${bucketName}/${id}`
383
+
384
+ const row = await getFileMeta(db, fileId)
385
+ if (!row || row.status !== 'ready' || row.bucket !== bucketName) {
386
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
387
+ }
388
+
389
+ const { user, activeOrganizationId } = await resolveSession(
390
+ auth,
391
+ c.req.raw.headers,
392
+ )
393
+ const ctx = buildCtx(c, user, activeOrganizationId, { row })
394
+
395
+ const denied = await gate(bucket.access.get, ctx, c)
396
+ if (denied) return denied
397
+
398
+ const requesterScope = bucket.scope?.(ctx)
399
+ if (!fileMatchesScope(row, requesterScope)) {
400
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
401
+ }
402
+
403
+ const spec = parseTransformSpec(c.req.query())
404
+ if (spec) {
405
+ if (!bucket.transforms) {
406
+ return apiError(
407
+ c,
408
+ ErrorCode.VALIDATION_ERROR,
409
+ 'Transforms not enabled for this bucket',
410
+ 400,
411
+ )
412
+ }
413
+ // Proxy-transform path regardless of visibility (we must read + write
414
+ // bytes through the app). Mirrors the legacy on-the-fly transform logic.
415
+ const ext = spec.format ? `.${spec.format}` : extname(fileId) || '.jpg'
416
+ const cacheKey = `${fileId}__transforms/${transformHash(spec)}${ext}`
417
+
418
+ if (await adapter.exists(cacheKey)) {
419
+ const cached = await adapter.get(cacheKey)
420
+ // Re-attach caching headers so cached derivatives match fresh serves
421
+ // (the adapter may not preserve Cache-Control on read).
422
+ const headers = new Headers(cached.headers)
423
+ headers.set('Cache-Control', 'public, max-age=31536000')
424
+ return new Response(cached.body, { status: cached.status, headers })
425
+ }
426
+
427
+ const original = await adapter.get(fileId)
428
+ if (original.status === 404) {
429
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
430
+ }
431
+
432
+ const inputBuffer = Buffer.from(await original.clone().arrayBuffer())
433
+ const transformed = await transformImage(inputBuffer, spec)
434
+ const contentType = spec.format
435
+ ? `image/${spec.format}`
436
+ : (original.headers.get('Content-Type') ?? 'image/jpeg')
437
+ const transformedAb = Uint8Array.from(transformed).buffer
438
+ await adapter.upload(cacheKey, transformedAb, contentType)
439
+ return new Response(transformedAb, {
440
+ headers: {
441
+ 'Content-Type': contentType,
442
+ 'Cache-Control': 'public, max-age=31536000',
443
+ },
444
+ })
445
+ }
446
+
447
+ // No spec — serve by visibility.
448
+ if (bucket.visibility === 'public' && adapter.publicUrlFor) {
449
+ const url = adapter.publicUrlFor(fileId)
450
+ if (url) return c.redirect(url, 302)
451
+ }
452
+ if (bucket.visibility === 'private' && adapter.presignGet) {
453
+ const url = await adapter.presignGet(fileId, {
454
+ expiresIn: presignExpiresSec,
455
+ })
456
+ return c.redirect(url, 302)
457
+ }
458
+
459
+ // Proxy fallback (e.g. local).
460
+ const res = await adapter.get(fileId)
461
+ if (res.status === 404 || !row.filename) return res
462
+ const headers = new Headers(res.headers)
463
+ headers.set(
464
+ 'Content-Disposition',
465
+ `inline; filename="${sanitizeFilename(row.filename)}"`,
466
+ )
467
+ return new Response(res.body, { status: res.status, headers })
468
+ })
469
+
470
+ // ─── DELETE /:bucket/:id ──────────────────────────────────────────────────
471
+ router.delete('/:bucket/:id', async (c) => {
472
+ const bucketName = c.req.param('bucket')
473
+ const entry = registry.get(bucketName)
474
+ if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
475
+ const { bucket, adapter } = entry
476
+
477
+ const id = c.req.param('id')
478
+ const fileId = `${bucketName}/${id}`
479
+
480
+ const row = await getFileMeta(db, fileId)
481
+ if (!row || row.bucket !== bucketName) {
482
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
483
+ }
484
+
485
+ const { user, activeOrganizationId } = await resolveSession(
486
+ auth,
487
+ c.req.raw.headers,
488
+ )
489
+ const ctx = buildCtx(c, user, activeOrganizationId, { row })
490
+
491
+ const denied = await gate(bucket.access.delete, ctx, c)
492
+ if (denied) return denied
493
+
494
+ const requesterScope = bucket.scope?.(ctx)
495
+ if (!fileMatchesScope(row, requesterScope)) {
496
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
497
+ }
498
+
499
+ // Removes the original, any transform-cache derivatives, and the meta row.
500
+ await deleteFileWithDerivatives(adapter, db, fileId)
501
+ return new Response(null, { status: 204 })
502
+ })
503
+
504
+ return router
505
+ }
506
+
507
+ // ─── Quota helper ─────────────────────────────────────────────────────────────
508
+
509
+ /**
510
+ * Returns true when adding `incoming` bytes would breach any configured quota
511
+ * dimension (perUser uses ownerId; perScope uses scopeJson).
512
+ */
513
+ async function quotaExceeded(
514
+ db: LibSQLDatabase<Record<string, unknown>>,
515
+ bucket: string,
516
+ quota: { perUserBytes?: number; perScopeBytes?: number },
517
+ ownerId: string | undefined,
518
+ scopeJson: string | null,
519
+ incoming: number,
520
+ ): Promise<boolean> {
521
+ // Anonymous uploads (no ownerId) aren't attributed to a per-user bucket, so
522
+ // they intentionally bypass `perUser` here. `perScope` (below) still applies.
523
+ if (quota.perUserBytes !== undefined && ownerId !== undefined) {
524
+ const current = await sumReadySize(db, { bucket, ownerId })
525
+ if (current + incoming > quota.perUserBytes) return true
526
+ }
527
+ if (quota.perScopeBytes !== undefined && scopeJson != null) {
528
+ const current = await sumReadySize(db, { bucket, scopeJson })
529
+ if (current + incoming > quota.perScopeBytes) return true
530
+ }
531
+ return false
532
+ }
@@ -0,0 +1,93 @@
1
+ // src/storage/s3.ts
2
+ import type {
3
+ PresignGetOptions,
4
+ PresignPutOptions,
5
+ StorageAdapter,
6
+ } from './index'
7
+
8
+ interface S3Config {
9
+ bucket: string
10
+ region: string
11
+ accessKeyId: string
12
+ secretAccessKey: string
13
+ endpoint?: string
14
+ publicUrl?: string
15
+ }
16
+
17
+ export class S3StorageAdapter implements StorageAdapter {
18
+ private client: InstanceType<typeof Bun.S3Client>
19
+ private readonly publicUrl?: string
20
+
21
+ constructor(cfg: S3Config) {
22
+ this.client = new Bun.S3Client({
23
+ bucket: cfg.bucket,
24
+ region: cfg.region,
25
+ accessKeyId: cfg.accessKeyId,
26
+ secretAccessKey: cfg.secretAccessKey,
27
+ ...(cfg.endpoint && { endpoint: cfg.endpoint }),
28
+ })
29
+ this.publicUrl = cfg.publicUrl
30
+ }
31
+
32
+ async upload(
33
+ fileId: string,
34
+ data: Blob | ArrayBuffer,
35
+ contentType: string,
36
+ ): Promise<void> {
37
+ const bytes = data instanceof Blob ? await data.arrayBuffer() : data
38
+ await this.client.write(fileId, bytes, { type: contentType })
39
+ }
40
+
41
+ async get(fileId: string): Promise<Response> {
42
+ const exists = await this.client.exists(fileId)
43
+ if (!exists) return new Response('Not found', { status: 404 })
44
+ const file = this.client.file(fileId)
45
+ return new Response(file.stream(), {
46
+ headers: { 'Content-Type': file.type ?? 'application/octet-stream' },
47
+ })
48
+ }
49
+
50
+ async delete(fileId: string): Promise<void> {
51
+ await this.client.delete(fileId)
52
+ }
53
+
54
+ async exists(fileId: string): Promise<boolean> {
55
+ return this.client.exists(fileId)
56
+ }
57
+
58
+ async presignPut(key: string, opts: PresignPutOptions): Promise<string> {
59
+ return this.client.presign(key, {
60
+ method: 'PUT',
61
+ expiresIn: opts.expiresIn,
62
+ ...(opts.contentType && { type: opts.contentType }),
63
+ })
64
+ }
65
+
66
+ async presignGet(key: string, opts: PresignGetOptions): Promise<string> {
67
+ return this.client.presign(key, {
68
+ method: 'GET',
69
+ expiresIn: opts.expiresIn,
70
+ })
71
+ }
72
+
73
+ async stat(
74
+ key: string,
75
+ ): Promise<{ size: number; contentType: string } | null> {
76
+ try {
77
+ const s = await this.client.stat(key)
78
+ return { size: s.size, contentType: s.type }
79
+ } catch {
80
+ return null
81
+ }
82
+ }
83
+
84
+ publicUrlFor(key: string): string | undefined {
85
+ if (!this.publicUrl) return undefined
86
+ return `${this.publicUrl.replace(/\/$/, '')}/${key}`
87
+ }
88
+
89
+ async list(prefix: string): Promise<string[]> {
90
+ const res = await this.client.list({ prefix })
91
+ return res.contents?.map((c) => c.key) ?? []
92
+ }
93
+ }
@@ -0,0 +1,26 @@
1
+ // src/storage/sweep.ts
2
+ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
3
+
4
+ import type { BucketStorageRegistry } from './registry'
5
+
6
+ import { deleteFileMetaRow, listStalePendingFiles } from './file-meta'
7
+
8
+ /**
9
+ * Reap stale `pending` file-meta rows (two-phase uploads that never confirmed)
10
+ * older than `olderThanMs`. For each, best-effort delete the object (it may
11
+ * never have landed) then remove the meta row. Returns the number reaped.
12
+ */
13
+ export async function sweepOrphans(
14
+ registry: BucketStorageRegistry,
15
+ db: LibSQLDatabase<Record<string, unknown>>,
16
+ olderThanMs: number,
17
+ ): Promise<number> {
18
+ const cutoff = Date.now() - olderThanMs
19
+ const rows = await listStalePendingFiles(db, cutoff)
20
+ for (const row of rows) {
21
+ const adapter = registry.get(row.bucket)?.adapter
22
+ await adapter?.delete(row.fileId).catch(() => {})
23
+ await deleteFileMetaRow(db, row.fileId)
24
+ }
25
+ return rows.length
26
+ }