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,273 @@
1
+ // src/storage/buckets.ts
2
+ import type { OperationRule, ScopeResolver } from '../access'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Input types (developer-facing config)
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export type BucketBackendInput =
9
+ | {
10
+ s3: {
11
+ bucket: string
12
+ region?: string
13
+ endpoint?: string
14
+ publicUrl?: string
15
+ }
16
+ }
17
+ | { local: string }
18
+
19
+ export type BucketConfigInput = {
20
+ visibility?: 'public' | 'private'
21
+ access?: {
22
+ create?: OperationRule
23
+ get?: OperationRule
24
+ delete?: OperationRule
25
+ }
26
+ upload?: { maxSize?: string | number; accept?: string[] }
27
+ transforms?: boolean
28
+ scope?: ScopeResolver
29
+ quota?: { perUser?: string | number; perScope?: string | number }
30
+ } & Partial<BucketBackendInput>
31
+
32
+ export type StorageConfigInput = {
33
+ s3?: true | { endpoint?: string }
34
+ local?: true | string
35
+ defaultBucket?: string
36
+ buckets?: Record<string, BucketConfigInput>
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Resolved types (internal)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ export type ResolvedBackend =
44
+ | { type: 'local'; path: string }
45
+ | {
46
+ type: 's3'
47
+ bucket: string
48
+ region: string
49
+ endpoint?: string
50
+ accessKeyId: string
51
+ secretAccessKey: string
52
+ publicUrl?: string
53
+ }
54
+
55
+ export type ResolvedBucket = {
56
+ name: string
57
+ backend: ResolvedBackend
58
+ visibility: 'public' | 'private'
59
+ access: { create: OperationRule; get: OperationRule; delete: OperationRule }
60
+ upload?: { maxSizeBytes?: number; accept?: string[] }
61
+ transforms: boolean
62
+ scope?: ScopeResolver
63
+ quota?: { perUserBytes?: number; perScopeBytes?: number }
64
+ }
65
+
66
+ export type ResolvedStorageBuckets = {
67
+ defaultBucket: string
68
+ buckets: Map<string, ResolvedBucket>
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // parseSize
73
+ // ---------------------------------------------------------------------------
74
+
75
+ const SIZE_UNITS: Record<string, number> = {
76
+ b: 1,
77
+ kb: 1024,
78
+ mb: 1024 * 1024,
79
+ gb: 1024 * 1024 * 1024,
80
+ }
81
+
82
+ export function parseSize(value: string | number): number {
83
+ if (typeof value === 'number') {
84
+ if (!Number.isFinite(value) || value < 0 || !Number.isInteger(value)) {
85
+ throw new Error(`[bunderstack] invalid size "${value}"`)
86
+ }
87
+ return value
88
+ }
89
+
90
+ const trimmed = value.trim()
91
+ if (trimmed === '') throw new Error(`[bunderstack] invalid size "${value}"`)
92
+
93
+ const match = /^(\d+(?:\.\d+)?)\s*([a-z]*)$/i.exec(trimmed)
94
+ if (!match) throw new Error(`[bunderstack] invalid size "${value}"`)
95
+
96
+ const numStr = match[1]
97
+ if (numStr === undefined)
98
+ throw new Error(`[bunderstack] invalid size "${value}"`)
99
+ const num = parseFloat(numStr)
100
+ const unit = (match[2] ?? '').toLowerCase()
101
+
102
+ if (unit === '') return Math.floor(num)
103
+
104
+ const multiplier = SIZE_UNITS[unit]
105
+ if (multiplier === undefined)
106
+ throw new Error(`[bunderstack] invalid size "${value}"`)
107
+
108
+ return Math.floor(num * multiplier)
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Shared backend resolution
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function resolveSharedBackend(
116
+ input: StorageConfigInput | undefined,
117
+ env: Record<string, string | undefined>,
118
+ ): ResolvedBackend {
119
+ if (!input) return { type: 'local', path: './uploads' }
120
+
121
+ if ('local' in input && input.local !== undefined) {
122
+ return {
123
+ type: 'local',
124
+ path: input.local === true ? './uploads' : input.local,
125
+ }
126
+ }
127
+
128
+ if ('s3' in input && input.s3 !== undefined) {
129
+ const s3Cfg = typeof input.s3 === 'object' ? input.s3 : {}
130
+ return {
131
+ type: 's3',
132
+ bucket: env['S3_BUCKET'] ?? '',
133
+ region: env['S3_REGION'] ?? 'us-east-1',
134
+ endpoint: s3Cfg.endpoint ?? env['S3_ENDPOINT'],
135
+ accessKeyId: env['S3_ACCESS_KEY_ID'] ?? '',
136
+ secretAccessKey: env['S3_SECRET_ACCESS_KEY'] ?? '',
137
+ }
138
+ }
139
+
140
+ return { type: 'local', path: './uploads' }
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Per-bucket backend resolution
145
+ // ---------------------------------------------------------------------------
146
+
147
+ function resolveBucketBackend(
148
+ bucketInput: BucketConfigInput,
149
+ sharedBackend: ResolvedBackend,
150
+ env: Record<string, string | undefined>,
151
+ ): ResolvedBackend {
152
+ if ('s3' in bucketInput && bucketInput.s3 !== undefined) {
153
+ const block = bucketInput.s3
154
+ return {
155
+ type: 's3',
156
+ bucket: block.bucket,
157
+ region: block.region ?? env['S3_REGION'] ?? 'us-east-1',
158
+ endpoint: block.endpoint ?? env['S3_ENDPOINT'],
159
+ accessKeyId: env['S3_ACCESS_KEY_ID'] ?? '',
160
+ secretAccessKey: env['S3_SECRET_ACCESS_KEY'] ?? '',
161
+ publicUrl: block.publicUrl,
162
+ }
163
+ }
164
+
165
+ if ('local' in bucketInput && bucketInput.local !== undefined) {
166
+ return { type: 'local', path: bucketInput.local }
167
+ }
168
+
169
+ return sharedBackend
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Bucket resolution
174
+ // ---------------------------------------------------------------------------
175
+
176
+ function resolveSingleBucket(
177
+ name: string,
178
+ input: BucketConfigInput,
179
+ sharedBackend: ResolvedBackend,
180
+ env: Record<string, string | undefined>,
181
+ ): ResolvedBucket {
182
+ const visibility = input.visibility ?? 'private'
183
+ const backend = resolveBucketBackend(input, sharedBackend, env)
184
+
185
+ const defaultGet: OperationRule = visibility === 'public' ? 'public' : 'owner'
186
+
187
+ const access: ResolvedBucket['access'] = {
188
+ create: input.access?.create ?? 'authenticated',
189
+ get: input.access?.get ?? defaultGet,
190
+ delete: input.access?.delete ?? 'owner',
191
+ }
192
+
193
+ let upload: ResolvedBucket['upload'] | undefined = undefined
194
+ if (input.upload !== undefined) {
195
+ upload = {}
196
+ if (input.upload.maxSize !== undefined) {
197
+ upload.maxSizeBytes = parseSize(input.upload.maxSize)
198
+ }
199
+ if (input.upload.accept !== undefined) {
200
+ upload.accept = input.upload.accept
201
+ }
202
+ }
203
+
204
+ let quota: ResolvedBucket['quota'] | undefined = undefined
205
+ if (input.quota !== undefined) {
206
+ quota = {}
207
+ if (input.quota.perUser !== undefined) {
208
+ quota.perUserBytes = parseSize(input.quota.perUser)
209
+ }
210
+ if (input.quota.perScope !== undefined) {
211
+ quota.perScopeBytes = parseSize(input.quota.perScope)
212
+ }
213
+ }
214
+
215
+ return {
216
+ name,
217
+ backend,
218
+ visibility,
219
+ access,
220
+ upload,
221
+ transforms: input.transforms ?? false,
222
+ scope: input.scope,
223
+ quota,
224
+ }
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // resolveBuckets
229
+ // ---------------------------------------------------------------------------
230
+
231
+ export function resolveBuckets(
232
+ input: StorageConfigInput | undefined,
233
+ env: Record<string, string | undefined> = process.env,
234
+ ): ResolvedStorageBuckets {
235
+ const sharedBackend = resolveSharedBackend(input, env)
236
+ const bucketsInput = input?.buckets
237
+
238
+ const declaredNames = bucketsInput ? Object.keys(bucketsInput) : []
239
+ const defaultBucketName =
240
+ input?.defaultBucket ??
241
+ // A single declared bucket is unambiguous — treat it as the default.
242
+ (declaredNames.length === 1 ? declaredNames[0]! : 'default')
243
+
244
+ const buckets = new Map<string, ResolvedBucket>()
245
+
246
+ if (!bucketsInput || Object.keys(bucketsInput).length === 0) {
247
+ // Synthesize a single default bucket
248
+ const defaultBucket = resolveSingleBucket(
249
+ defaultBucketName,
250
+ {},
251
+ sharedBackend,
252
+ env,
253
+ )
254
+ buckets.set(defaultBucketName, defaultBucket)
255
+ return { defaultBucket: defaultBucketName, buckets }
256
+ }
257
+
258
+ // Validate defaultBucket is declared
259
+ if (!(defaultBucketName in bucketsInput)) {
260
+ throw new Error(
261
+ `[bunderstack] defaultBucket "${defaultBucketName}" is not a declared bucket`,
262
+ )
263
+ }
264
+
265
+ for (const [name, bucketInput] of Object.entries(bucketsInput)) {
266
+ buckets.set(
267
+ name,
268
+ resolveSingleBucket(name, bucketInput, sharedBackend, env),
269
+ )
270
+ }
271
+
272
+ return { defaultBucket: defaultBucketName, buckets }
273
+ }
@@ -0,0 +1,27 @@
1
+ // src/storage/delete.ts
2
+ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
3
+
4
+ import type { StorageAdapter } from './index'
5
+
6
+ import { deleteFileMetaRow } from './file-meta'
7
+
8
+ /**
9
+ * Delete a logical file: its transform-cache derivatives (under
10
+ * `${fileId}__transforms/`), the original object, and the file-meta row.
11
+ *
12
+ * All deletion goes through this helper so the model stays addable to a future
13
+ * blob/file split (see file-meta.ts header) — never inline `adapter.delete`.
14
+ */
15
+ export async function deleteFileWithDerivatives(
16
+ adapter: StorageAdapter,
17
+ db: LibSQLDatabase<Record<string, unknown>>,
18
+ fileId: string,
19
+ ): Promise<void> {
20
+ if (adapter.list) {
21
+ const derivs = await adapter.list(`${fileId}__transforms/`)
22
+ // Ignore individual derivative-delete errors — orphan sweep is the backstop.
23
+ await Promise.all(derivs.map((k) => adapter.delete(k).catch(() => {})))
24
+ }
25
+ await adapter.delete(fileId)
26
+ await deleteFileMetaRow(db, fileId)
27
+ }
@@ -0,0 +1,224 @@
1
+ /*
2
+ * DEFERRED — logical/physical blob/file split
3
+ *
4
+ * The current model is 1:1 logical-file = physical-object: one row in
5
+ * bunderstack_file_meta maps to exactly one object in storage. A future
6
+ * blob/file split would introduce:
7
+ *
8
+ * bunderstackBlob { key, bucket, size, contentType, refcount, createdAt }
9
+ * bunderstackFile { id, blobKey, ownerId, scopeJson, status, filename,
10
+ * createdAt, confirmedAt, deletedAt }
11
+ *
12
+ * This would enable:
13
+ * - Soft/shadow delete (file.deletedAt + per-bucket retention policy)
14
+ * - Transfer ownership/scope (mutate file.ownerId / scopeJson, no byte movement)
15
+ * - Duplicate/share to many users or orgs by reference (refcount++) so sharing
16
+ * to N users stores bytes once; or by-copy via S3 CopyObject.
17
+ *
18
+ * Deletion would decrement refcount; bytes removed only at 0; orphan sweep also
19
+ * reaps refcount=0 blobs (self-heals crash mid-decrement).
20
+ *
21
+ * Deferred because refcount correctness is a real cost vs. current need.
22
+ *
23
+ * Two forward-compat invariants keep this addable without breaking the public API:
24
+ * 1. fileId is opaque — clients never parse it.
25
+ * 2. All deletion goes through the storage delete helper, never inline.
26
+ *
27
+ * See docs/plans/2026-06-28-multi-bucket-storage-design.md §6 for full rationale.
28
+ */
29
+
30
+ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
31
+
32
+ import { eq, and, lt, sql } from 'drizzle-orm'
33
+
34
+ import type { ScopeMap } from '../access'
35
+
36
+ import { rowMatchesScope } from '../access'
37
+ import { bunderstackFiles } from '../internal-tables'
38
+
39
+ export type FileMetaRow = typeof bunderstackFiles.$inferSelect
40
+
41
+ // ─── CRUD ─────────────────────────────────────────────────────────────────────
42
+
43
+ export async function insertPendingFile(
44
+ db: LibSQLDatabase<Record<string, unknown>>,
45
+ input: {
46
+ fileId: string
47
+ bucket: string
48
+ ownerId: string | null
49
+ scopeJson: string | null
50
+ filename: string | null
51
+ contentType: string | null
52
+ },
53
+ ): Promise<void> {
54
+ await db.insert(bunderstackFiles).values({
55
+ fileId: input.fileId,
56
+ bucket: input.bucket,
57
+ ownerId: input.ownerId,
58
+ scopeJson: input.scopeJson,
59
+ filename: input.filename,
60
+ contentType: input.contentType,
61
+ status: 'pending',
62
+ createdAt: Date.now(),
63
+ confirmedAt: null,
64
+ size: null,
65
+ })
66
+ }
67
+
68
+ export async function insertReadyFile(
69
+ db: LibSQLDatabase<Record<string, unknown>>,
70
+ input: {
71
+ fileId: string
72
+ bucket: string
73
+ ownerId: string | null
74
+ scopeJson: string | null
75
+ filename: string | null
76
+ contentType: string | null
77
+ size: number | null
78
+ },
79
+ ): Promise<void> {
80
+ const now = Date.now()
81
+ await db.insert(bunderstackFiles).values({
82
+ fileId: input.fileId,
83
+ bucket: input.bucket,
84
+ ownerId: input.ownerId,
85
+ scopeJson: input.scopeJson,
86
+ filename: input.filename,
87
+ contentType: input.contentType,
88
+ size: input.size,
89
+ status: 'ready',
90
+ createdAt: now,
91
+ confirmedAt: now,
92
+ })
93
+ }
94
+
95
+ export async function markFileReady(
96
+ db: LibSQLDatabase<Record<string, unknown>>,
97
+ fileId: string,
98
+ patch: { size: number | null; contentType: string | null },
99
+ ): Promise<void> {
100
+ await db
101
+ .update(bunderstackFiles)
102
+ .set({
103
+ status: 'ready',
104
+ confirmedAt: Date.now(),
105
+ size: patch.size,
106
+ contentType: patch.contentType,
107
+ })
108
+ .where(eq(bunderstackFiles.fileId, fileId))
109
+ }
110
+
111
+ export async function getFileMeta(
112
+ db: LibSQLDatabase<Record<string, unknown>>,
113
+ fileId: string,
114
+ ): Promise<FileMetaRow | null> {
115
+ const rows = await db
116
+ .select()
117
+ .from(bunderstackFiles)
118
+ .where(eq(bunderstackFiles.fileId, fileId))
119
+ .limit(1)
120
+ return rows[0] ?? null
121
+ }
122
+
123
+ export async function deleteFileMetaRow(
124
+ db: LibSQLDatabase<Record<string, unknown>>,
125
+ fileId: string,
126
+ ): Promise<void> {
127
+ await db.delete(bunderstackFiles).where(eq(bunderstackFiles.fileId, fileId))
128
+ }
129
+
130
+ // ─── Sweep ────────────────────────────────────────────────────────────────────
131
+
132
+ export async function listStalePendingFiles(
133
+ db: LibSQLDatabase<Record<string, unknown>>,
134
+ olderThanMs: number,
135
+ ): Promise<FileMetaRow[]> {
136
+ return db
137
+ .select()
138
+ .from(bunderstackFiles)
139
+ .where(
140
+ and(
141
+ eq(bunderstackFiles.status, 'pending'),
142
+ lt(bunderstackFiles.createdAt, olderThanMs),
143
+ ),
144
+ )
145
+ }
146
+
147
+ // ─── Quota ────────────────────────────────────────────────────────────────────
148
+
149
+ export async function sumReadySize(
150
+ db: LibSQLDatabase<Record<string, unknown>>,
151
+ q: { bucket: string; ownerId?: string; scopeJson?: string },
152
+ ): Promise<number> {
153
+ const conditions = [
154
+ eq(bunderstackFiles.status, 'ready'),
155
+ eq(bunderstackFiles.bucket, q.bucket),
156
+ ]
157
+ if (q.ownerId !== undefined) {
158
+ conditions.push(eq(bunderstackFiles.ownerId, q.ownerId))
159
+ }
160
+ if (q.scopeJson !== undefined) {
161
+ conditions.push(eq(bunderstackFiles.scopeJson, q.scopeJson))
162
+ }
163
+
164
+ const rows = await db
165
+ .select({
166
+ total: sql<number>`coalesce(sum(${bunderstackFiles.size}), 0)`,
167
+ })
168
+ .from(bunderstackFiles)
169
+ .where(and(...conditions))
170
+
171
+ const raw = rows[0]?.total ?? 0
172
+ return Number(raw)
173
+ }
174
+
175
+ // ─── Scope helpers ────────────────────────────────────────────────────────────
176
+
177
+ export function scopeToJson(scope: ScopeMap | undefined | null): string | null {
178
+ if (scope == null) return null
179
+ const keys = Object.keys(scope)
180
+ if (keys.length === 0) return null
181
+ const sorted = keys.sort()
182
+ const ordered: Record<string, string | string[]> = {}
183
+ for (const k of sorted) {
184
+ const value = scope[k]
185
+ if (value !== undefined) ordered[k] = value
186
+ }
187
+ return JSON.stringify(ordered)
188
+ }
189
+
190
+ function isScopeMap(value: unknown): value is ScopeMap {
191
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
192
+ return false
193
+ }
194
+ for (const item of Object.values(value)) {
195
+ if (typeof item === 'string') continue
196
+ if (Array.isArray(item) && item.every((entry) => typeof entry === 'string'))
197
+ continue
198
+ return false
199
+ }
200
+ return true
201
+ }
202
+
203
+ export function parseScopeJson(json: string | null): ScopeMap | null {
204
+ if (json == null) return null
205
+ try {
206
+ const parsed = JSON.parse(json)
207
+ return isScopeMap(parsed) ? parsed : null
208
+ } catch {
209
+ // Corrupt/legacy scope_json should not crash a read; treat as no scope.
210
+ return null
211
+ }
212
+ }
213
+
214
+ export function fileMatchesScope(
215
+ row: FileMetaRow,
216
+ requesterScope: ScopeMap | undefined,
217
+ ): boolean {
218
+ if (requesterScope == null || Object.keys(requesterScope).length === 0) {
219
+ return true
220
+ }
221
+ if (row.scopeJson == null) return false
222
+ const parsed = parseScopeJson(row.scopeJson) ?? {}
223
+ return rowMatchesScope(parsed, requesterScope)
224
+ }
@@ -0,0 +1,31 @@
1
+ // src/storage/index.ts
2
+ import { LocalStorageAdapter } from './local'
3
+ import { S3StorageAdapter } from './s3'
4
+
5
+ export type { LocalStorageAdapter, S3StorageAdapter }
6
+
7
+ export interface PresignPutOptions {
8
+ contentType?: string
9
+ expiresIn: number
10
+ }
11
+ export interface PresignGetOptions {
12
+ expiresIn: number
13
+ }
14
+
15
+ export interface StorageAdapter {
16
+ upload(
17
+ fileId: string,
18
+ data: Blob | ArrayBuffer,
19
+ contentType: string,
20
+ ): Promise<void>
21
+ get(fileId: string): Promise<Response>
22
+ delete(fileId: string): Promise<void>
23
+ exists(fileId: string): Promise<boolean>
24
+ // Optional — present on S3, absent on local (router uses proxy path for local)
25
+ presignPut?(key: string, opts: PresignPutOptions): Promise<string>
26
+ presignGet?(key: string, opts: PresignGetOptions): Promise<string>
27
+ stat?(key: string): Promise<{ size: number; contentType: string } | null>
28
+ publicUrlFor?(key: string): string | undefined
29
+ /** List full object keys under a prefix (used to reap transform derivatives). */
30
+ list?(prefix: string): Promise<string[]>
31
+ }
@@ -0,0 +1,69 @@
1
+ // src/storage/local.ts
2
+ import { mkdir, readdir } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ export class LocalStorageAdapter {
6
+ private readonly basePath: string
7
+ constructor(basePath: string) {
8
+ this.basePath = basePath
9
+ }
10
+
11
+ private filePath(fileId: string) {
12
+ return join(this.basePath, fileId)
13
+ }
14
+
15
+ async upload(
16
+ fileId: string,
17
+ data: Blob | ArrayBuffer,
18
+ contentType: string,
19
+ ): Promise<void> {
20
+ await mkdir(this.basePath, { recursive: true })
21
+ const bytes = data instanceof Blob ? await data.arrayBuffer() : data
22
+ await Bun.write(
23
+ Bun.file(this.filePath(fileId), { type: contentType }),
24
+ bytes,
25
+ )
26
+ }
27
+
28
+ async get(fileId: string): Promise<Response> {
29
+ const file = Bun.file(this.filePath(fileId))
30
+ if (!(await file.exists()))
31
+ return new Response('Not found', { status: 404 })
32
+ return new Response(file, {
33
+ headers: {
34
+ 'Content-Type': file.type,
35
+ 'Cache-Control': 'public, max-age=31536000',
36
+ },
37
+ })
38
+ }
39
+
40
+ async delete(fileId: string): Promise<void> {
41
+ const file = Bun.file(this.filePath(fileId))
42
+ if (await file.exists()) await file.unlink()
43
+ }
44
+
45
+ async exists(fileId: string): Promise<boolean> {
46
+ return Bun.file(this.filePath(fileId)).exists()
47
+ }
48
+
49
+ async stat(
50
+ key: string,
51
+ ): Promise<{ size: number; contentType: string } | null> {
52
+ const file = Bun.file(this.filePath(key))
53
+ if (!(await file.exists())) return null
54
+ return { size: file.size, contentType: file.type }
55
+ }
56
+
57
+ async list(prefix: string): Promise<string[]> {
58
+ const cleanPrefix = prefix.replace(/\/$/, '')
59
+ const dir = join(this.basePath, cleanPrefix)
60
+ let entries: string[]
61
+ try {
62
+ entries = await readdir(dir)
63
+ } catch {
64
+ // Missing dir (or prefix isn't a directory) → no derivatives.
65
+ return []
66
+ }
67
+ return entries.map((name) => `${cleanPrefix}/${name}`)
68
+ }
69
+ }
@@ -0,0 +1,40 @@
1
+ // src/storage/registry.ts
2
+ import type {
3
+ ResolvedBackend,
4
+ ResolvedBucket,
5
+ ResolvedStorageBuckets,
6
+ } from './buckets'
7
+ import type { StorageAdapter } from './index'
8
+
9
+ import { LocalStorageAdapter } from './local'
10
+ import { S3StorageAdapter } from './s3'
11
+
12
+ export interface BucketStorage {
13
+ bucket: ResolvedBucket
14
+ adapter: StorageAdapter
15
+ }
16
+ export type BucketStorageRegistry = Map<string, BucketStorage>
17
+
18
+ export function createAdapter(backend: ResolvedBackend): StorageAdapter {
19
+ if (backend.type === 'local') {
20
+ return new LocalStorageAdapter(backend.path)
21
+ }
22
+ return new S3StorageAdapter({
23
+ bucket: backend.bucket,
24
+ region: backend.region,
25
+ accessKeyId: backend.accessKeyId,
26
+ secretAccessKey: backend.secretAccessKey,
27
+ endpoint: backend.endpoint,
28
+ publicUrl: backend.publicUrl,
29
+ })
30
+ }
31
+
32
+ export function createBucketStorages(
33
+ resolved: ResolvedStorageBuckets,
34
+ ): BucketStorageRegistry {
35
+ const registry: BucketStorageRegistry = new Map()
36
+ for (const [name, bucket] of resolved.buckets) {
37
+ registry.set(name, { bucket, adapter: createAdapter(bucket.backend) })
38
+ }
39
+ return registry
40
+ }