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.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/package.json +69 -0
- package/src/access.ts +516 -0
- package/src/auth.ts +102 -0
- package/src/config.ts +152 -0
- package/src/crud.ts +389 -0
- package/src/db.ts +10 -0
- package/src/email.ts +200 -0
- package/src/env.ts +153 -0
- package/src/errors.ts +51 -0
- package/src/handler.ts +53 -0
- package/src/idempotency.ts +102 -0
- package/src/index.ts +368 -0
- package/src/internal-tables.ts +87 -0
- package/src/list-query.ts +413 -0
- package/src/provision.ts +46 -0
- package/src/rate-limit.ts +71 -0
- package/src/realtime/index.ts +245 -0
- package/src/realtime/redis.ts +204 -0
- package/src/schema-export.ts +4 -0
- package/src/scope.ts +17 -0
- package/src/storage/buckets.ts +273 -0
- package/src/storage/delete.ts +27 -0
- package/src/storage/file-meta.ts +224 -0
- package/src/storage/index.ts +31 -0
- package/src/storage/local.ts +69 -0
- package/src/storage/registry.ts +40 -0
- package/src/storage/router.ts +532 -0
- package/src/storage/s3.ts +93 -0
- package/src/storage/sweep.ts +26 -0
- package/src/storage/thumbnails.ts +65 -0
- package/src/trpc.ts +52 -0
- package/src/typeid.ts +116 -0
|
@@ -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
|
+
}
|