@orkestrel/indexeddb 0.0.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,939 @@
1
+ //#region src/browser/constants.ts
2
+ /**
3
+ * Native `DOMException.name` → our {@link IndexedDBErrorCode}.
4
+ *
5
+ * @remarks
6
+ * The mapping the request boundary's `wrapError` reads to translate a raw
7
+ * IndexedDB fault into a typed {@link IndexedDBError} code; an unmapped name
8
+ * falls back to `UNKNOWN`. Frozen plain data (AGENTS §5).
9
+ */
10
+ var ERROR_CODES = Object.freeze({
11
+ ConstraintError: "CONSTRAINT",
12
+ QuotaExceededError: "QUOTA",
13
+ AbortError: "ABORTED",
14
+ NotFoundError: "NOT_FOUND",
15
+ DataError: "DATA",
16
+ VersionError: "UPGRADE",
17
+ TransactionInactiveError: "INACTIVE",
18
+ InvalidStateError: "INVALID"
19
+ });
20
+ //#endregion
21
+ //#region src/browser/errors.ts
22
+ /**
23
+ * An error thrown by the IndexedDB wrapper.
24
+ *
25
+ * @remarks
26
+ * Carries an {@link IndexedDBErrorCode} and the originating native error as the
27
+ * standard `cause`. Construct it directly for wrapper-lifecycle faults; the
28
+ * internal `wrapError` maps a native `DOMException` to the right code at the
29
+ * request boundary. Narrow a caught value with `instanceof IndexedDBError`.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * try {
34
+ * await store.add(row)
35
+ * } catch (error) {
36
+ * if (error instanceof IndexedDBError && error.code === 'CONSTRAINT') await store.set(row)
37
+ * }
38
+ * ```
39
+ */
40
+ var IndexedDBError = class extends Error {
41
+ code;
42
+ constructor(code, message, cause) {
43
+ super(message, { cause });
44
+ this.name = "IndexedDBError";
45
+ this.code = code;
46
+ }
47
+ };
48
+ /**
49
+ * Whether a value is an {@link IndexedDBError}.
50
+ *
51
+ * @param value - The value to test
52
+ * @returns `true` when `value` is an `IndexedDBError`
53
+ */
54
+ function isIndexedDBError(value) {
55
+ return value instanceof IndexedDBError;
56
+ }
57
+ Object.freeze([
58
+ "null",
59
+ "boolean",
60
+ "object",
61
+ "array",
62
+ "number",
63
+ "integer",
64
+ "string"
65
+ ]);
66
+ /**
67
+ * Determine whether a value is a non-null object.
68
+ *
69
+ * @remarks
70
+ * `true` for arrays, class instances, plain objects, `Map`, `Set`, etc. — use
71
+ * {@link isRecord} when you need a plain-record check.
72
+ */
73
+ function isObject(value) {
74
+ return typeof value === "object" && value !== null;
75
+ }
76
+ /**
77
+ * Determine whether a value is a plain record (object literal or null-prototype),
78
+ * not an array or class instance.
79
+ *
80
+ * @remarks
81
+ * Use instead of {@link isObject} to distinguish a plain `{}` /
82
+ * `Object.create(null)` from arrays, `Date`, `Map`, etc. The prototype-chain
83
+ * test is realm-agnostic: rather than comparing against the current realm's
84
+ * `Object.prototype` (which a plain object from another `vm.Context`, iframe,
85
+ * or worker would fail), it accepts any value whose prototype is `null`, OR
86
+ * whose prototype's own prototype is `null` — the shape every plain object
87
+ * has in every realm, since `Object.prototype` itself always sits one step
88
+ * above `null`. Arrays and class instances are still rejected: an array's
89
+ * prototype chain runs through `Array.prototype` before `null`, and a class
90
+ * instance's runs through the class's own prototype. The whole body runs
91
+ * inside `attempt` (AGENTS §14) so a revoked `Proxy` or a hostile
92
+ * `getPrototypeOf` trap cannot escape as a thrown error.
93
+ */
94
+ function isRecord(value) {
95
+ const outcome = attempt(() => {
96
+ if (!isObject(value) || isArray(value)) return false;
97
+ const prototype = Object.getPrototypeOf(value);
98
+ return prototype === null || Object.getPrototypeOf(prototype) === null;
99
+ });
100
+ return outcome.success && outcome.value;
101
+ }
102
+ /** Determine whether a value is an array. */
103
+ function isArray(value) {
104
+ return Array.isArray(value);
105
+ }
106
+ /**
107
+ * Invoke a callback and capture its outcome as a {@link Result}, never letting
108
+ * a throw escape.
109
+ *
110
+ * @remarks
111
+ * The single sanctioned never-throw boundary for the guards (AGENTS §14). The
112
+ * `whereOf`, `lazyOf`, and `transformOf` combinators invoke caller-supplied
113
+ * callbacks *inside* a guard body, yet a guard must NEVER throw — it returns a
114
+ * `boolean`. This converts a throwing callback into a `Failure` so the
115
+ * surrounding guard can treat it as a non-match instead of propagating the
116
+ * exception, written once and shared rather than copy-pasted as ad-hoc
117
+ * `try`/`catch`.
118
+ *
119
+ * @param callback - The callback to invoke with no arguments
120
+ * @returns A `Success` carrying the return value, or a `Failure` carrying the
121
+ * thrown reason normalised to an `Error`
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * const outcome = attempt(() => predicate(value))
126
+ * return outcome.success && outcome.value
127
+ * ```
128
+ */
129
+ function attempt(callback) {
130
+ try {
131
+ return {
132
+ success: true,
133
+ value: callback()
134
+ };
135
+ } catch (reason) {
136
+ if (reason instanceof Error) return {
137
+ success: false,
138
+ error: reason
139
+ };
140
+ let message = "Unknown thrown value";
141
+ try {
142
+ message = String(reason);
143
+ } catch {}
144
+ return {
145
+ success: false,
146
+ error: new Error(message)
147
+ };
148
+ }
149
+ }
150
+ //#endregion
151
+ //#region src/browser/helpers.ts
152
+ /**
153
+ * Whether IndexedDB is available in this environment.
154
+ *
155
+ * @remarks
156
+ * Gate IndexedDB code with this and fall back to another storage strategy where
157
+ * it is absent (a non-browser runtime, a privacy mode that disables storage).
158
+ * The entry probe, checked before reaching for the rest of this module.
159
+ *
160
+ * @returns `true` when `globalThis.indexedDB` exists
161
+ */
162
+ function isIndexedDBSupported() {
163
+ return typeof globalThis.indexedDB !== "undefined";
164
+ }
165
+ /**
166
+ * Resolve an `IDBRequest` to its result, rejecting with an {@link IndexedDBError}.
167
+ *
168
+ * @remarks
169
+ * The single bridge from IndexedDB's event-based requests to Promises. Issue the
170
+ * request, then `await` this — within an implicit transaction, issue every request
171
+ * for that transaction before the first `await`, so they share it.
172
+ *
173
+ * @param request - The pending request
174
+ * @returns Its `result` on success
175
+ */
176
+ function promisifyRequest(request) {
177
+ return new Promise((resolve, reject) => {
178
+ request.onsuccess = () => resolve(request.result);
179
+ request.onerror = () => reject(wrapError(request.error));
180
+ });
181
+ }
182
+ /**
183
+ * Resolve once an `IDBTransaction` commits, rejecting if it errors or aborts.
184
+ *
185
+ * @remarks
186
+ * Await this after issuing the writes of a `readwrite` transaction to guarantee
187
+ * they are durable before continuing.
188
+ *
189
+ * @param transaction - The transaction to await
190
+ */
191
+ function promisifyTransaction(transaction) {
192
+ return new Promise((resolve, reject) => {
193
+ transaction.addEventListener("complete", () => resolve());
194
+ transaction.addEventListener("error", () => reject(wrapError(transaction.error)));
195
+ transaction.addEventListener("abort", () => reject(wrapError(transaction.error)));
196
+ });
197
+ }
198
+ /**
199
+ * Run a synchronous native IndexedDB call, wrapping a thrown `DOMException`
200
+ * into a typed {@link IndexedDBError}.
201
+ *
202
+ * @remarks
203
+ * Native IndexedDB throws SYNCHRONOUSLY (not through a request's `onerror`)
204
+ * from calls like `database.transaction(...)` or an inactive/closed store's
205
+ * `get` / `put` / `openCursor` — `TransactionInactiveError` /
206
+ * `InvalidStateError` never reach {@link promisifyRequest}'s `onerror`
207
+ * bridge. Every request-issuing call site wraps its native invocation in this
208
+ * so those faults surface as the same typed `IndexedDBError` as an
209
+ * asynchronous one.
210
+ *
211
+ * @param action - The synchronous native call to run
212
+ * @returns Its return value
213
+ */
214
+ function guardSync(action) {
215
+ try {
216
+ return action();
217
+ } catch (error) {
218
+ if (error instanceof DOMException) throw wrapError(error);
219
+ throw error;
220
+ }
221
+ }
222
+ /**
223
+ * Read one record by key from a store or index, narrowing it to a {@link Row}.
224
+ *
225
+ * @remarks
226
+ * The shared point-read of every store-like class (`IndexedDBStore`,
227
+ * `IndexedDBIndex`, `IndexedDBTransactionStore`): issue the native `get`, then
228
+ * narrow the structured clone with `isRecord` — a non-record (or a miss) reads as
229
+ * `undefined`, never an unchecked cast. On an index, `source.get` returns the
230
+ * first record for the index key.
231
+ *
232
+ * @param source - The object store or index to read from
233
+ * @param key - The key (a primary key for a store, an index key for an index)
234
+ * @returns The record, or `undefined` on a miss
235
+ */
236
+ async function readRecord(source, key) {
237
+ const value = await promisifyRequest(guardSync(() => source.get(key)));
238
+ return isRecord(value) ? value : void 0;
239
+ }
240
+ /**
241
+ * Read many records from a store or index over an optional key range.
242
+ *
243
+ * @remarks
244
+ * The shared bulk read of every store-like class: issue the native `getAll` over
245
+ * an optional `query` (a key range or a single key) and `count` cap, then keep
246
+ * only the records with `isRecord` — the same boundary narrowing as
247
+ * {@link readRecord}, applied across the batch.
248
+ *
249
+ * @param source - The object store or index to read from
250
+ * @param query - A key range or single key to restrict the read, or `null` for all
251
+ * @param count - The maximum number of records to read
252
+ * @returns The matching records
253
+ */
254
+ async function readRecords(source, query, count) {
255
+ return (await promisifyRequest(guardSync(() => source.getAll(query ?? void 0, count)))).filter(isRecord);
256
+ }
257
+ /**
258
+ * Whether a key is present in a store or index.
259
+ *
260
+ * @remarks
261
+ * The shared presence test of every store-like class: a native `count` of the
262
+ * key, true when at least one record matches — cheaper than reading the record
263
+ * when only existence matters.
264
+ *
265
+ * @param source - The object store or index to test
266
+ * @param key - The key to look for
267
+ * @returns `true` when at least one record has the key
268
+ */
269
+ async function hasKey(source, key) {
270
+ return await promisifyRequest(guardSync(() => source.count(key))) > 0;
271
+ }
272
+ /**
273
+ * Key-range builders — the wrapper's filter vocabulary.
274
+ *
275
+ * @remarks
276
+ * Each returns an `IDBKeyRange` to pass to `records` / `keys` / `count` / `cursor`,
277
+ * so a read is index-backed (O(log n)) rather than a full scan. `only` (exact),
278
+ * `above` / `from` (greater than, exclusive / inclusive), `below` / `to` (less
279
+ * than, exclusive / inclusive), `between` (bounded), and `prefix` (string
280
+ * starts-with).
281
+ */
282
+ var range = {
283
+ only(value) {
284
+ return IDBKeyRange.only(value);
285
+ },
286
+ above(value) {
287
+ return IDBKeyRange.lowerBound(value, true);
288
+ },
289
+ from(value) {
290
+ return IDBKeyRange.lowerBound(value, false);
291
+ },
292
+ below(value) {
293
+ return IDBKeyRange.upperBound(value, true);
294
+ },
295
+ to(value) {
296
+ return IDBKeyRange.upperBound(value, false);
297
+ },
298
+ between(lower, upper, options) {
299
+ return IDBKeyRange.bound(lower, upper, options?.lowerOpen ?? false, options?.upperOpen ?? false);
300
+ },
301
+ prefix(value) {
302
+ return IDBKeyRange.bound(value, value + "￿", false, false);
303
+ }
304
+ };
305
+ /**
306
+ * Map a native IndexedDB `DOMException` to a typed {@link IndexedDBError}.
307
+ *
308
+ * @remarks
309
+ * The boundary the two Promise bridges ({@link promisifyRequest} /
310
+ * {@link promisifyTransaction}) share: it reads {@link ERROR_CODES} to pick the
311
+ * machine-readable code for the native `name`, falling back to `UNKNOWN` for an
312
+ * unmapped name or a `null` error.
313
+ *
314
+ * @param error - The native error, or `null` when none is attached
315
+ * @returns The wrapped, typed error
316
+ */
317
+ function wrapError(error) {
318
+ if (error === null) return new IndexedDBError("UNKNOWN", "Unknown IndexedDB error");
319
+ return new IndexedDBError(ERROR_CODES[error.name] ?? "UNKNOWN", error.message || `IndexedDB error: ${error.name}`, error);
320
+ }
321
+ //#endregion
322
+ //#region src/browser/IndexedDBCursor.ts
323
+ /**
324
+ * A promisified value cursor over an object store or index.
325
+ *
326
+ * @remarks
327
+ * Wraps `IDBCursorWithValue` and the request that drives it. The position
328
+ * (`key` / `primary` / `value`) is snapshot at construction because IndexedDB
329
+ * mutates the live cursor object in place on each advance. `continue` / `seek` /
330
+ * `advance` re-arm the shared request and resolve to the next position (a fresh
331
+ * `IndexedDBCursor`) or `null` at the end. `update` / `delete` act on the current
332
+ * record — they require the cursor's transaction to be `readwrite` (a `store`
333
+ * cursor), so they reject on an `index` cursor's read-only transaction.
334
+ */
335
+ var IndexedDBCursor = class IndexedDBCursor {
336
+ #cursor;
337
+ #request;
338
+ #key;
339
+ #primary;
340
+ #value;
341
+ #direction;
342
+ constructor(cursor, request) {
343
+ this.#cursor = cursor;
344
+ this.#request = request;
345
+ this.#key = cursor.key;
346
+ this.#primary = cursor.primaryKey;
347
+ this.#value = isRecord(cursor.value) ? cursor.value : {};
348
+ this.#direction = cursor.direction;
349
+ }
350
+ get cursor() {
351
+ return this.#cursor;
352
+ }
353
+ get source() {
354
+ return this.#cursor.source;
355
+ }
356
+ get key() {
357
+ return this.#key;
358
+ }
359
+ get primary() {
360
+ return this.#primary;
361
+ }
362
+ get value() {
363
+ return this.#value;
364
+ }
365
+ get direction() {
366
+ return this.#direction;
367
+ }
368
+ async continue(key) {
369
+ guardSync(() => {
370
+ if (key === void 0) this.#cursor.continue();
371
+ else this.#cursor.continue(key);
372
+ });
373
+ return this.#advance();
374
+ }
375
+ async seek(key, primary) {
376
+ guardSync(() => this.#cursor.continuePrimaryKey(key, primary));
377
+ return this.#advance();
378
+ }
379
+ async advance(count) {
380
+ guardSync(() => this.#cursor.advance(count));
381
+ return this.#advance();
382
+ }
383
+ async update(value) {
384
+ const key = await promisifyRequest(guardSync(() => this.#cursor.update(value)));
385
+ this.#value = value;
386
+ return key;
387
+ }
388
+ async delete() {
389
+ await promisifyRequest(guardSync(() => this.#cursor.delete()));
390
+ }
391
+ async #advance() {
392
+ const next = await promisifyRequest(this.#request);
393
+ return next ? new IndexedDBCursor(next, this.#request) : null;
394
+ }
395
+ };
396
+ //#endregion
397
+ //#region src/browser/IndexedDBIndex.ts
398
+ /**
399
+ * A secondary index on a store — a read-only view keyed by an indexed path.
400
+ *
401
+ * @remarks
402
+ * Reached through `store.index(name)`. Each call opens its own `readonly`
403
+ * transaction. `get` / `resolve` fetch the first record for an index key
404
+ * (`resolve` throws `NOT_FOUND`); `records` reads the matching records and `keys`
405
+ * their **primary** keys; `primary` maps an index key to one primary key; `count`
406
+ * / `has` test presence; `cursor` streams matches. Reads batch by their array
407
+ * overload (AGENTS §9.2).
408
+ */
409
+ var IndexedDBIndex = class {
410
+ #store;
411
+ #name;
412
+ #definition;
413
+ #connect;
414
+ constructor(store, name, definition, connect) {
415
+ this.#store = store;
416
+ this.#name = name;
417
+ this.#definition = definition;
418
+ this.#connect = connect;
419
+ }
420
+ get name() {
421
+ return this.#name;
422
+ }
423
+ get path() {
424
+ return this.#definition.path;
425
+ }
426
+ get unique() {
427
+ return this.#definition.unique ?? false;
428
+ }
429
+ get multiple() {
430
+ return this.#definition.multiple ?? false;
431
+ }
432
+ async get(keyOrKeys) {
433
+ const index = await this.#index();
434
+ if (isArray(keyOrKeys)) return Promise.all(keyOrKeys.map((key) => readRecord(index, key)));
435
+ return readRecord(index, keyOrKeys);
436
+ }
437
+ async resolve(keyOrKeys) {
438
+ const index = await this.#index();
439
+ if (isArray(keyOrKeys)) return Promise.all(keyOrKeys.map((key) => this.#resolve(index, key)));
440
+ return this.#resolve(index, keyOrKeys);
441
+ }
442
+ async records(query, count) {
443
+ return readRecords(await this.#index(), query, count);
444
+ }
445
+ async keys(query, count) {
446
+ const index = await this.#index();
447
+ return promisifyRequest(guardSync(() => index.getAllKeys(query ?? void 0, count)));
448
+ }
449
+ async primary(key) {
450
+ const index = await this.#index();
451
+ return promisifyRequest(guardSync(() => index.getKey(key)));
452
+ }
453
+ async has(keyOrKeys) {
454
+ const index = await this.#index();
455
+ if (isArray(keyOrKeys)) return Promise.all(keyOrKeys.map((key) => hasKey(index, key)));
456
+ return hasKey(index, keyOrKeys);
457
+ }
458
+ async count(query) {
459
+ const index = await this.#index();
460
+ return promisifyRequest(guardSync(() => index.count(query ?? void 0)));
461
+ }
462
+ async cursor(options) {
463
+ const index = await this.#index();
464
+ const request = guardSync(() => index.openCursor(options?.query ?? null, options?.direction ?? "next"));
465
+ const cursor = await promisifyRequest(request);
466
+ return cursor ? new IndexedDBCursor(cursor, request) : null;
467
+ }
468
+ async #index() {
469
+ const database = await this.#connect();
470
+ return guardSync(() => database.transaction([this.#store], "readonly").objectStore(this.#store).index(this.#name));
471
+ }
472
+ async #resolve(index, key) {
473
+ const value = await readRecord(index, key);
474
+ if (value === void 0) throw new IndexedDBError("NOT_FOUND", `No record in index '${this.#name}' of store '${this.#store}' for key ${String(key)}`);
475
+ return value;
476
+ }
477
+ };
478
+ //#endregion
479
+ //#region src/browser/IndexedDBStore.ts
480
+ /**
481
+ * An object store — the full keyed CRUD surface plus index, count, and cursor
482
+ * access.
483
+ *
484
+ * @remarks
485
+ * Reached through `database.store(name)`. Each call runs in its own implicit
486
+ * transaction (`readonly` for reads, `readwrite` for writes), awaiting completion
487
+ * so writes are durable on return; for atomic multi-operation work use the
488
+ * database's `read` / `write`. The keyed verbs batch by their array overload — and
489
+ * those overloads are declared first, because an array is itself both a record and
490
+ * a compound `IDBValidKey`, so the array signature must take precedence to read as
491
+ * a batch (AGENTS §9.2). Pass `range.only([…])` to `records` / `count` to act on a
492
+ * single compound key.
493
+ */
494
+ var IndexedDBStore = class {
495
+ #name;
496
+ #definition;
497
+ #connect;
498
+ constructor(name, definition, connect) {
499
+ this.#name = name;
500
+ this.#definition = definition;
501
+ this.#connect = connect;
502
+ }
503
+ get name() {
504
+ return this.#name;
505
+ }
506
+ get path() {
507
+ return this.#definition.path ?? null;
508
+ }
509
+ get indexes() {
510
+ return (this.#definition.indexes ?? []).map((index) => index.name);
511
+ }
512
+ get increment() {
513
+ return this.#definition.increment ?? false;
514
+ }
515
+ async get(keyOrKeys) {
516
+ const store = await this.#store("readonly");
517
+ if (isArray(keyOrKeys)) return Promise.all(keyOrKeys.map((key) => readRecord(store, key)));
518
+ return readRecord(store, keyOrKeys);
519
+ }
520
+ async resolve(keyOrKeys) {
521
+ const store = await this.#store("readonly");
522
+ if (isArray(keyOrKeys)) return Promise.all(keyOrKeys.map((key) => this.#resolve(store, key)));
523
+ return this.#resolve(store, keyOrKeys);
524
+ }
525
+ async records(query, count) {
526
+ return readRecords(await this.#store("readonly"), query, count);
527
+ }
528
+ async keys(query, count) {
529
+ const store = await this.#store("readonly");
530
+ return promisifyRequest(guardSync(() => store.getAllKeys(query ?? void 0, count)));
531
+ }
532
+ async has(keyOrKeys) {
533
+ const store = await this.#store("readonly");
534
+ if (isArray(keyOrKeys)) return Promise.all(keyOrKeys.map((key) => hasKey(store, key)));
535
+ return hasKey(store, keyOrKeys);
536
+ }
537
+ async count(query) {
538
+ const store = await this.#store("readonly");
539
+ return promisifyRequest(guardSync(() => store.count(query ?? void 0)));
540
+ }
541
+ async set(valueOrValues, key) {
542
+ const store = await this.#store("readwrite");
543
+ if (isArray(valueOrValues)) {
544
+ const keys = await Promise.all(valueOrValues.map((value) => promisifyRequest(guardSync(() => store.put(value)))));
545
+ await promisifyTransaction(store.transaction);
546
+ return keys;
547
+ }
548
+ const written = await promisifyRequest(guardSync(() => key === void 0 ? store.put(valueOrValues) : store.put(valueOrValues, key)));
549
+ await promisifyTransaction(store.transaction);
550
+ return written;
551
+ }
552
+ async add(valueOrValues, key) {
553
+ const store = await this.#store("readwrite");
554
+ if (isArray(valueOrValues)) {
555
+ const keys = await Promise.all(valueOrValues.map((value) => promisifyRequest(guardSync(() => store.add(value)))));
556
+ await promisifyTransaction(store.transaction);
557
+ return keys;
558
+ }
559
+ const written = await promisifyRequest(guardSync(() => key === void 0 ? store.add(valueOrValues) : store.add(valueOrValues, key)));
560
+ await promisifyTransaction(store.transaction);
561
+ return written;
562
+ }
563
+ async remove(keyOrKeys) {
564
+ const store = await this.#store("readwrite");
565
+ if (isArray(keyOrKeys)) await Promise.all(keyOrKeys.map((key) => promisifyRequest(guardSync(() => store.delete(key)))));
566
+ else await promisifyRequest(guardSync(() => store.delete(keyOrKeys)));
567
+ await promisifyTransaction(store.transaction);
568
+ }
569
+ async clear() {
570
+ const store = await this.#store("readwrite");
571
+ await promisifyRequest(guardSync(() => store.clear()));
572
+ await promisifyTransaction(store.transaction);
573
+ }
574
+ index(name) {
575
+ const definition = (this.#definition.indexes ?? []).find((index) => index.name === name);
576
+ if (definition === void 0) throw new IndexedDBError("NOT_FOUND", `Index '${name}' is not declared on store '${this.#name}'`);
577
+ return new IndexedDBIndex(this.#name, name, definition, this.#connect);
578
+ }
579
+ async cursor(options) {
580
+ const store = await this.#store("readwrite");
581
+ const request = guardSync(() => store.openCursor(options?.query ?? null, options?.direction ?? "next"));
582
+ const cursor = await promisifyRequest(request);
583
+ return cursor ? new IndexedDBCursor(cursor, request) : null;
584
+ }
585
+ async #store(mode) {
586
+ const database = await this.#connect();
587
+ return guardSync(() => database.transaction([this.#name], mode).objectStore(this.#name));
588
+ }
589
+ async #resolve(store, key) {
590
+ const value = await readRecord(store, key);
591
+ if (value === void 0) throw new IndexedDBError("NOT_FOUND", `No record in store '${this.#name}' for key ${String(key)}`);
592
+ return value;
593
+ }
594
+ };
595
+ //#endregion
596
+ //#region src/browser/IndexedDBTransactionStore.ts
597
+ /**
598
+ * An object store bound to an explicit transaction.
599
+ *
600
+ * @remarks
601
+ * The same CRUD surface as a standalone store, but every call runs in the owning
602
+ * transaction (opened by the database's `read` / `write`) rather than its own — so
603
+ * a sequence of reads and writes commits atomically when the scope resolves. It
604
+ * does not await transaction completion per call (the scope does that once) and
605
+ * omits `index`; keep your awaited operations on IndexedDB requests only, so the
606
+ * transaction stays active across them.
607
+ */
608
+ var IndexedDBTransactionStore = class {
609
+ #store;
610
+ #name;
611
+ constructor(store) {
612
+ this.#store = store;
613
+ this.#name = store.name;
614
+ }
615
+ get store() {
616
+ return this.#store;
617
+ }
618
+ async get(keyOrKeys) {
619
+ if (isArray(keyOrKeys)) return Promise.all(keyOrKeys.map((key) => readRecord(this.#store, key)));
620
+ return readRecord(this.#store, keyOrKeys);
621
+ }
622
+ async resolve(keyOrKeys) {
623
+ if (isArray(keyOrKeys)) return Promise.all(keyOrKeys.map((key) => this.#resolve(key)));
624
+ return this.#resolve(keyOrKeys);
625
+ }
626
+ async records(query, count) {
627
+ return readRecords(this.#store, query, count);
628
+ }
629
+ async keys(query, count) {
630
+ return promisifyRequest(guardSync(() => this.#store.getAllKeys(query ?? void 0, count)));
631
+ }
632
+ async has(keyOrKeys) {
633
+ if (isArray(keyOrKeys)) return Promise.all(keyOrKeys.map((key) => hasKey(this.#store, key)));
634
+ return hasKey(this.#store, keyOrKeys);
635
+ }
636
+ async count(query) {
637
+ return promisifyRequest(guardSync(() => this.#store.count(query ?? void 0)));
638
+ }
639
+ async set(valueOrValues, key) {
640
+ if (isArray(valueOrValues)) return Promise.all(valueOrValues.map((value) => promisifyRequest(guardSync(() => this.#store.put(value)))));
641
+ return promisifyRequest(guardSync(() => key === void 0 ? this.#store.put(valueOrValues) : this.#store.put(valueOrValues, key)));
642
+ }
643
+ async add(valueOrValues, key) {
644
+ if (isArray(valueOrValues)) return Promise.all(valueOrValues.map((value) => promisifyRequest(guardSync(() => this.#store.add(value)))));
645
+ return promisifyRequest(guardSync(() => key === void 0 ? this.#store.add(valueOrValues) : this.#store.add(valueOrValues, key)));
646
+ }
647
+ async remove(keyOrKeys) {
648
+ if (isArray(keyOrKeys)) {
649
+ await Promise.all(keyOrKeys.map((key) => promisifyRequest(guardSync(() => this.#store.delete(key)))));
650
+ return;
651
+ }
652
+ await promisifyRequest(guardSync(() => this.#store.delete(keyOrKeys)));
653
+ }
654
+ async clear() {
655
+ await promisifyRequest(guardSync(() => this.#store.clear()));
656
+ }
657
+ async cursor(options) {
658
+ const request = guardSync(() => this.#store.openCursor(options?.query ?? null, options?.direction ?? "next"));
659
+ const cursor = await promisifyRequest(request);
660
+ return cursor ? new IndexedDBCursor(cursor, request) : null;
661
+ }
662
+ async #resolve(key) {
663
+ const value = await readRecord(this.#store, key);
664
+ if (value === void 0) throw new IndexedDBError("NOT_FOUND", `No record in store '${this.#name}' for key ${String(key)}`);
665
+ return value;
666
+ }
667
+ };
668
+ //#endregion
669
+ //#region src/browser/IndexedDBTransaction.ts
670
+ /**
671
+ * An explicit transaction over one or more stores.
672
+ *
673
+ * @remarks
674
+ * Wraps `IDBTransaction` with state tracking and typed, scope-bound store access.
675
+ * Constructed by the database's `read` / `write`, which await its completion (or
676
+ * roll it back on a throw). `store` reaches a store within the transaction's scope;
677
+ * `abort` rolls back; `commit` flushes early (the scope's completion commits
678
+ * otherwise). `active` is true until commit or abort; `finished` is its complement.
679
+ */
680
+ var IndexedDBTransaction = class {
681
+ #transaction;
682
+ #stores;
683
+ #active = true;
684
+ #finished = false;
685
+ constructor(transaction) {
686
+ this.#transaction = transaction;
687
+ this.#stores = Array.from(transaction.objectStoreNames);
688
+ const settle = () => {
689
+ this.#active = false;
690
+ this.#finished = true;
691
+ };
692
+ transaction.addEventListener("complete", settle);
693
+ transaction.addEventListener("abort", settle);
694
+ transaction.addEventListener("error", settle);
695
+ }
696
+ get transaction() {
697
+ return this.#transaction;
698
+ }
699
+ get mode() {
700
+ return this.#transaction.mode;
701
+ }
702
+ get stores() {
703
+ return this.#stores;
704
+ }
705
+ get active() {
706
+ return this.#active;
707
+ }
708
+ get finished() {
709
+ return this.#finished;
710
+ }
711
+ get error() {
712
+ return this.#transaction.error;
713
+ }
714
+ store(name) {
715
+ if (!this.#stores.includes(name)) throw new IndexedDBError("NOT_FOUND", `Store '${name}' is outside this transaction's scope (${this.#stores.join(", ")})`);
716
+ if (!this.#active) throw new IndexedDBError("ABORTED", `Transaction over ${this.#stores.join(", ")} is no longer active`);
717
+ return new IndexedDBTransactionStore(this.#transaction.objectStore(name));
718
+ }
719
+ abort() {
720
+ if (this.#finished) throw new IndexedDBError("ABORTED", "Cannot abort an already-finished transaction");
721
+ this.#transaction.abort();
722
+ this.#active = false;
723
+ this.#finished = true;
724
+ }
725
+ commit() {
726
+ if (this.#finished) throw new IndexedDBError("ABORTED", "Cannot commit an already-finished transaction");
727
+ this.#transaction.commit();
728
+ }
729
+ };
730
+ //#endregion
731
+ //#region src/browser/IndexedDBDatabase.ts
732
+ /**
733
+ * A browser-native IndexedDB database — a typed, Promise-based handle.
734
+ *
735
+ * @remarks
736
+ * Connects lazily on first use (`connect`, also awaited internally by every store
737
+ * operation), creating any missing stores from their definitions inside
738
+ * `onupgradeneeded`. `store` reaches a typed store; `read` / `write` run an atomic
739
+ * scope over one or more stores, committing on resolve and rolling back on a throw;
740
+ * `close` releases the connection and `drop` deletes the database. Schema changes
741
+ * beyond creating new stores (dropping stores, altering indexes) are deferred.
742
+ */
743
+ var IndexedDBDatabase = class {
744
+ #name;
745
+ #version;
746
+ #stores;
747
+ #upgrade;
748
+ #database;
749
+ #opening;
750
+ #closed = false;
751
+ constructor(options) {
752
+ if (options.name.length === 0) throw new IndexedDBError("OPEN", "Database name must be a non-empty string");
753
+ if (options.version !== void 0 && (!Number.isInteger(options.version) || options.version < 1)) throw new IndexedDBError("OPEN", `Database version must be a positive integer, got ${String(options.version)}`);
754
+ this.#name = options.name;
755
+ this.#version = options.version;
756
+ this.#stores = options.stores;
757
+ this.#upgrade = options.upgrade;
758
+ }
759
+ get database() {
760
+ if (this.#database === void 0) throw new IndexedDBError("NOT_OPEN", `Database '${this.#name}' is not open — call connect() first`);
761
+ return this.#database;
762
+ }
763
+ get name() {
764
+ return this.#name;
765
+ }
766
+ get version() {
767
+ return this.#database?.version ?? this.#version ?? 0;
768
+ }
769
+ get stores() {
770
+ if (this.#database !== void 0) return Array.from(this.#database.objectStoreNames);
771
+ return Object.keys(this.#stores);
772
+ }
773
+ get open() {
774
+ return this.#database !== void 0 && !this.#closed;
775
+ }
776
+ connect() {
777
+ if (this.#closed) throw new IndexedDBError("CLOSED", `Database '${this.#name}' has been closed`);
778
+ if (this.#database !== void 0) return Promise.resolve(this.#database);
779
+ if (this.#opening !== void 0) return this.#opening;
780
+ this.#opening = this.#open().catch((error) => {
781
+ this.#opening = void 0;
782
+ throw error;
783
+ });
784
+ return this.#opening;
785
+ }
786
+ store(name) {
787
+ const definition = this.#stores[name];
788
+ if (definition === void 0) throw new IndexedDBError("NOT_FOUND", `Store '${name}' is not declared on database '${this.#name}'`);
789
+ return new IndexedDBStore(name, definition, () => this.connect());
790
+ }
791
+ read(stores, scope) {
792
+ return this.#run("readonly", stores, scope);
793
+ }
794
+ write(stores, scope) {
795
+ return this.#run("readwrite", stores, scope);
796
+ }
797
+ close() {
798
+ this.#database?.close();
799
+ this.#database = void 0;
800
+ this.#opening = void 0;
801
+ this.#closed = true;
802
+ }
803
+ async drop() {
804
+ this.close();
805
+ return new Promise((resolve, reject) => {
806
+ const request = globalThis.indexedDB.deleteDatabase(this.#name);
807
+ request.onsuccess = () => resolve();
808
+ request.onerror = () => reject(new IndexedDBError("UNKNOWN", `Failed to delete database '${this.#name}'`, request.error));
809
+ request.onblocked = () => reject(new IndexedDBError("BLOCKED", `Deletion of '${this.#name}' is blocked by another connection`));
810
+ });
811
+ }
812
+ async #run(mode, stores, scope) {
813
+ const database = await this.connect();
814
+ const names = isArray(stores) ? [...stores] : [stores];
815
+ const native = guardSync(() => database.transaction(names, mode));
816
+ const tx = new IndexedDBTransaction(native);
817
+ try {
818
+ await scope(tx);
819
+ await promisifyTransaction(native);
820
+ } catch (error) {
821
+ if (tx.active) try {
822
+ tx.abort();
823
+ } catch {}
824
+ throw error;
825
+ }
826
+ }
827
+ async #open() {
828
+ let database = await this.#request(this.#version);
829
+ if (this.#version === void 0) {
830
+ if (this.#missing(database).length > 0) {
831
+ const next = database.version + 1;
832
+ database.close();
833
+ database = await this.#request(next);
834
+ }
835
+ }
836
+ database.onclose = () => {
837
+ this.#database = void 0;
838
+ };
839
+ database.onversionchange = () => {
840
+ database.close();
841
+ this.#database = void 0;
842
+ this.#opening = void 0;
843
+ };
844
+ this.#database = database;
845
+ return database;
846
+ }
847
+ #request(version) {
848
+ return new Promise((resolve, reject) => {
849
+ let upgradeError;
850
+ const request = version === void 0 ? globalThis.indexedDB.open(this.#name) : globalThis.indexedDB.open(this.#name, version);
851
+ request.onupgradeneeded = (event) => {
852
+ const database = request.result;
853
+ for (const [name, definition] of Object.entries(this.#stores)) if (!database.objectStoreNames.contains(name)) this.#createStore(database, name, definition);
854
+ if (this.#upgrade !== void 0) {
855
+ const transaction = request.transaction;
856
+ if (transaction !== null) {
857
+ const result = this.#upgrade(this.#context(database, transaction, event));
858
+ if (result !== void 0) result.catch((error) => {
859
+ upgradeError = error;
860
+ try {
861
+ transaction.abort();
862
+ } catch {}
863
+ });
864
+ }
865
+ }
866
+ };
867
+ request.onsuccess = () => resolve(request.result);
868
+ request.onerror = () => reject(upgradeError !== void 0 ? new IndexedDBError("UPGRADE", `Upgrade of '${this.#name}' failed`, upgradeError) : new IndexedDBError("OPEN", `Failed to open database '${this.#name}'`, request.error));
869
+ request.onblocked = () => reject(new IndexedDBError("BLOCKED", `Open of '${this.#name}' is blocked by another connection`));
870
+ });
871
+ }
872
+ #missing(database) {
873
+ return Object.keys(this.#stores).filter((name) => !database.objectStoreNames.contains(name));
874
+ }
875
+ #context(database, transaction, event) {
876
+ return {
877
+ transaction,
878
+ old: event.oldVersion,
879
+ version: event.newVersion ?? database.version,
880
+ stores: Array.from(database.objectStoreNames),
881
+ create: (name, definition) => {
882
+ this.#createStore(database, name, definition);
883
+ },
884
+ drop: (name) => {
885
+ database.deleteObjectStore(name);
886
+ },
887
+ store: (name) => new IndexedDBTransactionStore(transaction.objectStore(name))
888
+ };
889
+ }
890
+ #createStore(database, name, definition) {
891
+ const options = { autoIncrement: definition.increment ?? false };
892
+ if (definition.path !== void 0) options.keyPath = typeof definition.path === "string" ? definition.path : [...definition.path];
893
+ const store = database.createObjectStore(name, options);
894
+ for (const index of definition.indexes ?? []) {
895
+ const keyPath = typeof index.path === "string" ? index.path : [...index.path];
896
+ store.createIndex(index.name, keyPath, {
897
+ unique: index.unique ?? false,
898
+ multiEntry: index.multiple ?? false
899
+ });
900
+ }
901
+ }
902
+ };
903
+ //#endregion
904
+ //#region src/browser/factories.ts
905
+ /**
906
+ * Create a browser-native IndexedDB database over a store schema.
907
+ *
908
+ * @remarks
909
+ * The `const` type parameter captures the literal store names, so `db.store(name)`
910
+ * and `db.read` / `db.write` are checked against the declared stores. Stores are
911
+ * created from their definitions the first time the database opens at a new
912
+ * `version`; omit `version` for auto-managed mode, where the database bumps its
913
+ * own version once to create any declared store the stored schema is missing.
914
+ *
915
+ * @param options - The database `name`, `version`, and `stores` schema
916
+ * @returns A typed {@link IndexedDBDatabaseInterface}
917
+ *
918
+ * @example
919
+ * ```ts
920
+ * import { createIndexedDBDatabase, range } from '@src/browser'
921
+ *
922
+ * const db = createIndexedDBDatabase({
923
+ * name: 'app',
924
+ * version: 1,
925
+ * stores: {
926
+ * users: { path: 'id', indexes: [{ name: 'byAge', path: 'age' }] },
927
+ * },
928
+ * })
929
+ * await db.store('users').set({ id: 'u1', name: 'Ada', age: 36 })
930
+ * await db.store('users').index('byAge').records(range.from(18)) // adults, index-backed
931
+ * ```
932
+ */
933
+ function createIndexedDBDatabase(options) {
934
+ return new IndexedDBDatabase(options);
935
+ }
936
+ //#endregion
937
+ export { ERROR_CODES, IndexedDBCursor, IndexedDBDatabase, IndexedDBError, IndexedDBIndex, IndexedDBStore, IndexedDBTransaction, IndexedDBTransactionStore, createIndexedDBDatabase, guardSync, hasKey, isIndexedDBError, isIndexedDBSupported, promisifyRequest, promisifyTransaction, range, readRecord, readRecords, wrapError };
938
+
939
+ //# sourceMappingURL=index.js.map