pyric 0.1.0-alpha.21 → 0.1.0-alpha.22

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.
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * One database (`pyric-storage` by default), two object stores —
5
5
  * `blobs` for the file content and `metadata` for the descriptive
6
- * record. Both stores key on the file's full path string. Object
7
- * upload writes both stores within a single `readwrite` transaction
8
- * so the pair stays consistent — a partial failure can't leave a
9
- * blob without metadata or vice versa.
6
+ * record. Both stores key on composite bucket and path strings
7
+ * (`${bucket}/${fullPath}`) to isolate storage persistence across
8
+ * buckets. Object upload writes both stores within a single `readwrite`
9
+ * transaction so the pair stays consistent — a partial failure can't
10
+ * leave a blob without metadata or vice versa.
10
11
  *
11
12
  * Slice 3 of the v1 scope (per the design rationale):
12
13
  *
@@ -43,6 +44,30 @@
43
44
  */
44
45
  const DEFAULT_DB_NAME = 'pyric-storage';
45
46
 
47
+ /**
48
+ * Default sandbox bucket identifier when not explicitly provided.
49
+ */
50
+ export const DEFAULT_BUCKET = 'pyric-default';
51
+
52
+ /**
53
+ * Encode a composite key scoping an object path to its bucket: `${bucket}/${fullPath}`.
54
+ */
55
+ export function toStorageKey(bucket: string, path: string): string {
56
+ return `${bucket}/${path}`;
57
+ }
58
+
59
+ /**
60
+ * Decode a composite key into bucket and object path.
61
+ */
62
+ export function parseStorageKey(key: string): { bucket: string; path: string } | null {
63
+ const slashIdx = key.indexOf('/');
64
+ if (slashIdx === -1) return null;
65
+ return {
66
+ bucket: key.slice(0, slashIdx),
67
+ path: key.slice(slashIdx + 1),
68
+ };
69
+ }
70
+
46
71
  /**
47
72
  * Resolve the default IndexedDB database name for a project identity:
48
73
  * `pyric-storage:<projectId>`, or the legacy shared {@link DEFAULT_DB_NAME}
@@ -54,9 +79,7 @@ export function storageDbName(projectId?: string | null): string {
54
79
  }
55
80
  /**
56
81
  * Schema version. Bump and add an `upgradeneeded` branch when the
57
- * store layout changes. Slice 3 ships v1: two object stores keyed by
58
- * fullPath, no indexes. Future migrations follow IndexedDB's standard
59
- * upgrade path.
82
+ * store layout changes.
60
83
  */
61
84
  const SCHEMA_VERSION = 1;
62
85
  const BLOBS_STORE = 'blobs';
@@ -74,11 +97,11 @@ const METADATA_STORE = 'metadata';
74
97
  * caller's `SettableMetadata`.
75
98
  */
