@aws-blocks/bb-file-bucket 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.
Files changed (87) hide show
  1. package/LICENSE +174 -0
  2. package/README.md +197 -0
  3. package/dist/bucket-name.d.ts +9 -0
  4. package/dist/bucket-name.d.ts.map +1 -0
  5. package/dist/bucket-name.js +62 -0
  6. package/dist/bucket-name.test.d.ts +2 -0
  7. package/dist/bucket-name.test.d.ts.map +1 -0
  8. package/dist/bucket-name.test.js +61 -0
  9. package/dist/errors.d.ts +20 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +21 -0
  12. package/dist/file-server.d.ts +14 -0
  13. package/dist/file-server.d.ts.map +1 -0
  14. package/dist/file-server.js +169 -0
  15. package/dist/file-server.test.d.ts +2 -0
  16. package/dist/file-server.test.d.ts.map +1 -0
  17. package/dist/file-server.test.js +307 -0
  18. package/dist/index.aws.d.ts +47 -0
  19. package/dist/index.aws.d.ts.map +1 -0
  20. package/dist/index.aws.js +183 -0
  21. package/dist/index.browser.d.ts +4 -0
  22. package/dist/index.browser.d.ts.map +1 -0
  23. package/dist/index.browser.js +6 -0
  24. package/dist/index.cdk.d.ts +16 -0
  25. package/dist/index.cdk.d.ts.map +1 -0
  26. package/dist/index.cdk.js +75 -0
  27. package/dist/index.cdk.test.d.ts +2 -0
  28. package/dist/index.cdk.test.d.ts.map +1 -0
  29. package/dist/index.cdk.test.js +69 -0
  30. package/dist/index.mock.d.ts +246 -0
  31. package/dist/index.mock.d.ts.map +1 -0
  32. package/dist/index.mock.js +502 -0
  33. package/dist/index.test.d.ts +2 -0
  34. package/dist/index.test.d.ts.map +1 -0
  35. package/dist/index.test.js +318 -0
  36. package/dist/middleware.d.ts +3 -0
  37. package/dist/middleware.d.ts.map +1 -0
  38. package/dist/middleware.js +62 -0
  39. package/dist/mock-middleware.d.ts +3 -0
  40. package/dist/mock-middleware.d.ts.map +1 -0
  41. package/dist/mock-middleware.js +62 -0
  42. package/dist/mock-utils.d.ts +11 -0
  43. package/dist/mock-utils.d.ts.map +1 -0
  44. package/dist/mock-utils.js +28 -0
  45. package/dist/path-containment.test.d.ts +2 -0
  46. package/dist/path-containment.test.d.ts.map +1 -0
  47. package/dist/path-containment.test.js +91 -0
  48. package/dist/paths.d.ts +25 -0
  49. package/dist/paths.d.ts.map +1 -0
  50. package/dist/paths.js +67 -0
  51. package/dist/scan.test.d.ts +2 -0
  52. package/dist/scan.test.d.ts.map +1 -0
  53. package/dist/scan.test.js +107 -0
  54. package/dist/tokens.d.ts +12 -0
  55. package/dist/tokens.d.ts.map +1 -0
  56. package/dist/tokens.js +42 -0
  57. package/dist/types.d.ts +170 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +3 -0
  60. package/dist/url-encoding.test.d.ts +2 -0
  61. package/dist/url-encoding.test.d.ts.map +1 -0
  62. package/dist/url-encoding.test.js +88 -0
  63. package/dist/version.d.ts +3 -0
  64. package/dist/version.d.ts.map +1 -0
  65. package/dist/version.js +3 -0
  66. package/package.json +57 -0
  67. package/src/bucket-name.test.ts +103 -0
  68. package/src/bucket-name.ts +83 -0
  69. package/src/errors.ts +22 -0
  70. package/src/file-server.test.ts +381 -0
  71. package/src/file-server.ts +203 -0
  72. package/src/index.aws.ts +219 -0
  73. package/src/index.browser.ts +7 -0
  74. package/src/index.cdk.test.ts +84 -0
  75. package/src/index.cdk.ts +89 -0
  76. package/src/index.mock.ts +531 -0
  77. package/src/index.test.ts +366 -0
  78. package/src/middleware.ts +66 -0
  79. package/src/mock-middleware.ts +66 -0
  80. package/src/mock-utils.ts +31 -0
  81. package/src/path-containment.test.ts +122 -0
  82. package/src/paths.ts +78 -0
  83. package/src/scan.test.ts +137 -0
  84. package/src/tokens.ts +61 -0
  85. package/src/types.ts +206 -0
  86. package/src/url-encoding.test.ts +120 -0
  87. package/src/version.ts +3 -0
