@gravito/nebula 3.0.1 → 4.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/dist/index.js CHANGED
@@ -1,87 +1,931 @@
1
- // src/index.ts
2
- import { mkdir } from "fs/promises";
3
- import { isAbsolute, normalize, resolve, sep } from "path";
4
- import {
5
- getRuntimeAdapter
6
- } from "@gravito/core";
7
- var LocalStorageProvider = class {
8
- rootDir;
9
- baseUrl;
10
- runtime = getRuntimeAdapter();
1
+ // src/StorageRepository.ts
2
+ var StorageRepository = class {
3
+ constructor(store, hooks) {
4
+ this.store = store;
5
+ this.hooks = hooks;
6
+ }
7
+ /**
8
+ * Stores content and triggers upload hooks.
9
+ *
10
+ * Applies the `storage:upload` filter to the data before storage and
11
+ * fires the `storage:uploaded` action upon success.
12
+ *
13
+ * @param key - The destination path
14
+ * @param data - The content to store
15
+ * @param options - Optional upload options (content-type, metadata, cache-control, etc.)
16
+ * @throws {Error} If the underlying store fails to persist the data
17
+ */
18
+ async put(key, data, options) {
19
+ const finalData = this.hooks ? await this.hooks.applyFilter("storage:upload", data, { key, options }) : data;
20
+ await this.store.put(key, finalData, options);
21
+ if (this.hooks) {
22
+ await this.hooks.doAction("storage:uploaded", { key, options });
23
+ }
24
+ }
25
+ /**
26
+ * Retrieves content and triggers hit/miss hooks.
27
+ *
28
+ * Fires `storage:hit` if the file exists, or `storage:miss` otherwise.
29
+ *
30
+ * @param key - The path of the file to retrieve
31
+ * @returns The file content as a Blob, or null if not found
32
+ */
33
+ async get(key) {
34
+ const data = await this.store.get(key);
35
+ if (this.hooks) {
36
+ if (data) {
37
+ await this.hooks.doAction("storage:hit", { key });
38
+ } else {
39
+ await this.hooks.doAction("storage:miss", { key });
40
+ }
41
+ }
42
+ return data;
43
+ }
44
+ /**
45
+ * Deletes a file and triggers the deleted hook.
46
+ *
47
+ * Fires `storage:deleted` only if the file was actually removed.
48
+ *
49
+ * @param key - The path of the file to delete
50
+ * @returns True if deleted, false if file didn't exist
51
+ */
52
+ async delete(key) {
53
+ const deleted = await this.store.delete(key);
54
+ if (deleted && this.hooks) {
55
+ await this.hooks.doAction("storage:deleted", { key });
56
+ }
57
+ return deleted;
58
+ }
59
+ /**
60
+ * Checks for file existence.
61
+ *
62
+ * @param key - The path to check
63
+ * @returns True if exists, false otherwise
64
+ */
65
+ async exists(key) {
66
+ return this.store.exists(key);
67
+ }
68
+ /**
69
+ * Copies a file and triggers the copied hook.
70
+ *
71
+ * Fires `storage:copied` upon successful completion.
72
+ *
73
+ * @param from - Source path
74
+ * @param to - Destination path
75
+ * @throws {Error} If source missing or operation fails
76
+ */
77
+ async copy(from, to) {
78
+ await this.store.copy(from, to);
79
+ if (this.hooks) {
80
+ await this.hooks.doAction("storage:copied", { from, to });
81
+ }
82
+ }
83
+ /**
84
+ * Moves a file and triggers the moved hook.
85
+ *
86
+ * Fires `storage:moved` upon successful completion.
87
+ *
88
+ * @param from - Current path
89
+ * @param to - New path
90
+ * @throws {Error} If source missing or operation fails
91
+ */
92
+ async move(from, to) {
93
+ await this.store.move(from, to);
94
+ if (this.hooks) {
95
+ await this.hooks.doAction("storage:moved", { from, to });
96
+ }
97
+ }
11
98
  /**
12
- * Create a new LocalStorageProvider.
99
+ * Lists files using an async generator.
13
100
  *
14
- * @param rootDir - The absolute path to the local storage directory.
15
- * @param baseUrl - The public URL path for accessing stored files (e.g., '/storage').
101
+ * @param prefix - Path prefix to filter by
102
+ * @returns Async iterable of storage items
103
+ * @throws {Error} If the underlying driver does not support listing
16
104
  */
105
+ async *list(prefix) {
106
+ if (!this.store.list) {
107
+ throw new Error("[StorageRepository] This storage driver does not support listing files.");
108
+ }
109
+ yield* this.store.list(prefix);
110
+ }
111
+ /**
112
+ * Lists files with pagination support.
113
+ *
114
+ * 分頁列舉檔案
115
+ * Recommended for large storage backends to prevent OOM.
116
+ * Provides cursor-based pagination for efficient enumeration.
117
+ *
118
+ * @param prefix - Path prefix to filter by
119
+ * @param options - Pagination and filtering options
120
+ * @returns Paginated list result with cursor for next page
121
+ * @throws {Error} If the underlying driver does not support paginated listing
122
+ */
123
+ async listPaginated(prefix, options) {
124
+ if (!this.store.listPaginated) {
125
+ throw new Error("[StorageRepository] This storage driver does not support paginated listing.");
126
+ }
127
+ return this.store.listPaginated(prefix, options);
128
+ }
129
+ /**
130
+ * Retrieves file metadata.
131
+ *
132
+ * @param key - Path of the file
133
+ * @returns Metadata object or null if not found
134
+ */
135
+ async getMetadata(key) {
136
+ return this.store.getMetadata(key);
137
+ }
138
+ /**
139
+ * Updates custom metadata for a file.
140
+ *
141
+ * 更新自定義 Metadata
142
+ *
143
+ * @param key - Path of the file
144
+ * @param metadata - Custom metadata to set
145
+ * @throws {Error} If the underlying driver does not support metadata updates
146
+ */
147
+ async setMetadata(key, metadata) {
148
+ if (!this.store.setMetadata) {
149
+ throw new Error("[StorageRepository] This storage driver does not support metadata updates.");
150
+ }
151
+ await this.store.setMetadata(key, metadata);
152
+ }
153
+ /**
154
+ * Generates a public URL.
155
+ *
156
+ * @param key - Path of the file
157
+ * @returns URL string
158
+ */
159
+ getUrl(key) {
160
+ return this.store.getUrl(key);
161
+ }
162
+ /**
163
+ * Generates a temporary signed URL.
164
+ *
165
+ * @param key - Path of the file
166
+ * @param expiresIn - Expiration in seconds
167
+ * @returns Signed URL string
168
+ * @throws {Error} If the underlying driver does not support signed URLs
169
+ */
170
+ async getSignedUrl(key, expiresIn) {
171
+ if (!this.store.getSignedUrl) {
172
+ throw new Error("[StorageRepository] This storage driver does not support signed URLs.");
173
+ }
174
+ return this.store.getSignedUrl(key, expiresIn);
175
+ }
176
+ /**
177
+ * Stores content from a readable stream and triggers upload hooks.
178
+ *
179
+ * 串流上傳並觸發 hooks
180
+ * Useful for handling large files without loading entire content into memory.
181
+ * Applies the `storage:upload` filter and fires the `storage:uploaded` action.
182
+ *
183
+ * @param key - The destination path
184
+ * @param stream - Readable stream containing the data
185
+ * @throws {Error} If the underlying driver does not support stream writing
186
+ */
187
+ async putStream(key, stream) {
188
+ if (!this.store.putStream) {
189
+ throw new Error("[StorageRepository] This storage driver does not support stream writing.");
190
+ }
191
+ await this.store.putStream(key, stream);
192
+ if (this.hooks) {
193
+ await this.hooks.doAction("storage:uploaded", { key });
194
+ }
195
+ }
196
+ /**
197
+ * Retrieves content as a readable stream and triggers hit/miss hooks.
198
+ *
199
+ * 串流讀取並觸發 hooks
200
+ * Useful for handling large files without loading entire content into memory.
201
+ * Fires `storage:hit` if the file exists, or `storage:miss` otherwise.
202
+ *
203
+ * @param key - Path of the file
204
+ * @returns Readable stream of file content, or null if not found
205
+ * @throws {Error} If the underlying driver does not support stream reading
206
+ */
207
+ async getStream(key) {
208
+ if (!this.store.getStream) {
209
+ throw new Error("[StorageRepository] This storage driver does not support stream reading.");
210
+ }
211
+ const stream = await this.store.getStream(key);
212
+ if (this.hooks) {
213
+ if (stream) {
214
+ await this.hooks.doAction("storage:hit", { key });
215
+ } else {
216
+ await this.hooks.doAction("storage:miss", { key });
217
+ }
218
+ }
219
+ return stream;
220
+ }
221
+ };
222
+
223
+ // src/StorageManager.ts
224
+ var StorageManager = class {
225
+ constructor(storeFactory, options, hooks) {
226
+ this.storeFactory = storeFactory;
227
+ this.options = options;
228
+ this.hooks = hooks;
229
+ }
230
+ stores = /* @__PURE__ */ new Map();
231
+ repositories = /* @__PURE__ */ new Map();
232
+ /**
233
+ * Accesses a specific storage disk by name.
234
+ *
235
+ * If no name is provided, the default disk configured during initialization is returned.
236
+ * Repositories are lazily initialized and cached for subsequent access.
237
+ *
238
+ * @param name - The unique identifier of the disk to access
239
+ * @returns A repository instance for the requested disk
240
+ * @throws {Error} If the requested disk is not configured or factory fails
241
+ */
242
+ disk(name) {
243
+ const diskName = name ?? this.options.default;
244
+ if (!this.repositories.has(diskName)) {
245
+ const store = this.resolveStore(diskName);
246
+ this.repositories.set(diskName, new StorageRepository(store, this.hooks));
247
+ }
248
+ return this.repositories.get(diskName);
249
+ }
250
+ resolveStore(name) {
251
+ if (!this.stores.has(name)) {
252
+ this.stores.set(name, this.storeFactory(name));
253
+ }
254
+ return this.stores.get(name);
255
+ }
256
+ /**
257
+ * Stores content at the specified key on the default disk.
258
+ *
259
+ * @param key - The destination path/identifier
260
+ * @param data - The content to store
261
+ * @param options - Optional upload options (content-type, metadata, cache-control, etc.)
262
+ * @returns A promise that resolves when the operation completes
263
+ * @throws {Error} If the storage operation fails on the default disk
264
+ */
265
+ put(key, data, options) {
266
+ return this.disk().put(key, data, options);
267
+ }
268
+ /**
269
+ * Retrieves content from the specified key on the default disk.
270
+ *
271
+ * @param key - The path/identifier of the file to retrieve
272
+ * @returns The file content as a Blob, or null if not found
273
+ */
274
+ get(key) {
275
+ return this.disk().get(key);
276
+ }
277
+ /**
278
+ * Removes a file from the default disk.
279
+ *
280
+ * @param key - The path/identifier of the file to delete
281
+ * @returns True if the file was deleted, false if it didn't exist
282
+ */
283
+ delete(key) {
284
+ return this.disk().delete(key);
285
+ }
286
+ /**
287
+ * Checks if a file exists on the default disk.
288
+ *
289
+ * @param key - The path/identifier to check
290
+ * @returns True if the file exists, false otherwise
291
+ */
292
+ exists(key) {
293
+ return this.disk().exists(key);
294
+ }
295
+ /**
296
+ * Creates a copy of a file on the default disk.
297
+ *
298
+ * @param from - The source path
299
+ * @param to - The destination path
300
+ * @throws {Error} If the source file does not exist or copy fails
301
+ */
302
+ copy(from, to) {
303
+ return this.disk().copy(from, to);
304
+ }
305
+ /**
306
+ * Moves or renames a file on the default disk.
307
+ *
308
+ * @param from - The current path
309
+ * @param to - The new path
310
+ * @throws {Error} If the source file does not exist or move fails
311
+ */
312
+ move(from, to) {
313
+ return this.disk().move(from, to);
314
+ }
315
+ /**
316
+ * Lists files and directories under a given prefix on the default disk.
317
+ *
318
+ * @param prefix - The directory or path prefix to list
319
+ * @returns An async iterable of storage items
320
+ * @throws {Error} If the default disk driver does not support listing
321
+ */
322
+ list(prefix) {
323
+ return this.disk().list(prefix);
324
+ }
325
+ /**
326
+ * Retrieves metadata for a specific file on the default disk.
327
+ *
328
+ * @param key - The path/identifier of the file
329
+ * @returns Metadata object or null if file not found
330
+ */
331
+ getMetadata(key) {
332
+ return this.disk().getMetadata(key);
333
+ }
334
+ /**
335
+ * Generates a public URL for a file on the default disk.
336
+ *
337
+ * @param key - The path/identifier of the file
338
+ * @returns The absolute or relative URL string
339
+ */
340
+ getUrl(key) {
341
+ return this.disk().getUrl(key);
342
+ }
343
+ /**
344
+ * Generates a temporary, signed URL for a file on the default disk.
345
+ *
346
+ * @param key - The path/identifier of the file
347
+ * @param expiresIn - Expiration time in seconds
348
+ * @returns A promise resolving to the signed URL string
349
+ * @throws {Error} If the default disk driver does not support signed URLs
350
+ */
351
+ getSignedUrl(key, expiresIn) {
352
+ return this.disk().getSignedUrl(key, expiresIn);
353
+ }
354
+ /**
355
+ * Stores content from a readable stream on the default disk.
356
+ *
357
+ * 串流上傳到預設磁碟
358
+ * Useful for handling large files without loading entire content into memory.
359
+ *
360
+ * @param key - The destination path
361
+ * @param stream - Readable stream containing the data
362
+ * @throws {Error} If the default disk driver does not support stream writing
363
+ */
364
+ putStream(key, stream) {
365
+ return this.disk().putStream(key, stream);
366
+ }
367
+ /**
368
+ * Retrieves content as a readable stream from the default disk.
369
+ *
370
+ * 從預設磁碟串流讀取
371
+ * Useful for handling large files without loading entire content into memory.
372
+ *
373
+ * @param key - The path/identifier of the file
374
+ * @returns Readable stream of file content, or null if not found
375
+ * @throws {Error} If the default disk driver does not support stream reading
376
+ */
377
+ getStream(key) {
378
+ return this.disk().getStream(key);
379
+ }
380
+ /**
381
+ * Lists files with pagination support on the default disk.
382
+ *
383
+ * 在預設磁碟上分頁列舉檔案
384
+ * Recommended for large storage backends to prevent OOM.
385
+ *
386
+ * @param prefix - Path prefix to filter by
387
+ * @param options - Pagination and filtering options
388
+ * @returns Paginated list result with cursor for next page
389
+ * @throws {Error} If the default disk driver does not support paginated listing
390
+ */
391
+ listPaginated(prefix, options) {
392
+ return this.disk().listPaginated(prefix, options);
393
+ }
394
+ /**
395
+ * Updates custom metadata for a file on the default disk.
396
+ *
397
+ * 更新預設磁碟上檔案的自定義 Metadata
398
+ *
399
+ * @param key - The path/identifier of the file
400
+ * @param metadata - Custom metadata to set
401
+ * @throws {Error} If the default disk driver does not support metadata updates
402
+ */
403
+ setMetadata(key, metadata) {
404
+ return this.disk().setMetadata(key, metadata);
405
+ }
406
+ };
407
+
408
+ // src/stores/LocalStore.ts
409
+ import { mkdir } from "fs/promises";
410
+ import { isAbsolute, normalize, resolve, sep } from "path";
411
+ import { getRuntimeAdapter } from "@gravito/core";
412
+ var LocalStore = class {
17
413
  constructor(rootDir, baseUrl = "/storage") {
18
414
  this.rootDir = rootDir;
19
415
  this.baseUrl = baseUrl;
20
416
  }
417
+ runtime = getRuntimeAdapter();
21
418
  /**
22
- * Write data to the local disk.
419
+ * Writes data to a file on the local disk.
420
+ *
421
+ * Automatically creates parent directories if they don't exist.
422
+ *
423
+ * @param key - Relative path from the root directory
424
+ * @param data - Content to write
425
+ * @param options - Optional upload options (note: customMetadata not persisted in LocalStore)
426
+ * @throws {Error} If the key is invalid or path is outside root
23
427
  */
24
- async put(key, data) {
25
- const path = this.resolveKeyPath(key);
26
- const dir = path.substring(0, path.lastIndexOf("/"));
27
- if (dir && dir !== this.rootDir) {
28
- await mkdir(dir, { recursive: true });
29
- }
428
+ async put(key, data, options) {
429
+ const path = this.resolvePath(key);
430
+ await this.ensureDirectory(path);
30
431
  await this.runtime.writeFile(path, data);
31
432
  }
32
433
  /**
33
- * Read data from the local disk.
434
+ * Reads a file from the local disk as a Blob.
435
+ *
436
+ * @param key - Relative path from the root directory
437
+ * @returns File content as Blob, or null if not found
438
+ * @throws {Error} If the key is invalid or path is outside root
34
439
  */
35
440
  async get(key) {
36
- const path = this.resolveKeyPath(key);
37
- if (!await this.runtime.exists(path)) {
441
+ if (!await this.exists(key)) {
38
442
  return null;
39
443
  }
40
- return await this.runtime.readFileAsBlob(path);
444
+ const path = this.resolvePath(key);
445
+ return this.runtime.readFileAsBlob(path);
41
446
  }
42
447
  /**
43
- * Delete a file from the local disk.
448
+ * Deletes a file from the local disk.
449
+ *
450
+ * @param key - Relative path from the root directory
451
+ * @returns True if deleted, false if file didn't exist
452
+ * @throws {Error} If the key is invalid or path is outside root
44
453
  */
45
454
  async delete(key) {
46
- await this.runtime.deleteFile(this.resolveKeyPath(key));
455
+ if (!await this.exists(key)) {
456
+ return false;
457
+ }
458
+ const path = this.resolvePath(key);
459
+ await this.runtime.deleteFile(path);
460
+ return true;
47
461
  }
48
462
  /**
49
- * Resolve the public URL for a locally stored file.
463
+ * Checks if a file exists on the local disk.
464
+ *
465
+ * @param key - Relative path from the root directory
466
+ * @returns True if exists, false otherwise
467
+ */
468
+ async exists(key) {
469
+ const path = this.resolvePath(key);
470
+ return this.runtime.exists(path);
471
+ }
472
+ /**
473
+ * Copies a file on the local disk.
474
+ *
475
+ * @param from - Source relative path
476
+ * @param to - Destination relative path
477
+ * @throws {Error} If source missing or operation fails
478
+ */
479
+ async copy(from, to) {
480
+ const data = await this.get(from);
481
+ if (!data) {
482
+ throw new Error(`[LocalStore] Source file not found: ${from}`);
483
+ }
484
+ await this.put(to, data);
485
+ }
486
+ /**
487
+ * Moves a file on the local disk.
488
+ *
489
+ * @param from - Current relative path
490
+ * @param to - New relative path
491
+ * @throws {Error} If source missing or operation fails
492
+ */
493
+ async move(from, to) {
494
+ await this.copy(from, to);
495
+ await this.delete(from);
496
+ }
497
+ /**
498
+ * @internal
499
+ * @throws {Error} Always, as not yet implemented
500
+ */
501
+ list(_prefix = "") {
502
+ throw new Error(
503
+ "[LocalStore] list() is not yet implemented. Requires RuntimeAdapter.readDir() support in @gravito/core."
504
+ );
505
+ }
506
+ /**
507
+ * Retrieves file metadata from the local filesystem.
508
+ *
509
+ * @param key - Relative path from the root directory
510
+ * @returns Metadata object or null if not found
511
+ */
512
+ async getMetadata(key) {
513
+ if (!await this.exists(key)) {
514
+ return null;
515
+ }
516
+ const path = this.resolvePath(key);
517
+ const stat = await this.runtime.stat(path);
518
+ return {
519
+ key,
520
+ size: stat.size,
521
+ mimeType: this.guessMimeType(key),
522
+ lastModified: /* @__PURE__ */ new Date()
523
+ };
524
+ }
525
+ /**
526
+ * Generates a public URL based on the configured base URL.
527
+ *
528
+ * @param key - Relative path from the root directory
529
+ * @returns URL string
50
530
  */
51
531
  getUrl(key) {
52
532
  const safeKey = this.normalizeKey(key);
53
533
  return `${this.baseUrl}/${safeKey}`;
54
534
  }
535
+ /**
536
+ * Writes data from a readable stream to a file on the local disk.
537
+ *
538
+ * 串流寫入檔案
539
+ * Automatically creates parent directories if they don't exist.
540
+ * Useful for handling large files without loading entire content into memory.
541
+ *
542
+ * @param key - Relative path from the root directory
543
+ * @param stream - Readable stream containing the data
544
+ * @throws {Error} If the key is invalid or path is outside root
545
+ */
546
+ async putStream(key, stream) {
547
+ const path = this.resolvePath(key);
548
+ await this.ensureDirectory(path);
549
+ const file = Bun.file(path);
550
+ const writer = file.writer();
551
+ const reader = stream.getReader();
552
+ try {
553
+ while (true) {
554
+ const { done, value } = await reader.read();
555
+ if (done) break;
556
+ writer.write(value);
557
+ }
558
+ await writer.end();
559
+ } catch (error) {
560
+ reader.releaseLock();
561
+ throw new Error(`[LocalStore] Failed to write stream: ${error}`);
562
+ }
563
+ }
564
+ /**
565
+ * Reads a file from the local disk as a readable stream.
566
+ *
567
+ * 串流讀取檔案
568
+ * Useful for handling large files without loading entire content into memory.
569
+ *
570
+ * @param key - Relative path from the root directory
571
+ * @returns Readable stream of file content, or null if not found
572
+ * @throws {Error} If the key is invalid or path is outside root
573
+ */
574
+ async getStream(key) {
575
+ if (!await this.exists(key)) {
576
+ return null;
577
+ }
578
+ const path = this.resolvePath(key);
579
+ const file = Bun.file(path);
580
+ return file.stream();
581
+ }
55
582
  normalizeKey(key) {
56
583
  if (!key || key.includes("\0")) {
57
- throw new Error("Invalid storage key.");
584
+ throw new Error("[LocalStore] Invalid storage key: empty or contains null byte.");
58
585
  }
59
586
  const normalized = normalize(key).replace(/^[/\\]+/, "");
60
- if (normalized === "." || normalized === ".." || normalized.startsWith(`..${sep}`) || isAbsolute(normalized)) {
61
- throw new Error("Invalid storage key.");
587
+ if (normalized === "." || normalized === ".." || normalized.startsWith(`..${sep}`) || normalized.startsWith(`.${sep}`) || isAbsolute(normalized)) {
588
+ throw new Error("[LocalStore] Invalid storage key: path traversal attempt.");
62
589
  }
63
590
  return normalized.replace(/\\/g, "/");
64
591
  }
65
- resolveKeyPath(key) {
592
+ resolvePath(key) {
66
593
  const normalized = this.normalizeKey(key);
67
594
  const root = resolve(this.rootDir);
68
595
  const resolved = resolve(root, normalized);
69
596
  const rootPrefix = root.endsWith(sep) ? root : `${root}${sep}`;
70
597
  if (!resolved.startsWith(rootPrefix) && resolved !== root) {
71
- throw new Error("Invalid storage key.");
598
+ throw new Error("[LocalStore] Invalid storage key: resolved path outside root.");
72
599
  }
73
600
  return resolved;
74
601
  }
602
+ async ensureDirectory(filePath) {
603
+ const dir = filePath.substring(0, filePath.lastIndexOf(sep));
604
+ if (dir && dir !== this.rootDir) {
605
+ await mkdir(dir, { recursive: true });
606
+ }
607
+ }
608
+ guessMimeType(key) {
609
+ const ext = key.split(".").pop()?.toLowerCase();
610
+ const mimeTypes = {
611
+ txt: "text/plain",
612
+ html: "text/html",
613
+ css: "text/css",
614
+ js: "text/javascript",
615
+ json: "application/json",
616
+ png: "image/png",
617
+ jpg: "image/jpeg",
618
+ jpeg: "image/jpeg",
619
+ gif: "image/gif",
620
+ svg: "image/svg+xml",
621
+ pdf: "application/pdf",
622
+ zip: "application/zip"
623
+ };
624
+ return mimeTypes[ext ?? ""] ?? "application/octet-stream";
625
+ }
75
626
  };
627
+
628
+ // src/stores/MemoryStore.ts
629
+ var MemoryStore = class {
630
+ files = /* @__PURE__ */ new Map();
631
+ /**
632
+ * Stores data in the internal Map.
633
+ *
634
+ * Converts input data to a Blob before storage.
635
+ *
636
+ * @param key - Unique identifier for the file
637
+ * @param data - Content to store
638
+ * @param options - Optional upload options (content-type, metadata, etc.)
639
+ */
640
+ async put(key, data, options) {
641
+ let blob;
642
+ if (data instanceof Blob) {
643
+ blob = data;
644
+ } else if (typeof data === "string") {
645
+ blob = new Blob([data], { type: options?.contentType });
646
+ } else {
647
+ blob = new Blob([new Uint8Array(data)], { type: options?.contentType });
648
+ }
649
+ this.files.set(key, {
650
+ data: blob,
651
+ options,
652
+ metadata: {
653
+ key,
654
+ size: blob.size,
655
+ mimeType: options?.contentType || blob.type || "application/octet-stream",
656
+ lastModified: /* @__PURE__ */ new Date(),
657
+ customMetadata: options?.metadata
658
+ }
659
+ });
660
+ }
661
+ /**
662
+ * Retrieves a Blob from the internal Map.
663
+ *
664
+ * @param key - Unique identifier for the file
665
+ * @returns The Blob content, or null if not found
666
+ */
667
+ async get(key) {
668
+ return this.files.get(key)?.data ?? null;
669
+ }
670
+ /**
671
+ * Removes a file from memory.
672
+ *
673
+ * @param key - Unique identifier for the file
674
+ * @returns True if deleted, false if not found
675
+ */
676
+ async delete(key) {
677
+ return this.files.delete(key);
678
+ }
679
+ /**
680
+ * Checks if a key exists in the internal Map.
681
+ *
682
+ * @param key - Unique identifier to check
683
+ * @returns True if exists, false otherwise
684
+ */
685
+ async exists(key) {
686
+ return this.files.has(key);
687
+ }
688
+ /**
689
+ * Copies a file within memory.
690
+ *
691
+ * @param from - Source key
692
+ * @param to - Destination key
693
+ * @throws {Error} If source file is missing
694
+ */
695
+ async copy(from, to) {
696
+ const file = this.files.get(from);
697
+ if (!file) {
698
+ throw new Error(`[MemoryStore] Source file not found: ${from}`);
699
+ }
700
+ this.files.set(to, {
701
+ data: file.data,
702
+ metadata: { ...file.metadata, key: to, lastModified: /* @__PURE__ */ new Date() }
703
+ });
704
+ }
705
+ /**
706
+ * Moves a file within memory.
707
+ *
708
+ * @param from - Current key
709
+ * @param to - New key
710
+ * @throws {Error} If source file is missing
711
+ */
712
+ async move(from, to) {
713
+ await this.copy(from, to);
714
+ await this.delete(from);
715
+ }
716
+ /**
717
+ * Lists all files currently in memory.
718
+ *
719
+ * @param prefix - Optional key prefix to filter by
720
+ * @returns Async iterable of storage items
721
+ */
722
+ async *list(prefix = "") {
723
+ for (const [key, file] of this.files.entries()) {
724
+ if (key.startsWith(prefix)) {
725
+ yield {
726
+ key,
727
+ isDirectory: false,
728
+ size: file.metadata.size,
729
+ lastModified: file.metadata.lastModified
730
+ };
731
+ }
732
+ }
733
+ }
734
+ /**
735
+ * Retrieves metadata for an in-memory file.
736
+ *
737
+ * @param key - Unique identifier for the file
738
+ * @returns Metadata object or null if not found
739
+ */
740
+ async getMetadata(key) {
741
+ return this.files.get(key)?.metadata ?? null;
742
+ }
743
+ /**
744
+ * Generates a dummy URL for the in-memory file.
745
+ *
746
+ * @param key - Unique identifier for the file
747
+ * @returns A string starting with /memory/
748
+ */
749
+ getUrl(key) {
750
+ return `/memory/${key}`;
751
+ }
752
+ /**
753
+ * Stores data from a readable stream in memory.
754
+ *
755
+ * 串流寫入記憶體
756
+ * Reads the entire stream into memory before storing.
757
+ *
758
+ * @param key - Unique identifier for the file
759
+ * @param stream - Readable stream containing the data
760
+ */
761
+ async putStream(key, stream) {
762
+ const reader = stream.getReader();
763
+ const chunks = [];
764
+ try {
765
+ while (true) {
766
+ const { done, value } = await reader.read();
767
+ if (done) break;
768
+ chunks.push(value);
769
+ }
770
+ const buffer = Buffer.concat(chunks);
771
+ const blob = new Blob([buffer]);
772
+ await this.put(key, blob);
773
+ } catch (error) {
774
+ reader.releaseLock();
775
+ throw new Error(`[MemoryStore] Failed to write stream: ${error}`);
776
+ }
777
+ }
778
+ /**
779
+ * Reads an in-memory file as a readable stream.
780
+ *
781
+ * 串流讀取記憶體檔案
782
+ *
783
+ * @param key - Unique identifier for the file
784
+ * @returns Readable stream of file content, or null if not found
785
+ */
786
+ async getStream(key) {
787
+ const blob = await this.get(key);
788
+ if (!blob) {
789
+ return null;
790
+ }
791
+ return blob.stream();
792
+ }
793
+ /**
794
+ * Updates custom metadata for an in-memory file.
795
+ *
796
+ * 更新自定義 Metadata
797
+ *
798
+ * @param key - Unique identifier for the file
799
+ * @param metadata - Custom metadata to set
800
+ * @throws {Error} If file does not exist
801
+ */
802
+ async setMetadata(key, metadata) {
803
+ const file = this.files.get(key);
804
+ if (!file) {
805
+ throw new Error(`[MemoryStore] File not found: ${key}`);
806
+ }
807
+ file.metadata.customMetadata = {
808
+ ...file.metadata.customMetadata,
809
+ ...metadata
810
+ };
811
+ file.metadata.lastModified = /* @__PURE__ */ new Date();
812
+ }
813
+ /**
814
+ * Lists files with pagination support.
815
+ *
816
+ * 分頁列舉記憶體中的檔案
817
+ * Provides cursor-based pagination for consistent API with other drivers.
818
+ *
819
+ * @param prefix - Key prefix to filter by
820
+ * @param options - Pagination and filtering options
821
+ * @returns Paginated list result
822
+ */
823
+ async listPaginated(prefix = "", options) {
824
+ const maxResults = options?.maxResults ?? 1e3;
825
+ const cursorKey = options?.cursor;
826
+ const allKeys = Array.from(this.files.keys()).filter((key) => key.startsWith(prefix)).sort();
827
+ let startIndex = 0;
828
+ if (cursorKey) {
829
+ startIndex = allKeys.findIndex((key) => key > cursorKey);
830
+ if (startIndex === -1) {
831
+ return {
832
+ items: [],
833
+ nextCursor: null,
834
+ hasMore: false,
835
+ count: 0
836
+ };
837
+ }
838
+ }
839
+ const endIndex = startIndex + maxResults;
840
+ const pageKeys = allKeys.slice(startIndex, endIndex);
841
+ const hasMore = endIndex < allKeys.length;
842
+ const items = pageKeys.map((key) => {
843
+ const file = this.files.get(key);
844
+ return {
845
+ key,
846
+ isDirectory: false,
847
+ size: file.metadata.size,
848
+ lastModified: file.metadata.lastModified
849
+ };
850
+ });
851
+ const nextCursor = hasMore && items.length > 0 ? items[items.length - 1].key : null;
852
+ return {
853
+ items,
854
+ nextCursor,
855
+ hasMore,
856
+ count: items.length
857
+ };
858
+ }
859
+ };
860
+
861
+ // src/stores/NullStore.ts
862
+ var NullStore = class {
863
+ /**
864
+ * No-op: does not store any data.
865
+ */
866
+ async put(_key, _data) {
867
+ }
868
+ /**
869
+ * Always returns null.
870
+ */
871
+ async get(_key) {
872
+ return null;
873
+ }
874
+ /**
875
+ * Always returns false.
876
+ */
877
+ async delete(_key) {
878
+ return false;
879
+ }
880
+ /**
881
+ * Always returns false.
882
+ */
883
+ async exists(_key) {
884
+ return false;
885
+ }
886
+ /**
887
+ * No-op: does not copy anything.
888
+ */
889
+ async copy(_from, _to) {
890
+ }
891
+ /**
892
+ * No-op: does not move anything.
893
+ */
894
+ async move(_from, _to) {
895
+ }
896
+ /**
897
+ * Always yields nothing.
898
+ */
899
+ async *list(_prefix) {
900
+ }
901
+ /**
902
+ * Always returns null.
903
+ */
904
+ async getMetadata(_key) {
905
+ return null;
906
+ }
907
+ /**
908
+ * Generates a dummy URL starting with /null/.
909
+ */
910
+ getUrl(key) {
911
+ return `/null/${key}`;
912
+ }
913
+ };
914
+
915
+ // src/index.ts
76
916
  var OrbitNebula = class {
77
917
  constructor(options) {
78
918
  this.options = options;
79
919
  }
920
+ manager;
80
921
  /**
81
- * Install storage service into PlanetCore.
922
+ * Bootstraps the storage service and registers it with PlanetCore.
82
923
  *
83
- * @param core - The PlanetCore instance.
84
- * @throws {Error} If configuration or provider is missing.
924
+ * This method initializes the StorageManager, configures the default disk,
925
+ * and registers the storage service in the IoC container and middleware.
926
+ *
927
+ * @param core - The PlanetCore instance to install into
928
+ * @throws {Error} If configuration is missing or default disk cannot be initialized
85
929
  */
86
930
  install(core) {
87
931
  const config = this.options || core.config.get("storage");
@@ -91,62 +935,93 @@ var OrbitNebula = class {
91
935
  );
92
936
  }
93
937
  const { exposeAs = "storage" } = config;
94
- const logger = core.logger;
95
- logger.info(`[OrbitNebula] Initializing Storage (Exposed as: ${exposeAs})`);
96
- let provider = config.provider;
97
- if (!provider && config.local) {
98
- logger.info(`[OrbitNebula] Using LocalStorageProvider at ${config.local.root}`);
99
- provider = new LocalStorageProvider(config.local.root, config.local.baseUrl);
100
- }
101
- if (!provider) {
102
- throw new Error(
103
- "[OrbitNebula] No provider configured. Please provide a provider instance or local configuration."
104
- );
938
+ core.logger.info(`[OrbitNebula] Initializing Storage (Exposed as: ${exposeAs})`);
939
+ let defaultDisk = config.default;
940
+ if (!defaultDisk) {
941
+ if (config.local) {
942
+ defaultDisk = "local";
943
+ } else if (config.provider) {
944
+ defaultDisk = "custom";
945
+ } else {
946
+ defaultDisk = "local";
947
+ }
105
948
  }
106
- const storageService = {
107
- put: async (key, data) => {
108
- const finalData = await core.hooks.applyFilters("storage:upload", data, { key });
109
- await provider?.put(key, finalData);
110
- await core.hooks.doAction("storage:uploaded", { key });
111
- },
112
- get: (key) => provider?.get(key),
113
- delete: (key) => provider?.delete(key),
114
- getUrl: (key) => provider?.getUrl(key)
949
+ const managerOptions = {
950
+ default: defaultDisk
951
+ };
952
+ const storageHooks = {
953
+ applyFilter: (h, v, c) => core.hooks.applyFilters(h, v, c),
954
+ doAction: (h, c) => core.hooks.doAction(h, c)
115
955
  };
116
- core.container.instance(exposeAs, storageService);
956
+ const storeFactory = this.createStoreFactory(config);
957
+ this.manager = new StorageManager(storeFactory, managerOptions, storageHooks);
958
+ this.manager.disk();
959
+ core.container.instance(exposeAs, this.manager);
117
960
  core.adapter.use("*", async (c, next) => {
118
- c.set(exposeAs, storageService);
961
+ c.set(exposeAs, this.manager);
119
962
  await next();
120
963
  return void 0;
121
964
  });
122
- core.hooks.doAction("storage:init", storageService);
965
+ core.hooks.doAction("storage:init", { manager: this.manager });
966
+ }
967
+ /**
968
+ * Retrieves the initialized StorageManager instance.
969
+ *
970
+ * Use this to access storage operations directly from the orbit instance
971
+ * after it has been installed.
972
+ *
973
+ * @returns The active StorageManager instance
974
+ * @throws {Error} If called before the orbit is installed
975
+ */
976
+ getStorage() {
977
+ if (!this.manager) {
978
+ throw new Error("[OrbitNebula] StorageManager not initialized. Call install() first.");
979
+ }
980
+ return this.manager;
981
+ }
982
+ createStoreFactory(options) {
983
+ return (name) => {
984
+ const config = options.disks?.[name];
985
+ if (config) {
986
+ if (config.driver === "local") {
987
+ return new LocalStore(config.root, config.baseUrl);
988
+ }
989
+ if (config.driver === "memory") {
990
+ return new MemoryStore();
991
+ }
992
+ if (config.driver === "null") {
993
+ return new NullStore();
994
+ }
995
+ if (config.driver === "custom") {
996
+ return config.store;
997
+ }
998
+ }
999
+ if (name === "local" && options.local) {
1000
+ return new LocalStore(options.local.root, options.local.baseUrl);
1001
+ }
1002
+ if (options.provider) {
1003
+ if (name === "custom" || !options.disks && !options.local) {
1004
+ return options.provider;
1005
+ }
1006
+ }
1007
+ throw new Error(`[OrbitNebula] Driver not configured for disk: ${name}`);
1008
+ };
123
1009
  }
124
1010
  };
125
1011
  function orbitStorage(core, options) {
126
1012
  const orbit = new OrbitNebula(options);
127
1013
  orbit.install(core);
128
- let provider = options.provider;
129
- if (!provider && options.local) {
130
- provider = new LocalStorageProvider(options.local.root, options.local.baseUrl);
131
- }
132
- if (!provider) {
133
- throw new Error("[OrbitNebula] No provider configured.");
134
- }
135
- return {
136
- put: async (key, data) => {
137
- const finalData = await core.hooks.applyFilters("storage:upload", data, { key });
138
- await provider?.put(key, finalData);
139
- await core.hooks.doAction("storage:uploaded", { key });
140
- },
141
- get: (key) => provider?.get(key),
142
- delete: (key) => provider?.delete(key),
143
- getUrl: (key) => provider?.getUrl(key)
144
- };
1014
+ return orbit.getStorage();
145
1015
  }
146
1016
  var OrbitStorage = OrbitNebula;
147
1017
  export {
148
- LocalStorageProvider,
1018
+ LocalStore as LocalStorageProvider,
1019
+ LocalStore,
1020
+ MemoryStore,
1021
+ NullStore,
149
1022
  OrbitNebula,
150
1023
  OrbitStorage,
1024
+ StorageManager,
1025
+ StorageRepository,
151
1026
  orbitStorage as default
152
1027
  };