76
99
  export interface StoredMetadata {
77
- /** Full path of the object within the bucket. Also the store key. */
100
+ /** Full path of the object within the bucket. */
78
101
  fullPath: string;
79
102
  /** Last path segment (`b.txt` in `a/b.txt`). */
80
103
  name: string;
81
- /** Sandbox bucket id. v1 has a single implicit bucket. */
104
+ /** Sandbox bucket id. */
82
105
  bucket: string;
83
106
  /** Monotonically increasing version of the object content. */
84
107
  generation: string;
@@ -122,16 +145,16 @@ export interface StorageBackend {
122
145
  put(path: string, blob: Blob, metadata: StoredMetadata): Promise<void>;
123
146
 
124
147
  /**
125
- * Read the blob stored at `path`. Resolves to `undefined` when no
148
+ * Read the blob stored at `path` within `bucket`. Resolves to `undefined` when no
126
149
  * entry exists — callers translate that into `object-not-found`.
127
150
  */
128
- getBlob(path: string): Promise<Blob | undefined>;
151
+ getBlob(path: string, bucket?: string): Promise<Blob | undefined>;
129
152
 
130
153
  /**
131
- * Read the metadata record stored at `path`. Resolves to
154
+ * Read the metadata record stored at `path` within `bucket`. Resolves to
132
155
  * `undefined` when no entry exists.
133
156
  */
134
- getMetadata(path: string): Promise<StoredMetadata | undefined>;
157
+ getMetadata(path: string, bucket?: string): Promise<StoredMetadata | undefined>;
135
158
 
136
159
  /**
137
160
  * Replace the metadata record at `path` without touching the blob.
@@ -139,31 +162,37 @@ export interface StorageBackend {
139
162
  * preserving server-set fields (`generation`, `timeCreated`, …)
140
163
  * the spec says metadata-only updates leave intact.
141
164
  */
142
- putMetadata(path: string, metadata: StoredMetadata): Promise<void>;
165
+ putMetadata(path: string, metadata: StoredMetadata, bucket?: string): Promise<void>;
143
166
 
144
167
  /**
145
168
  * Delete both the blob and the metadata at `path` atomically.
146
169
  * No-op when neither entry exists.
147
170
  */
148
- delete(path: string): Promise<void>;
171
+ delete(path: string, bucket?: string): Promise<void>;
149
172
 
150
173
  /**
151
174
  * Enumerate metadata records whose `fullPath` starts with `prefix`.
152
- * Returned in IndexedDB key order (lexicographic by path). The
175
+ * Returned in key order (lexicographic by path within bucket). The
153
176
  * prefix is used verbatim — no implicit trailing-slash addition,
154
177
  * matching the survey's path-semantics call (Section 6).
155
178
  */
156
- listByPrefix(prefix: string): Promise<StoredMetadata[]>;
179
+ listByPrefix(prefix: string, bucket?: string): Promise<StoredMetadata[]>;
157
180
 
158
181
  /**
159
- * Clear both stores. Called by Slice 4's `StorageService.reset`.
182
+ * Clear stores. If `bucket` is supplied, clears only entries in that bucket;
183
+ * otherwise clears all stores.
160
184
  */
161
- reset(): Promise<void>;
185
+ reset(bucket?: string): Promise<void>;
162
186
 
163
187
  /**
164
188
  * Close the underlying IndexedDB connection. Idempotent.
165
189
  */
166
190
  close(): void;
191
+
192
+ /**
193
+ * Return a view of this backend scoped to a specific bucket.
194
+ */
195
+ scoped?(bucket: string): StorageBackend;
167
196
  }
168
197
 
169
198
  /**
@@ -178,46 +207,97 @@ export interface StorageBackend {
178
207
  */
179
208
  export async function openStorageBackend(
180
209
  dbName: string = DEFAULT_DB_NAME,
210
+ defaultBucket?: string,
181
211
  ): Promise<StorageBackend> {
182
212
  try {
183
213
  const db = await openDatabase(dbName);
184
- return new IndexedDbStorageBackend(db);
214
+ return new IndexedDbStorageBackend(db, defaultBucket);
185
215
  } catch (err) {
186
- return new InMemoryStorageBackend();
216
+ return new InMemoryStorageBackend(defaultBucket);
187
217
  }
188
218
  }
189
219
 
190
- class InMemoryStorageBackend implements StorageBackend {
220
+ export class InMemoryStorageBackend implements StorageBackend {
191
221
  private readonly blobs = new Map<string, Blob>();
192
222
  private readonly metadata = new Map<string, StoredMetadata>();
223
+ private _defaultBucket: string | undefined;
224
+
225
+ constructor(defaultBucket?: string) {
226
+ this._defaultBucket = defaultBucket;
227
+ }
193
228
 
194
229
  async put(path: string, blob: Blob, metadata: StoredMetadata): Promise<void> {
195
- this.blobs.set(path, blob);
196
- this.metadata.set(path, metadata);
230
+ const bucket = metadata.bucket || this._defaultBucket || DEFAULT_BUCKET;
231
+ if (!this._defaultBucket) {
232
+ this._defaultBucket = bucket;
233
+ }
234
+ if (!metadata.bucket) {
235
+ metadata.bucket = bucket;
236
+ }
237
+ const key = toStorageKey(bucket, path);
238
+ this.blobs.set(key, blob);
239
+ this.metadata.set(key, metadata);
197
240
  }
198
241
 
199
- async getBlob(path: string): Promise<Blob | undefined> {
200
- return this.blobs.get(path);
242
+ async getBlob(path: string, bucket?: string): Promise<Blob | undefined> {
243
+ if (bucket) {
244
+ return this.blobs.get(toStorageKey(bucket, path));
245
+ }
246
+ const targetBucket = this._defaultBucket ?? DEFAULT_BUCKET;
247
+ const direct = this.blobs.get(toStorageKey(targetBucket, path));
248
+ if (direct !== undefined) return direct;
249
+ if (targetBucket !== DEFAULT_BUCKET) {
250
+ const def = this.blobs.get(toStorageKey(DEFAULT_BUCKET, path));
251
+ if (def !== undefined) return def;
252
+ }
253
+ for (const [key, blob] of this.blobs.entries()) {
254
+ const parsed = parseStorageKey(key);
255
+ if (parsed && parsed.path === path) {
256
+ return blob;
257
+ }
258
+ }
259
+ return undefined;
201
260
  }
202
261
 
203
- async getMetadata(path: string): Promise<StoredMetadata | undefined> {
204
- return this.metadata.get(path);
262
+ async getMetadata(path: string, bucket?: string): Promise<StoredMetadata | undefined> {
263
+ if (bucket) {
264
+ return this.metadata.get(toStorageKey(bucket, path));
265
+ }
266
+ const targetBucket = this._defaultBucket ?? DEFAULT_BUCKET;
267
+ const direct = this.metadata.get(toStorageKey(targetBucket, path));
268
+ if (direct !== undefined) return direct;
269
+ if (targetBucket !== DEFAULT_BUCKET) {
270
+ const def = this.metadata.get(toStorageKey(DEFAULT_BUCKET, path));
271
+ if (def !== undefined) return def;
272
+ }
273
+ for (const [key, meta] of this.metadata.entries()) {
274
+ const parsed = parseStorageKey(key);
275
+ if (parsed && parsed.path === path) {
276
+ return meta;
277
+ }
278
+ }
279
+ return undefined;
205
280
  }
206
281
 
207
- async putMetadata(path: string, metadata: StoredMetadata): Promise<void> {
208
- this.metadata.set(path, metadata);
282
+ async putMetadata(path: string, metadata: StoredMetadata, bucket?: string): Promise<void> {
283
+ const b = bucket ?? metadata.bucket ?? this._defaultBucket ?? DEFAULT_BUCKET;
284
+ if (!metadata.bucket) metadata.bucket = b;
285
+ this.metadata.set(toStorageKey(b, path), metadata);
209
286
  }
210
287
 
211
- async delete(path: string): Promise<void> {
212
- this.blobs.delete(path);
213
- this.metadata.delete(path);
288
+ async delete(path: string, bucket?: string): Promise<void> {
289
+ const targetBucket = bucket ?? this._defaultBucket ?? DEFAULT_BUCKET;
290
+ this.blobs.delete(toStorageKey(targetBucket, path));
291
+ this.metadata.delete(toStorageKey(targetBucket, path));
214
292
  }
215
293
 
216
- async listByPrefix(prefix: string): Promise<StoredMetadata[]> {
294
+ async listByPrefix(prefix: string, bucket?: string): Promise<StoredMetadata[]> {
295
+ const targetBucket = bucket ?? this._defaultBucket ?? DEFAULT_BUCKET;
296
+ const keyPrefix = `${targetBucket}/${prefix}`;
217
297
  const results: StoredMetadata[] = [];
218
298
  const keys = Array.from(this.metadata.keys()).sort();
219
299
  for (const key of keys) {
220
- if (key.startsWith(prefix)) {
300
+ if (key.startsWith(keyPrefix)) {
221
301
  const meta = this.metadata.get(key);
222
302
  if (meta) results.push(meta);
223
303
  }
@@ -225,74 +305,222 @@ class InMemoryStorageBackend implements StorageBackend {
225
305
  return results;
226
306
  }
227
307
 
228
- async reset(): Promise<void> {
229
- this.blobs.clear();
230
- this.metadata.clear();
308
+ async reset(bucket?: string): Promise<void> {
309
+ if (!bucket) {
310
+ this.blobs.clear();
311
+ this.metadata.clear();
312
+ this._defaultBucket = undefined;
313
+ return;
314
+ }
315
+ const bucketPrefix = `${bucket}/`;
316
+ for (const key of Array.from(this.blobs.keys())) {
317
+ if (key.startsWith(bucketPrefix)) {
318
+ this.blobs.delete(key);
319
+ }
320
+ }
321
+ for (const key of Array.from(this.metadata.keys())) {
322
+ if (key.startsWith(bucketPrefix)) {
323
+ this.metadata.delete(key);
324
+ }
325
+ }
231
326
  }
232
327
 
233
328
  close(): void {}
329
+
330
+ scoped(bucket: string): StorageBackend {
331
+ return new ScopedStorageBackend(this, bucket);
332
+ }
234
333
  }
235
334
 
236
335
  // ─── Internal ──────────────────────────────────────────────────────
237
336
 
238
- class IndexedDbStorageBackend implements StorageBackend {
239
- constructor(private readonly db: IDBDatabase) {}
337
+ export class IndexedDbStorageBackend implements StorageBackend {
338
+ private _defaultBucket: string | undefined;
339
+
340
+ constructor(
341
+ private readonly db: IDBDatabase,
342
+ defaultBucket?: string,
343
+ ) {
344
+ this._defaultBucket = defaultBucket;
345
+ }
240
346
 
241
347
  async put(path: string, blob: Blob, metadata: StoredMetadata): Promise<void> {
348
+ const bucket = metadata.bucket || this._defaultBucket || DEFAULT_BUCKET;
349
+ if (!this._defaultBucket) {
350
+ this._defaultBucket = bucket;
351
+ }
352
+ if (!metadata.bucket) {
353
+ metadata.bucket = bucket;
354
+ }
355
+ const key = toStorageKey(bucket, path);
242
356
  const tx = this.db.transaction([BLOBS_STORE, METADATA_STORE], 'readwrite');
243
- tx.objectStore(BLOBS_STORE).put(blob, path);
244
- tx.objectStore(METADATA_STORE).put(metadata, path);
357
+ tx.objectStore(BLOBS_STORE).put(blob, key);
358
+ tx.objectStore(METADATA_STORE).put(metadata, key);
245
359
  await awaitTransaction(tx);
246
360
  }
247
361
 
248
- async getBlob(path: string): Promise<Blob | undefined> {
362
+ async getBlob(path: string, bucket?: string): Promise<Blob | undefined> {
249
363
  const tx = this.db.transaction(BLOBS_STORE, 'readonly');
250
- return awaitRequest<Blob | undefined>(tx.objectStore(BLOBS_STORE).get(path));
364
+ const store = tx.objectStore(BLOBS_STORE);
365
+ if (bucket) {
366
+ return awaitRequest<Blob | undefined>(store.get(toStorageKey(bucket, path)));
367
+ }
368
+ const targetBucket = this._defaultBucket ?? DEFAULT_BUCKET;
369
+ const direct = await awaitRequest<Blob | undefined>(store.get(toStorageKey(targetBucket, path)));
370
+ if (direct !== undefined) return direct;
371
+ if (targetBucket !== DEFAULT_BUCKET) {
372
+ const def = await awaitRequest<Blob | undefined>(store.get(toStorageKey(DEFAULT_BUCKET, path)));
373
+ if (def !== undefined) return def;
374
+ }
375
+ return new Promise((resolve, reject) => {
376
+ const req = store.openCursor();
377
+ req.addEventListener('success', () => {
378
+ const cursor = req.result;
379
+ if (!cursor) {
380
+ resolve(undefined);
381
+ return;
382
+ }
383
+ const key = String(cursor.key);
384
+ const parsed = parseStorageKey(key);
385
+ if (parsed && parsed.path === path) {
386
+ resolve(cursor.value as Blob);
387
+ return;
388
+ }
389
+ cursor.continue();
390
+ });
391
+ req.addEventListener('error', () => reject(req.error));
392
+ });
251
393
  }
252
394
 
253
- async getMetadata(path: string): Promise<StoredMetadata | undefined> {
395
+ async getMetadata(path: string, bucket?: string): Promise<StoredMetadata | undefined> {
254
396
  const tx = this.db.transaction(METADATA_STORE, 'readonly');
255
- return awaitRequest<StoredMetadata | undefined>(
256
- tx.objectStore(METADATA_STORE).get(path),
257
- );
397
+ const store = tx.objectStore(METADATA_STORE);
398
+ if (bucket) {
399
+ return awaitRequest<StoredMetadata | undefined>(store.get(toStorageKey(bucket, path)));
400
+ }
401
+ const targetBucket = this._defaultBucket ?? DEFAULT_BUCKET;
402
+ const direct = await awaitRequest<StoredMetadata | undefined>(store.get(toStorageKey(targetBucket, path)));
403
+ if (direct !== undefined) return direct;
404
+ if (targetBucket !== DEFAULT_BUCKET) {
405
+ const def = await awaitRequest<StoredMetadata | undefined>(store.get(toStorageKey(DEFAULT_BUCKET, path)));
406
+ if (def !== undefined) return def;
407
+ }
408
+ return new Promise((resolve, reject) => {
409
+ const req = store.openCursor();
410
+ req.addEventListener('success', () => {
411
+ const cursor = req.result;
412
+ if (!cursor) {
413
+ resolve(undefined);
414
+ return;
415
+ }
416
+ const key = String(cursor.key);
417
+ const parsed = parseStorageKey(key);
418
+ if (parsed && parsed.path === path) {
419
+ resolve(cursor.value as StoredMetadata);
420
+ return;
421
+ }
422
+ cursor.continue();
423
+ });
424
+ req.addEventListener('error', () => reject(req.error));
425
+ });
258
426
  }
259
427
 
260
- async putMetadata(path: string, metadata: StoredMetadata): Promise<void> {
428
+ async putMetadata(path: string, metadata: StoredMetadata, bucket?: string): Promise<void> {
429
+ const b = bucket ?? metadata.bucket ?? this._defaultBucket ?? DEFAULT_BUCKET;
430
+ if (!metadata.bucket) metadata.bucket = b;
431
+ const key = toStorageKey(b, path);
261
432
  const tx = this.db.transaction(METADATA_STORE, 'readwrite');
262
- tx.objectStore(METADATA_STORE).put(metadata, path);
433
+ tx.objectStore(METADATA_STORE).put(metadata, key);
263
434
  await awaitTransaction(tx);
264
435
  }
265
436
 
266
- async delete(path: string): Promise<void> {
437
+ async delete(path: string, bucket?: string): Promise<void> {
438
+ const targetBucket = bucket ?? this._defaultBucket ?? DEFAULT_BUCKET;
439
+ const key = toStorageKey(targetBucket, path);
267
440
  const tx = this.db.transaction([BLOBS_STORE, METADATA_STORE], 'readwrite');
268
- tx.objectStore(BLOBS_STORE).delete(path);
269
- tx.objectStore(METADATA_STORE).delete(path);
441
+ tx.objectStore(BLOBS_STORE).delete(key);
442
+ tx.objectStore(METADATA_STORE).delete(key);
270
443
  await awaitTransaction(tx);
271
444
  }
272
445
 
273
- async listByPrefix(prefix: string): Promise<StoredMetadata[]> {
446
+ async listByPrefix(prefix: string, bucket?: string): Promise<StoredMetadata[]> {
447
+ const targetBucket = bucket ?? this._defaultBucket ?? DEFAULT_BUCKET;
274
448
  const tx = this.db.transaction(METADATA_STORE, 'readonly');
275
- // `IDBKeyRange.bound(prefix, prefix + '￿')` returns every
276
- // key that starts with `prefix` — '￿' is the highest BMP
277
- // code point, so any path whose first char beyond `prefix` is
278
- // legal will sort below it. Matches the survey's recommended
279
- // prefix-query pattern (Section 6).
280
- const range = IDBKeyRange.bound(prefix, prefix + '￿', false, false);
449
+ const keyPrefix = `${targetBucket}/${prefix}`;
450
+ const range = IDBKeyRange.bound(keyPrefix, keyPrefix + '\ufffd', false, false);
281
451
  return awaitRequest<StoredMetadata[]>(
282
452
  tx.objectStore(METADATA_STORE).getAll(range),
283
453
  );
284
454
  }
285
455
 
286
- async reset(): Promise<void> {
456
+ async reset(bucket?: string): Promise<void> {
287
457
  const tx = this.db.transaction([BLOBS_STORE, METADATA_STORE], 'readwrite');
288
- tx.objectStore(BLOBS_STORE).clear();
289
- tx.objectStore(METADATA_STORE).clear();
458
+ if (!bucket) {
459
+ tx.objectStore(BLOBS_STORE).clear();
460
+ tx.objectStore(METADATA_STORE).clear();
461
+ this._defaultBucket = undefined;
462
+ } else {
463
+ const bucketPrefix = `${bucket}/`;
464
+ const range = IDBKeyRange.bound(bucketPrefix, bucketPrefix + '\ufffd', false, false);
465
+ tx.objectStore(BLOBS_STORE).delete(range);
466
+ tx.objectStore(METADATA_STORE).delete(range);
467
+ }
290
468
  await awaitTransaction(tx);
291
469
  }
292
470
 
293
471
  close(): void {
294
472
  this.db.close();
295
473
  }
474
+
475
+ scoped(bucket: string): StorageBackend {
476
+ return new ScopedStorageBackend(this, bucket);
477
+ }
478
+ }
479
+
480
+ export class ScopedStorageBackend implements StorageBackend {
481
+ constructor(
482
+ private readonly underlying: StorageBackend,
483
+ readonly bucket: string,
484
+ ) {}
485
+
486
+ put(path: string, blob: Blob, metadata: StoredMetadata): Promise<void> {
487
+ const meta = metadata.bucket ? metadata : { ...metadata, bucket: this.bucket };
488
+ return this.underlying.put(path, blob, meta);
489
+ }
490
+
491
+ getBlob(path: string, bucket?: string): Promise<Blob | undefined> {
492
+ return this.underlying.getBlob(path, bucket ?? this.bucket);
493
+ }
494
+
495
+ getMetadata(path: string, bucket?: string): Promise<StoredMetadata | undefined> {
496
+ return this.underlying.getMetadata(path, bucket ?? this.bucket);
497
+ }
498
+
499
+ putMetadata(path: string, metadata: StoredMetadata, bucket?: string): Promise<void> {
500
+ const meta = metadata.bucket ? metadata : { ...metadata, bucket: this.bucket };
501
+ return this.underlying.putMetadata(path, meta, bucket ?? this.bucket);
502
+ }
503
+
504
+ delete(path: string, bucket?: string): Promise<void> {
505
+ return this.underlying.delete(path, bucket ?? this.bucket);
506
+ }
507
+
508
+ listByPrefix(prefix: string, bucket?: string): Promise<StoredMetadata[]> {
509
+ return this.underlying.listByPrefix(prefix, bucket ?? this.bucket);
510
+ }
511
+
512
+ reset(bucket?: string): Promise<void> {
513
+ return this.underlying.reset(bucket ?? this.bucket);
514
+ }
515
+
516
+ close(): void {
517
+ this.underlying.close();
518
+ }
519
+
520
+ scoped(bucket: string): StorageBackend {
521
+ if (bucket === this.bucket) return this;
522
+ return this.underlying.scoped ? this.underlying.scoped(bucket) : new ScopedStorageBackend(this.underlying, bucket);
523
+ }
296
524
  }
297
525
 
298
526
  function openDatabase(dbName: string): Promise<IDBDatabase> {