@@ -0,0 +1,531 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { Scope, registerSdkIdentifiers } from '@aws-blocks/core';
5
+ import { getMockDataDir } from '@aws-blocks/core/bb-utils';
6
+ import type { ScopeParent } from '@aws-blocks/core';
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'node:fs';
8
+ import { dirname, join, relative } from 'node:path';
9
+ import { assertContainedPath } from './mock-utils.js';
10
+ import {
11
+ contentRoot, contentPath, metaPath,
12
+ versionsDirFor, versionContentPath, versionMetaPath,
13
+ deleteMarkerPath, isVersionEntry,
14
+ } from './paths.js';
15
+ import { mintFileToken, LOCAL_FILE_SECRET } from './tokens.js';
16
+ import { validateBucketName } from './bucket-name.js';
17
+ import type {
18
+ FileBucketOptions, PutOptions, PutUrlOptions, ScanOptions,
19
+ FileContent, FileInfo, ExternalBucketRef,
20
+ FileDownloadClient, FileUploadClient, FileVersionInfo,
21
+ GetOptionsFor, DeleteOptionsFor, GetUrlOptionsFor,
22
+ } from './types.js';
23
+
24
+ export type {
25
+ FileBucketOptions, PutOptions, GetUrlOptions, PutUrlOptions, ScanOptions,
26
+ FileContent, FileInfo, CorsRule, LifecycleRule, ExternalBucketRef,
27
+ FileDownloadClient, FileUploadClient, FileVersionInfo,
28
+ FileDownloadDescriptor, FileUploadDescriptor,
29
+ VersionedGetOptions, VersionedDeleteOptions, VersionedGetUrlOptions,
30
+ GetOptionsFor, DeleteOptionsFor, GetUrlOptionsFor,
31
+ } from './types.js';
32
+
33
+ import { Logger } from '@aws-blocks/bb-logger';
34
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
35
+ import { BB_NAME, BB_VERSION } from './version.js';
36
+
37
+ export { FileBucketErrors } from './errors.js';
38
+
39
+ // ── Helpers ─────────────────────────────────────────────────────────────────
40
+
41
+ const MAX_KEY_BYTES = 1024; // S3 key limit
42
+
43
+ function blocksError(name: string, message: string): Error {
44
+ const err = new Error(`${name}: ${message}`);
45
+ err.name = name;
46
+ return err;
47
+ }
48
+
49
+ interface SidecarMeta {
50
+ contentType: string;
51
+ metadata: Record<string, string>;
52
+ cacheControl?: string;
53
+ }
54
+
55
+ function getDevBaseUrl(): string {
56
+ const port = (globalThis as any).__BLOCKS_DEV_SERVER_PORT__ ?? 3001;
57
+ return `http://localhost:${port}`;
58
+ }
59
+
60
+ // ── FileBucket (mock) ───────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * File storage backed by Amazon S3.
64
+ *
65
+ * **When to use:** You need to store, retrieve, or serve binary files —
66
+ * user uploads, generated reports, images, videos, or static assets.
67
+ *
68
+ * **When NOT to use:** If you need structured key-value data with conditional
69
+ * writes, use `KVStore`. If you need queryable records with indexes, use
70
+ * `DistributedTable`.
71
+ *
72
+ * **Best practices:**
73
+ * - Use path prefixes to organize files (e.g., `uploads/{userId}/`, `reports/`)
74
+ * - Set `contentType` on `put()` to ensure correct MIME handling on download
75
+ * - Use presigned URLs (`getUrl` / `putUrl`) for direct browser upload/download
76
+ * - Use `getFileHandle` / `createUploadHandle` for ergonomic browser file transfers
77
+ * - Prefer `scan({ prefix })` over unscoped `scan()` to limit enumeration cost
78
+ *
79
+ * **Scaling:** S3 scales automatically. No provisioned throughput. Costs are
80
+ * per-request plus storage. Individual objects up to 5 TB. For objects larger
81
+ * than ~100 MB, consider multipart upload.
82
+ */
83
+ export class FileBucket<O extends FileBucketOptions = FileBucketOptions> extends Scope {
84
+ private dataDir: string;
85
+ private versioned: boolean;
86
+
87
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
88
+ protected log: ChildLogger;
89
+
90
+ constructor(scope: ScopeParent, id: string, options?: O) {
91
+ super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
92
+ // Validate the derived bucket name against S3's naming rules so local
93
+ // dev fails the same way a deploy would. Validate `fullId` (the real
94
+ // deployed bucket name), not the `mock-` prefixed local name, to keep
95
+ // parity with the CDK path.
96
+ if (!options?.bucket) validateBucketName(this.fullId);
97
+ this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
98
+ this.dataDir = getMockDataDir(this);
99
+ this.versioned = options?.versioned ?? false;
100
+ this.registerClientMiddleware('@aws-blocks/bb-file-bucket/middleware');
101
+ this.registerDevAttachment('@aws-blocks/bb-file-bucket/file-server');
102
+ registerSdkIdentifiers(this.fullId, { bucketName: `mock-${this.fullId}` });
103
+ // Register in global registry so the file-server can delegate PUT to bucket.put()
104
+ const registry = ((globalThis as any).__BLOCKS_FILE_BUCKET_REGISTRY__ ??= new Map());
105
+ registry.set(this.fullId, this);
106
+ }
107
+
108
+ /**
109
+ * Upload a file.
110
+ *
111
+ * Overwrites any existing file at the given path.
112
+ *
113
+ * @param path - The object key (e.g., `uploads/photo.jpg`).
114
+ * @param body - The file content.
115
+ * @param options - Optional metadata and content settings.
116
+ *
117
+ * @example
118
+ * ```typescript
119
+ * await bucket.put('reports/q1.pdf', pdfBuffer, {
120
+ * contentType: 'application/pdf',
121
+ * });
122
+ * ```
123
+ */
124
+ async put(path: string, body: Buffer | string, options?: PutOptions): Promise<void> {
125
+ this.validateKey(path);
126
+ const filePath = contentPath(this.dataDir, path);
127
+ mkdirSync(dirname(filePath), { recursive: true });
128
+
129
+ const buf = typeof body === 'string' ? Buffer.from(body) : body;
130
+ const meta: SidecarMeta = {
131
+ contentType: options?.contentType ?? 'application/octet-stream',
132
+ metadata: options?.metadata ?? {},
133
+ cacheControl: options?.cacheControl,
134
+ };
135
+
136
+ if (this.versioned) {
137
+ const versionId = this.nextVersionId(path);
138
+ const versionsDir = versionsDirFor(this.dataDir, path);
139
+ mkdirSync(versionsDir, { recursive: true });
140
+ writeFileSync(versionContentPath(this.dataDir, path, versionId), buf);
141
+ writeFileSync(versionMetaPath(this.dataDir, path, versionId), JSON.stringify(meta));
142
+ // Remove any delete marker
143
+ try { unlinkSync(deleteMarkerPath(this.dataDir, path)); } catch {}
144
+ }
145
+
146
+ this.writeMeta(path, meta);
147
+ writeFileSync(filePath, buf);
148
+ }
149
+
150
+ /**
151
+ * Download a file.
152
+ *
153
+ * @param path - The object key.
154
+ * @param options - Optional. On versioned buckets, pass `{ versionId }` to retrieve a specific version.
155
+ * @returns The file content and metadata, or null if the file does not exist.
156
+ *
157
+ * @example
158
+ * ```typescript
159
+ * const file = await bucket.get('reports/q1.pdf');
160
+ * if (file) {
161
+ * console.log(file.contentType); // 'application/pdf'
162
+ * }
163
+ * ```
164
+ */
165
+ async get(path: string, options?: GetOptionsFor<O>): Promise<FileContent | null> {
166
+ this.validateKey(path);
167
+ const versionId = (options as any)?.versionId as string | undefined;
168
+ if (versionId && this.versioned) {
169
+ const vPath = versionContentPath(this.dataDir, path, versionId);
170
+ if (!existsSync(vPath)) return null;
171
+ const body = readFileSync(vPath);
172
+ const meta = this.readVersionMeta(path, versionId);
173
+ return { body, contentType: meta.contentType, metadata: meta.metadata, size: body.length };
174
+ }
175
+ const filePath = contentPath(this.dataDir, path);
176
+ if (!existsSync(filePath)) return null;
177
+ // Check for delete marker
178
+ if (this.versioned && existsSync(deleteMarkerPath(this.dataDir, path))) return null;
179
+
180
+ const body = readFileSync(filePath);
181
+ const meta = this.readMeta(path);
182
+ return {
183
+ body,
184
+ contentType: meta.contentType,
185
+ metadata: meta.metadata,
186
+ size: body.length,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Delete a file.
192
+ *
193
+ * On non-versioned buckets, permanently removes the file.
194
+ * On versioned buckets without `versionId`, places a delete marker (file appears deleted
195
+ * but previous versions are preserved). With `versionId`, permanently removes that version.
196
+ *
197
+ * No-op if the file does not exist (matches S3 behavior).
198
+ *
199
+ * @param path - The object key.
200
+ * @param options - Optional. On versioned buckets, pass `{ versionId }` to permanently delete a specific version.
201
+ *
202
+ * @example
203
+ * ```typescript
204
+ * await bucket.delete('uploads/old-photo.jpg');
205
+ * ```
206
+ */
207
+ async delete(path: string, options?: DeleteOptionsFor<O>): Promise<void> {
208
+ this.validateKey(path);
209
+ const versionId = (options as any)?.versionId as string | undefined;
210
+ if (this.versioned && versionId) {
211
+ // Permanently delete a specific version
212
+ try { unlinkSync(versionContentPath(this.dataDir, path, versionId)); } catch {}
213
+ try { unlinkSync(versionMetaPath(this.dataDir, path, versionId)); } catch {}
214
+ return;
215
+ }
216
+ if (this.versioned) {
217
+ // Place a delete marker — current file stays on disk for version history
218
+ const versionsDir = versionsDirFor(this.dataDir, path);
219
+ mkdirSync(versionsDir, { recursive: true });
220
+ writeFileSync(deleteMarkerPath(this.dataDir, path), '');
221
+ return;
222
+ }
223
+ try { unlinkSync(contentPath(this.dataDir, path)); } catch {}
224
+ try { unlinkSync(metaPath(this.dataDir, path)); } catch {}
225
+ }
226
+
227
+ /**
228
+ * Delete multiple files in a single operation.
229
+ *
230
+ * Uses S3 DeleteObjects for efficient bulk deletion (up to 1,000 keys
231
+ * per request). Chunking is handled internally.
232
+ *
233
+ * @param paths - The object keys to delete.
234
+ *
235
+ * @example
236
+ * ```typescript
237
+ * await bucket.deleteBatch(['tmp/a.txt', 'tmp/b.txt', 'tmp/c.txt']);
238
+ * ```
239
+ */
240
+ async deleteBatch(paths: string[]): Promise<void> {
241
+ for (const p of paths) {
242
+ await this.delete(p);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Generate a presigned URL for downloading a file.
248
+ *
249
+ * @param path - The object key.
250
+ * @param options - URL generation options.
251
+ * @returns A presigned URL string.
252
+ *
253
+ * @example
254
+ * ```typescript
255
+ * const url = await bucket.getUrl('reports/q1.pdf', { expiresIn: 3600 });
256
+ * ```
257
+ */
258
+ async getUrl(path: string, options?: GetUrlOptionsFor<O>): Promise<string> {
259
+ const expiresIn = (options as any)?.expiresIn ?? 3600;
260
+ const token = mintFileToken(this.fullId, path, 'GET', expiresIn, LOCAL_FILE_SECRET);
261
+ const encodedPath = path.split('/').map(s => encodeURIComponent(s)).join('/');
262
+ let url = `${getDevBaseUrl()}/.bb-file-bucket/${this.fullId}/${encodedPath}?token=${token}`;
263
+ const versionId = (options as any)?.versionId;
264
+ if (versionId && this.versioned) url += `&versionId=${versionId}`;
265
+ return url;
266
+ }
267
+
268
+ /**
269
+ * Generate a presigned URL for uploading a file.
270
+ *
271
+ * @param path - The object key.
272
+ * @param options - URL generation options.
273
+ * @returns A presigned URL string.
274
+ *
275
+ * @example
276
+ * ```typescript
277
+ * const url = await bucket.putUrl('uploads/photo.jpg', {
278
+ * expiresIn: 600,
279
+ * contentType: 'image/jpeg',
280
+ * });
281
+ * ```
282
+ */
283
+ async putUrl(path: string, options?: PutUrlOptions): Promise<string> {
284
+ const expiresIn = options?.expiresIn ?? 3600;
285
+ const token = mintFileToken(this.fullId, path, 'PUT', expiresIn, LOCAL_FILE_SECRET, options?.contentType);
286
+ const encodedPath = path.split('/').map(s => encodeURIComponent(s)).join('/');
287
+ return `${getDevBaseUrl()}/.bb-file-bucket/${this.fullId}/${encodedPath}?token=${token}`;
288
+ }
289
+
290
+ /**
291
+ * Get a file handle for browser-side download.
292
+ *
293
+ * Returns a Transferable descriptor that the client middleware hydrates
294
+ * into an object with `.download()` and `.getUrl()` methods. Use this
295
+ * instead of `getUrl()` when you want the client to download files
296
+ * without manually managing presigned URLs.
297
+ *
298
+ * @param path - The object key.
299
+ * @param options - URL generation options.
300
+ * @returns A Transferable file download handle.
301
+ *
302
+ * @example
303
+ * ```typescript
304
+ * // Backend
305
+ * async getReport(name: string) {
306
+ * return bucket.getFileHandle('reports/' + name);
307
+ * }
308
+ *
309
+ * // Frontend
310
+ * const file = await api.getReport('q1.pdf');
311
+ * const blob = await file.download();
312
+ * ```
313
+ */
314
+ async getFileHandle(path: string, options?: GetUrlOptionsFor<O>): Promise<FileDownloadClient> {
315
+ const url = await this.getUrl(path, options);
316
+ return {
317
+ download: async () => {
318
+ const res = await fetch(url);
319
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
320
+ return res.blob();
321
+ },
322
+ getUrl: () => url,
323
+ toJSON: () => ({ __blocks: 'file-bucket/download' as const, url }),
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Create an upload handle for browser-side file upload.
329
+ *
330
+ * Returns a Transferable descriptor that the client middleware hydrates
331
+ * into an object with `.upload(body)` and `.getUrl()` methods. Use this
332
+ * instead of `putUrl()` when you want the client to upload files
333
+ * without manually managing presigned URLs.
334
+ *
335
+ * @param path - The object key.
336
+ * @param options - URL generation options.
337
+ * @returns A Transferable file upload handle.
338
+ *
339
+ * @example
340
+ * ```typescript
341
+ * // Backend
342
+ * async getUploadSlot(name: string) {
343
+ * return bucket.createUploadHandle('uploads/' + name, {
344
+ * contentType: 'image/jpeg',
345
+ * });
346
+ * }
347
+ *
348
+ * // Frontend
349
+ * const slot = await api.getUploadSlot('photo.jpg');
350
+ * await slot.upload(fileBlob);
351
+ * ```
352
+ */
353
+ async createUploadHandle(path: string, options?: PutUrlOptions): Promise<FileUploadClient> {
354
+ const expiresIn = options?.expiresIn ?? 3600;
355
+ const token = mintFileToken(this.fullId, path, 'PUT', expiresIn, LOCAL_FILE_SECRET, options?.contentType);
356
+ const encodedPath = path.split('/').map(s => encodeURIComponent(s)).join('/');
357
+ const url = `${getDevBaseUrl()}/.bb-file-bucket/${this.fullId}/${encodedPath}?token=${token}`;
358
+ const contentType = options?.contentType;
359
+ return {
360
+ upload: async (body: Blob | File | ArrayBuffer) => {
361
+ const headers: Record<string, string> = {};
362
+ if (contentType) headers['Content-Type'] = contentType;
363
+ const res = await fetch(url, { method: 'PUT', body, headers });
364
+ if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
365
+ },
366
+ getUrl: () => url,
367
+ toJSON: () => ({ __blocks: 'file-bucket/upload' as const, url, contentType }),
368
+ };
369
+ }
370
+
371
+ /**
372
+ * List files in the bucket.
373
+ *
374
+ * Returns an `AsyncIterable` that paginates internally. Use `prefix`
375
+ * to scope the listing. Without a prefix, enumerates all files —
376
+ * this is expensive on large buckets.
377
+ *
378
+ * @param options - Listing options.
379
+ * @returns An async iterable of file info objects.
380
+ *
381
+ * @example
382
+ * ```typescript
383
+ * for await (const file of bucket.scan({ prefix: 'uploads/' })) {
384
+ * console.log(file.path, file.size);
385
+ * }
386
+ * ```
387
+ */
388
+ async *scan(options?: ScanOptions): AsyncIterable<FileInfo> {
389
+ const root = contentRoot(this.dataDir);
390
+ for (const absPath of this.walkDir(root)) {
391
+ const relPath = relative(root, absPath).replace(/\\/g, '/');
392
+ if (options?.prefix && !relPath.startsWith(options.prefix)) continue;
393
+ // Skip files with delete markers
394
+ if (this.versioned && existsSync(deleteMarkerPath(this.dataDir, relPath))) continue;
395
+ const stat = statSync(absPath);
396
+ yield { path: relPath, size: stat.size, lastModified: stat.mtime };
397
+ }
398
+ }
399
+
400
+ /**
401
+ * List all versions of a file. Only available on versioned buckets.
402
+ *
403
+ * Returns versions newest-first. Includes the current version and all
404
+ * previous versions. Does not include delete markers.
405
+ *
406
+ * @param path - The object key.
407
+ * @returns An array of version metadata, newest first.
408
+ *
409
+ * @example
410
+ * ```typescript
411
+ * const versions = await bucket.listVersions('report.pdf');
412
+ * for (const v of versions) {
413
+ * console.log(v.versionId, v.lastModified, v.isCurrent);
414
+ * }
415
+ * ```
416
+ */
417
+ async listVersions(path: string): Promise<FileVersionInfo[]> {
418
+ const versionsDir = versionsDirFor(this.dataDir, path);
419
+ if (!existsSync(versionsDir)) return [];
420
+ const entries = readdirSync(versionsDir).filter(isVersionEntry);
421
+ if (entries.length === 0) return [];
422
+
423
+ const hasDeleteMarker = existsSync(deleteMarkerPath(this.dataDir, path));
424
+ const versions: FileVersionInfo[] = entries.map(versionId => {
425
+ const vPath = versionContentPath(this.dataDir, path, versionId);
426
+ const stat = statSync(vPath);
427
+ return { versionId, lastModified: stat.mtime, size: stat.size, isCurrent: false };
428
+ });
429
+ // Sort newest first; break ties by version number (descending) for deterministic ordering
430
+ versions.sort((a, b) => {
431
+ const timeDiff = b.lastModified.getTime() - a.lastModified.getTime();
432
+ if (timeDiff !== 0) return timeDiff;
433
+ const aNum = parseInt(a.versionId.slice(1), 10);
434
+ const bNum = parseInt(b.versionId.slice(1), 10);
435
+ return bNum - aNum;
436
+ });
437
+ // Mark the newest as current (unless there's a delete marker)
438
+ if (versions.length > 0 && !hasDeleteMarker) {
439
+ versions[0].isCurrent = true;
440
+ }
441
+ return versions;
442
+ }
443
+
444
+ /**
445
+ * Restore a previous version of a file, making it the current version.
446
+ *
447
+ * Creates a new version that is a copy of the specified old version.
448
+ * On S3, this is implemented as a CopyObject from the old version to the same key.
449
+ *
450
+ * @param path - The object key.
451
+ * @param versionId - The version to restore.
452
+ *
453
+ * @example
454
+ * ```typescript
455
+ * const versions = await bucket.listVersions('report.pdf');
456
+ * await bucket.restoreVersion('report.pdf', versions[1].versionId);
457
+ * ```
458
+ */
459
+ async restoreVersion(path: string, versionId: string): Promise<void> {
460
+ const vPath = versionContentPath(this.dataDir, path, versionId);
461
+ if (!existsSync(vPath)) {
462
+ throw blocksError('NoSuchVersion', `Version "${versionId}" does not exist for "${path}"`);
463
+ }
464
+ const body = readFileSync(vPath);
465
+ const meta = this.readVersionMeta(path, versionId);
466
+ await this.put(path, body, { contentType: meta.contentType, metadata: meta.metadata });
467
+ }
468
+
469
+ /**
470
+ * Create a reference to an existing S3 bucket not managed by this scope.
471
+ *
472
+ * @param bucketName - The name of the existing S3 bucket.
473
+ */
474
+ static fromExisting(bucketName: string): ExternalBucketRef {
475
+ return { __brand: 'ExternalBucketRef' as const, bucketName };
476
+ }
477
+
478
+ // ── Internal helpers ──────────────────────────────────────────────────
479
+
480
+ private validateKey(key: string): void {
481
+ if (Buffer.byteLength(key, 'utf8') > MAX_KEY_BYTES) {
482
+ this.log.warn(`Key "${key}" exceeds S3's 1,024-byte limit`);
483
+ }
484
+ assertContainedPath(contentRoot(this.dataDir), key);
485
+ }
486
+
487
+ private writeMeta(path: string, meta: SidecarMeta): void {
488
+ const mPath = metaPath(this.dataDir, path);
489
+ mkdirSync(dirname(mPath), { recursive: true });
490
+ writeFileSync(mPath, JSON.stringify(meta));
491
+ }
492
+
493
+ private readMeta(path: string): SidecarMeta {
494
+ return this.readMetaFile(metaPath(this.dataDir, path));
495
+ }
496
+
497
+ private readVersionMeta(path: string, versionId: string): SidecarMeta {
498
+ return this.readMetaFile(versionMetaPath(this.dataDir, path, versionId));
499
+ }
500
+
501
+ private readMetaFile(mPath: string): SidecarMeta {
502
+ if (existsSync(mPath)) {
503
+ try { return JSON.parse(readFileSync(mPath, 'utf8')); } catch {}
504
+ }
505
+ return { contentType: 'application/octet-stream', metadata: {} };
506
+ }
507
+
508
+ private walkDir(dir: string): string[] {
509
+ if (!existsSync(dir)) return [];
510
+ const results: string[] = [];
511
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
512
+ const full = join(dir, entry.name);
513
+ if (entry.isDirectory()) {
514
+ results.push(...this.walkDir(full));
515
+ } else {
516
+ results.push(full);
517
+ }
518
+ }
519
+ return results;
520
+ }
521
+
522
+ private nextVersionId(path: string): string {
523
+ const dir = versionsDirFor(this.dataDir, path);
524
+ if (!existsSync(dir)) return 'v1';
525
+ const existing = readdirSync(dir)
526
+ .filter(f => f.startsWith('v') && !f.includes('.'))
527
+ .map(f => parseInt(f.slice(1), 10))
528
+ .filter(n => !isNaN(n));
529
+ return `v${(existing.length > 0 ? Math.max(...existing) : 0) + 1}`;
530
+ }
531
+ }