@autumnsgrove/groveengine 0.5.0 → 0.6.1

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,221 @@
1
+ /**
2
+ * Storage Service - R2 Object Storage Abstraction
3
+ *
4
+ * Provides a clean interface for file storage operations with:
5
+ * - Automatic cache control based on content type
6
+ * - Integrated metadata storage in D1
7
+ * - Specific error types for debugging
8
+ * - ETag support for conditional requests
9
+ *
10
+ * REQUIRED SCHEMA: This service expects a `cdn_files` table in D1:
11
+ *
12
+ * ```sql
13
+ * CREATE TABLE cdn_files (
14
+ * id TEXT PRIMARY KEY,
15
+ * filename TEXT NOT NULL,
16
+ * original_filename TEXT NOT NULL,
17
+ * key TEXT NOT NULL UNIQUE,
18
+ * content_type TEXT NOT NULL,
19
+ * size_bytes INTEGER NOT NULL,
20
+ * folder TEXT DEFAULT '/',
21
+ * alt_text TEXT,
22
+ * uploaded_by TEXT NOT NULL,
23
+ * created_at TEXT NOT NULL
24
+ * );
25
+ *
26
+ * CREATE INDEX idx_cdn_files_folder ON cdn_files(folder);
27
+ * CREATE INDEX idx_cdn_files_key ON cdn_files(key);
28
+ * ```
29
+ */
30
+ export interface StorageFile {
31
+ id: string;
32
+ filename: string;
33
+ originalFilename: string;
34
+ key: string;
35
+ contentType: string;
36
+ sizeBytes: number;
37
+ folder: string;
38
+ altText: string | null;
39
+ uploadedBy: string;
40
+ createdAt: string;
41
+ }
42
+ export interface UploadOptions {
43
+ /** The file data as ArrayBuffer */
44
+ data: ArrayBuffer;
45
+ /** Original filename from the user */
46
+ filename: string;
47
+ /** MIME type of the file */
48
+ contentType: string;
49
+ /** Folder path (e.g., "/images", "/fonts") - defaults to "/" */
50
+ folder?: string;
51
+ /** Alt text for accessibility */
52
+ altText?: string;
53
+ /** User ID who uploaded the file */
54
+ uploadedBy: string;
55
+ /** Override default file size limit (in bytes) */
56
+ maxFileSize?: number;
57
+ }
58
+ export interface GetFileResult {
59
+ body: ReadableStream<Uint8Array>;
60
+ contentType: string;
61
+ cacheControl: string;
62
+ etag: string;
63
+ size: number;
64
+ }
65
+ export interface FileMetadata {
66
+ contentType: string;
67
+ cacheControl: string;
68
+ etag: string;
69
+ size: number;
70
+ }
71
+ export declare class StorageError extends Error {
72
+ readonly code: StorageErrorCode;
73
+ readonly cause?: unknown | undefined;
74
+ constructor(message: string, code: StorageErrorCode, cause?: unknown | undefined);
75
+ }
76
+ export type StorageErrorCode = 'FILE_NOT_FOUND' | 'FILE_TOO_LARGE' | 'INVALID_TYPE' | 'UPLOAD_FAILED' | 'DELETE_FAILED' | 'METADATA_FAILED' | 'BUCKET_UNAVAILABLE';
77
+ /** Storage configuration - can be customized per-upload */
78
+ export interface StorageConfig {
79
+ /** Maximum file size in bytes (default: 50MB) */
80
+ maxFileSize?: number;
81
+ /** Additional allowed content types beyond the defaults */
82
+ additionalContentTypes?: string[];
83
+ }
84
+ /** Default storage configuration */
85
+ export declare const STORAGE_DEFAULTS: {
86
+ readonly MAX_FILE_SIZE: number;
87
+ };
88
+ /**
89
+ * Validate file size and content type
90
+ *
91
+ * @param data - File data as ArrayBuffer
92
+ * @param contentType - MIME type of the file
93
+ * @param config - Optional configuration to override defaults
94
+ */
95
+ export declare function validateFile(data: ArrayBuffer, contentType: string, config?: StorageConfig): void;
96
+ /**
97
+ * Check if a content type is allowed
98
+ *
99
+ * @param contentType - MIME type to check
100
+ * @param additionalTypes - Additional types to allow beyond defaults
101
+ */
102
+ export declare function isAllowedContentType(contentType: string, additionalTypes?: string[]): boolean;
103
+ type D1DatabaseOrSession = D1Database | D1DatabaseSession;
104
+ /**
105
+ * Upload a file to storage with metadata
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * const file = await storage.uploadFile(bucket, db, {
110
+ * data: arrayBuffer,
111
+ * filename: 'photo.jpg',
112
+ * contentType: 'image/jpeg',
113
+ * folder: '/images',
114
+ * uploadedBy: userId
115
+ * });
116
+ * ```
117
+ */
118
+ export declare function uploadFile(bucket: R2Bucket, db: D1DatabaseOrSession, options: UploadOptions): Promise<StorageFile>;
119
+ /**
120
+ * Get a file from storage
121
+ *
122
+ * @example
123
+ * ```ts
124
+ * const file = await storage.getFile(bucket, 'images/photo.jpg');
125
+ * if (file) {
126
+ * return new Response(file.body, {
127
+ * headers: { 'Content-Type': file.contentType }
128
+ * });
129
+ * }
130
+ * ```
131
+ */
132
+ export declare function getFile(bucket: R2Bucket, key: string): Promise<GetFileResult | null>;
133
+ /**
134
+ * Get file metadata without downloading the body
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * const meta = await storage.getFileMetadata(bucket, 'images/photo.jpg');
139
+ * if (meta) {
140
+ * console.log(`File size: ${meta.size}`);
141
+ * }
142
+ * ```
143
+ */
144
+ export declare function getFileMetadata(bucket: R2Bucket, key: string): Promise<FileMetadata | null>;
145
+ /**
146
+ * Check if a file exists in storage
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * if (await storage.fileExists(bucket, 'images/photo.jpg')) {
151
+ * // File exists
152
+ * }
153
+ * ```
154
+ */
155
+ export declare function fileExists(bucket: R2Bucket, key: string): Promise<boolean>;
156
+ /**
157
+ * Delete a file from storage and its metadata
158
+ *
159
+ * @example
160
+ * ```ts
161
+ * await storage.deleteFile(bucket, db, fileId);
162
+ * ```
163
+ */
164
+ export declare function deleteFile(bucket: R2Bucket, db: D1DatabaseOrSession, fileId: string): Promise<void>;
165
+ /**
166
+ * Delete a file by its storage key (for cleanup operations)
167
+ *
168
+ * @example
169
+ * ```ts
170
+ * await storage.deleteFileByKey(bucket, db, 'images/photo.jpg');
171
+ * ```
172
+ */
173
+ export declare function deleteFileByKey(bucket: R2Bucket, db: D1DatabaseOrSession, key: string): Promise<void>;
174
+ /**
175
+ * Get file metadata from D1 by ID
176
+ */
177
+ export declare function getFileRecord(db: D1DatabaseOrSession, fileId: string): Promise<StorageFile | null>;
178
+ /**
179
+ * Get file metadata from D1 by key
180
+ */
181
+ export declare function getFileRecordByKey(db: D1DatabaseOrSession, key: string): Promise<StorageFile | null>;
182
+ /**
183
+ * List files in a folder
184
+ */
185
+ export declare function listFiles(db: D1DatabaseOrSession, options?: {
186
+ folder?: string;
187
+ limit?: number;
188
+ offset?: number;
189
+ }): Promise<{
190
+ files: StorageFile[];
191
+ total: number;
192
+ }>;
193
+ /**
194
+ * List all files across all folders
195
+ */
196
+ export declare function listAllFiles(db: D1DatabaseOrSession, options?: {
197
+ limit?: number;
198
+ offset?: number;
199
+ }): Promise<{
200
+ files: StorageFile[];
201
+ total: number;
202
+ }>;
203
+ /**
204
+ * Get all unique folders
205
+ */
206
+ export declare function listFolders(db: D1DatabaseOrSession): Promise<string[]>;
207
+ /**
208
+ * Update file alt text
209
+ */
210
+ export declare function updateAltText(db: D1DatabaseOrSession, fileId: string, altText: string): Promise<void>;
211
+ /**
212
+ * Check if request can use cached version (304 Not Modified)
213
+ */
214
+ export declare function shouldReturn304(request: Request, etag: string): boolean;
215
+ /**
216
+ * Build response headers for a file
217
+ */
218
+ export declare function buildFileHeaders(file: GetFileResult | FileMetadata, options?: {
219
+ enableCors?: boolean;
220
+ }): Headers;
221
+ export {};
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Storage Service - R2 Object Storage Abstraction
3
+ *
4
+ * Provides a clean interface for file storage operations with:
5
+ * - Automatic cache control based on content type
6
+ * - Integrated metadata storage in D1
7
+ * - Specific error types for debugging
8
+ * - ETag support for conditional requests
9
+ *
10
+ * REQUIRED SCHEMA: This service expects a `cdn_files` table in D1:
11
+ *
12
+ * ```sql
13
+ * CREATE TABLE cdn_files (
14
+ * id TEXT PRIMARY KEY,
15
+ * filename TEXT NOT NULL,
16
+ * original_filename TEXT NOT NULL,
17
+ * key TEXT NOT NULL UNIQUE,
18
+ * content_type TEXT NOT NULL,
19
+ * size_bytes INTEGER NOT NULL,
20
+ * folder TEXT DEFAULT '/',
21
+ * alt_text TEXT,
22
+ * uploaded_by TEXT NOT NULL,
23
+ * created_at TEXT NOT NULL
24
+ * );
25
+ *
26
+ * CREATE INDEX idx_cdn_files_folder ON cdn_files(folder);
27
+ * CREATE INDEX idx_cdn_files_key ON cdn_files(key);
28
+ * ```
29
+ */
30
+ // ============================================================================
31
+ // Errors
32
+ // ============================================================================
33
+ export class StorageError extends Error {
34
+ code;
35
+ cause;
36
+ constructor(message, code, cause) {
37
+ super(message);
38
+ this.code = code;
39
+ this.cause = cause;
40
+ this.name = 'StorageError';
41
+ }
42
+ }
43
+ // ============================================================================
44
+ // Configuration
45
+ // ============================================================================
46
+ /** Default max file size: 50MB */
47
+ const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024;
48
+ /** Default storage configuration */
49
+ export const STORAGE_DEFAULTS = {
50
+ MAX_FILE_SIZE: DEFAULT_MAX_FILE_SIZE
51
+ };
52
+ const ALLOWED_CONTENT_TYPES = new Set([
53
+ // Images
54
+ 'image/jpeg',
55
+ 'image/png',
56
+ 'image/gif',
57
+ 'image/webp',
58
+ 'image/avif',
59
+ 'image/svg+xml',
60
+ // Documents
61
+ 'application/pdf',
62
+ // Video
63
+ 'video/mp4',
64
+ 'video/webm',
65
+ // Audio
66
+ 'audio/mpeg',
67
+ 'audio/wav',
68
+ 'audio/webm',
69
+ // Fonts
70
+ 'font/woff',
71
+ 'font/woff2',
72
+ 'font/ttf',
73
+ 'font/otf',
74
+ // Web assets
75
+ 'application/json',
76
+ 'text/css',
77
+ 'text/javascript',
78
+ 'application/javascript'
79
+ ]);
80
+ const CACHE_CONTROL = {
81
+ // Immutable assets (1 year) - content-addressed or versioned
82
+ 'image/jpeg': 'public, max-age=31536000, immutable',
83
+ 'image/png': 'public, max-age=31536000, immutable',
84
+ 'image/gif': 'public, max-age=31536000, immutable',
85
+ 'image/webp': 'public, max-age=31536000, immutable',
86
+ 'image/avif': 'public, max-age=31536000, immutable',
87
+ 'image/svg+xml': 'public, max-age=31536000, immutable',
88
+ 'font/woff': 'public, max-age=31536000, immutable',
89
+ 'font/woff2': 'public, max-age=31536000, immutable',
90
+ 'font/ttf': 'public, max-age=31536000, immutable',
91
+ 'font/otf': 'public, max-age=31536000, immutable',
92
+ 'video/mp4': 'public, max-age=31536000, immutable',
93
+ 'video/webm': 'public, max-age=31536000, immutable',
94
+ 'audio/mpeg': 'public, max-age=31536000, immutable',
95
+ 'audio/wav': 'public, max-age=31536000, immutable',
96
+ 'audio/webm': 'public, max-age=31536000, immutable',
97
+ // Mutable assets (shorter TTL)
98
+ 'application/pdf': 'public, max-age=86400',
99
+ 'application/json': 'public, max-age=3600',
100
+ 'text/css': 'public, max-age=86400',
101
+ 'text/javascript': 'public, max-age=86400',
102
+ 'application/javascript': 'public, max-age=86400'
103
+ };
104
+ const DEFAULT_CACHE_CONTROL = 'public, max-age=86400';
105
+ // ============================================================================
106
+ // Utility Functions
107
+ // ============================================================================
108
+ function generateId() {
109
+ return crypto.randomUUID();
110
+ }
111
+ function now() {
112
+ return new Date().toISOString();
113
+ }
114
+ function sanitizeFilename(filename) {
115
+ return filename
116
+ .replace(/[/\\:*?"<>|]/g, '-')
117
+ .replace(/\s+/g, '-')
118
+ .replace(/-+/g, '-')
119
+ .toLowerCase();
120
+ }
121
+ function generateUniqueFilename(originalFilename) {
122
+ const ext = originalFilename.split('.').pop() || '';
123
+ const nameWithoutExt = originalFilename.slice(0, originalFilename.lastIndexOf('.')) || originalFilename;
124
+ const sanitized = sanitizeFilename(nameWithoutExt);
125
+ const timestamp = Date.now();
126
+ const random = Math.random().toString(36).substring(2, 8);
127
+ return ext ? `${sanitized}-${timestamp}-${random}.${ext}` : `${sanitized}-${timestamp}-${random}`;
128
+ }
129
+ function getCacheControl(contentType) {
130
+ return CACHE_CONTROL[contentType] || DEFAULT_CACHE_CONTROL;
131
+ }
132
+ function normalizeFolder(folder) {
133
+ if (!folder)
134
+ return '/';
135
+ return folder.startsWith('/') ? folder : `/${folder}`;
136
+ }
137
+ function buildStorageKey(folder, filename) {
138
+ const cleanFolder = normalizeFolder(folder);
139
+ return cleanFolder === '/' ? filename : `${cleanFolder.slice(1)}/${filename}`;
140
+ }
141
+ // ============================================================================
142
+ // Validation
143
+ // ============================================================================
144
+ /**
145
+ * Validate file size and content type
146
+ *
147
+ * @param data - File data as ArrayBuffer
148
+ * @param contentType - MIME type of the file
149
+ * @param config - Optional configuration to override defaults
150
+ */
151
+ export function validateFile(data, contentType, config) {
152
+ const maxSize = config?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
153
+ if (data.byteLength > maxSize) {
154
+ throw new StorageError(`File too large. Maximum size is ${maxSize / 1024 / 1024}MB`, 'FILE_TOO_LARGE');
155
+ }
156
+ const isAllowed = ALLOWED_CONTENT_TYPES.has(contentType) ||
157
+ config?.additionalContentTypes?.includes(contentType);
158
+ if (!isAllowed) {
159
+ throw new StorageError(`Content type not allowed: ${contentType}`, 'INVALID_TYPE');
160
+ }
161
+ }
162
+ /**
163
+ * Check if a content type is allowed
164
+ *
165
+ * @param contentType - MIME type to check
166
+ * @param additionalTypes - Additional types to allow beyond defaults
167
+ */
168
+ export function isAllowedContentType(contentType, additionalTypes) {
169
+ return ALLOWED_CONTENT_TYPES.has(contentType) || additionalTypes?.includes(contentType) || false;
170
+ }
171
+ /**
172
+ * Upload a file to storage with metadata
173
+ *
174
+ * @example
175
+ * ```ts
176
+ * const file = await storage.uploadFile(bucket, db, {
177
+ * data: arrayBuffer,
178
+ * filename: 'photo.jpg',
179
+ * contentType: 'image/jpeg',
180
+ * folder: '/images',
181
+ * uploadedBy: userId
182
+ * });
183
+ * ```
184
+ */
185
+ export async function uploadFile(bucket, db, options) {
186
+ const { data, filename, contentType, folder, altText, uploadedBy, maxFileSize } = options;
187
+ // Validate with optional custom size limit
188
+ validateFile(data, contentType, { maxFileSize });
189
+ // Generate unique key
190
+ const uniqueFilename = generateUniqueFilename(filename);
191
+ const normalizedFolder = normalizeFolder(folder);
192
+ const key = buildStorageKey(normalizedFolder, uniqueFilename);
193
+ // Upload to R2
194
+ try {
195
+ await bucket.put(key, data, {
196
+ httpMetadata: {
197
+ contentType,
198
+ cacheControl: getCacheControl(contentType)
199
+ }
200
+ });
201
+ }
202
+ catch (err) {
203
+ throw new StorageError('Failed to upload file to storage', 'UPLOAD_FAILED', err);
204
+ }
205
+ // Store metadata in D1
206
+ const id = generateId();
207
+ const timestamp = now();
208
+ try {
209
+ await db
210
+ .prepare(`INSERT INTO cdn_files (id, filename, original_filename, key, content_type, size_bytes, folder, alt_text, uploaded_by, created_at)
211
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
212
+ .bind(id, uniqueFilename, filename, key, contentType, data.byteLength, normalizedFolder, altText ?? null, uploadedBy, timestamp)
213
+ .run();
214
+ }
215
+ catch (err) {
216
+ // Attempt to clean up the uploaded file
217
+ try {
218
+ await bucket.delete(key);
219
+ }
220
+ catch (cleanupErr) {
221
+ console.error('[Storage] Failed to cleanup R2 object after metadata failure:', cleanupErr);
222
+ }
223
+ throw new StorageError('Failed to store file metadata', 'METADATA_FAILED', err);
224
+ }
225
+ return {
226
+ id,
227
+ filename: uniqueFilename,
228
+ originalFilename: filename,
229
+ key,
230
+ contentType,
231
+ sizeBytes: data.byteLength,
232
+ folder: normalizedFolder,
233
+ altText: altText ?? null,
234
+ uploadedBy,
235
+ createdAt: timestamp
236
+ };
237
+ }
238
+ /**
239
+ * Get a file from storage
240
+ *
241
+ * @example
242
+ * ```ts
243
+ * const file = await storage.getFile(bucket, 'images/photo.jpg');
244
+ * if (file) {
245
+ * return new Response(file.body, {
246
+ * headers: { 'Content-Type': file.contentType }
247
+ * });
248
+ * }
249
+ * ```
250
+ */
251
+ export async function getFile(bucket, key) {
252
+ const object = await bucket.get(key);
253
+ if (!object) {
254
+ return null;
255
+ }
256
+ const contentType = object.httpMetadata?.contentType || 'application/octet-stream';
257
+ return {
258
+ body: object.body,
259
+ contentType,
260
+ cacheControl: getCacheControl(contentType),
261
+ etag: object.httpEtag,
262
+ size: object.size
263
+ };
264
+ }
265
+ /**
266
+ * Get file metadata without downloading the body
267
+ *
268
+ * @example
269
+ * ```ts
270
+ * const meta = await storage.getFileMetadata(bucket, 'images/photo.jpg');
271
+ * if (meta) {
272
+ * console.log(`File size: ${meta.size}`);
273
+ * }
274
+ * ```
275
+ */
276
+ export async function getFileMetadata(bucket, key) {
277
+ const object = await bucket.head(key);
278
+ if (!object) {
279
+ return null;
280
+ }
281
+ const contentType = object.httpMetadata?.contentType || 'application/octet-stream';
282
+ return {
283
+ contentType,
284
+ cacheControl: getCacheControl(contentType),
285
+ etag: object.httpEtag,
286
+ size: object.size
287
+ };
288
+ }
289
+ /**
290
+ * Check if a file exists in storage
291
+ *
292
+ * @example
293
+ * ```ts
294
+ * if (await storage.fileExists(bucket, 'images/photo.jpg')) {
295
+ * // File exists
296
+ * }
297
+ * ```
298
+ */
299
+ export async function fileExists(bucket, key) {
300
+ const object = await bucket.head(key);
301
+ return object !== null;
302
+ }
303
+ /**
304
+ * Delete a file from storage and its metadata
305
+ *
306
+ * @example
307
+ * ```ts
308
+ * await storage.deleteFile(bucket, db, fileId);
309
+ * ```
310
+ */
311
+ export async function deleteFile(bucket, db, fileId) {
312
+ // Get file metadata first
313
+ const file = await db
314
+ .prepare('SELECT key FROM cdn_files WHERE id = ?')
315
+ .bind(fileId)
316
+ .first();
317
+ if (!file) {
318
+ throw new StorageError('File not found', 'FILE_NOT_FOUND');
319
+ }
320
+ // Delete from R2
321
+ try {
322
+ await bucket.delete(file.key);
323
+ }
324
+ catch (err) {
325
+ throw new StorageError('Failed to delete file from storage', 'DELETE_FAILED', err);
326
+ }
327
+ // Delete metadata from D1
328
+ try {
329
+ await db.prepare('DELETE FROM cdn_files WHERE id = ?').bind(fileId).run();
330
+ }
331
+ catch (err) {
332
+ throw new StorageError('Failed to delete file metadata', 'METADATA_FAILED', err);
333
+ }
334
+ }
335
+ /**
336
+ * Delete a file by its storage key (for cleanup operations)
337
+ *
338
+ * @example
339
+ * ```ts
340
+ * await storage.deleteFileByKey(bucket, db, 'images/photo.jpg');
341
+ * ```
342
+ */
343
+ export async function deleteFileByKey(bucket, db, key) {
344
+ // Delete from R2
345
+ try {
346
+ await bucket.delete(key);
347
+ }
348
+ catch (err) {
349
+ throw new StorageError('Failed to delete file from storage', 'DELETE_FAILED', err);
350
+ }
351
+ // Delete metadata from D1 (if it exists)
352
+ try {
353
+ await db.prepare('DELETE FROM cdn_files WHERE key = ?').bind(key).run();
354
+ }
355
+ catch (err) {
356
+ throw new StorageError('Failed to delete file metadata', 'METADATA_FAILED', err);
357
+ }
358
+ }
359
+ // ============================================================================
360
+ // Metadata Operations (D1 only)
361
+ // ============================================================================
362
+ /**
363
+ * Get file metadata from D1 by ID
364
+ */
365
+ export async function getFileRecord(db, fileId) {
366
+ const row = await db
367
+ .prepare('SELECT * FROM cdn_files WHERE id = ?')
368
+ .bind(fileId)
369
+ .first();
370
+ return row ? mapRowToStorageFile(row) : null;
371
+ }
372
+ /**
373
+ * Get file metadata from D1 by key
374
+ */
375
+ export async function getFileRecordByKey(db, key) {
376
+ const row = await db
377
+ .prepare('SELECT * FROM cdn_files WHERE key = ?')
378
+ .bind(key)
379
+ .first();
380
+ return row ? mapRowToStorageFile(row) : null;
381
+ }
382
+ /**
383
+ * List files in a folder
384
+ */
385
+ export async function listFiles(db, options) {
386
+ const folder = normalizeFolder(options?.folder);
387
+ const limit = options?.limit ?? 50;
388
+ const offset = options?.offset ?? 0;
389
+ const [filesResult, countResult] = await Promise.all([
390
+ db
391
+ .prepare(`SELECT * FROM cdn_files
392
+ WHERE folder = ?
393
+ ORDER BY created_at DESC
394
+ LIMIT ? OFFSET ?`)
395
+ .bind(folder, limit, offset)
396
+ .all(),
397
+ db
398
+ .prepare('SELECT COUNT(*) as count FROM cdn_files WHERE folder = ?')
399
+ .bind(folder)
400
+ .first()
401
+ ]);
402
+ return {
403
+ files: (filesResult.results ?? []).map(mapRowToStorageFile),
404
+ total: countResult?.count ?? 0
405
+ };
406
+ }
407
+ /**
408
+ * List all files across all folders
409
+ */
410
+ export async function listAllFiles(db, options) {
411
+ const limit = options?.limit ?? 50;
412
+ const offset = options?.offset ?? 0;
413
+ const [filesResult, countResult] = await Promise.all([
414
+ db
415
+ .prepare(`SELECT * FROM cdn_files
416
+ ORDER BY created_at DESC
417
+ LIMIT ? OFFSET ?`)
418
+ .bind(limit, offset)
419
+ .all(),
420
+ db.prepare('SELECT COUNT(*) as count FROM cdn_files').first()
421
+ ]);
422
+ return {
423
+ files: (filesResult.results ?? []).map(mapRowToStorageFile),
424
+ total: countResult?.count ?? 0
425
+ };
426
+ }
427
+ /**
428
+ * Get all unique folders
429
+ */
430
+ export async function listFolders(db) {
431
+ const result = await db
432
+ .prepare('SELECT DISTINCT folder FROM cdn_files ORDER BY folder')
433
+ .all();
434
+ return (result.results ?? []).map((r) => r.folder);
435
+ }
436
+ /**
437
+ * Update file alt text
438
+ */
439
+ export async function updateAltText(db, fileId, altText) {
440
+ const result = await db
441
+ .prepare('UPDATE cdn_files SET alt_text = ? WHERE id = ?')
442
+ .bind(altText, fileId)
443
+ .run();
444
+ if (result.meta.changes === 0) {
445
+ throw new StorageError('File not found', 'FILE_NOT_FOUND');
446
+ }
447
+ }
448
+ function mapRowToStorageFile(row) {
449
+ return {
450
+ id: row.id,
451
+ filename: row.filename,
452
+ originalFilename: row.original_filename,
453
+ key: row.key,
454
+ contentType: row.content_type,
455
+ sizeBytes: row.size_bytes,
456
+ folder: row.folder,
457
+ altText: row.alt_text,
458
+ uploadedBy: row.uploaded_by,
459
+ createdAt: row.created_at
460
+ };
461
+ }
462
+ // ============================================================================
463
+ // Response Helpers
464
+ // ============================================================================
465
+ /**
466
+ * Check if request can use cached version (304 Not Modified)
467
+ */
468
+ export function shouldReturn304(request, etag) {
469
+ const ifNoneMatch = request.headers.get('If-None-Match');
470
+ return ifNoneMatch === etag;
471
+ }
472
+ /**
473
+ * Build response headers for a file
474
+ */
475
+ export function buildFileHeaders(file, options) {
476
+ const headers = new Headers();
477
+ headers.set('Content-Type', file.contentType);
478
+ headers.set('Cache-Control', file.cacheControl);
479
+ headers.set('ETag', file.etag);
480
+ // Enable CORS for fonts (required for cross-origin font loading)
481
+ if (options?.enableCors || file.contentType.startsWith('font/')) {
482
+ headers.set('Access-Control-Allow-Origin', '*');
483
+ }
484
+ return headers;
485
+ }