@orkestrel/database 0.0.12 → 0.0.14

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.
@@ -1,31 +1,52 @@
1
- import { DatabaseError, applyQuery, bindRowKey, checkAbort, cloneDriverMetadata, cloneMigrationInput, compareValues, equalsValue, extractKey, isDatabaseError, isKey, matchesQuery, migrateRows, normalizeDriverSchema, planMigration, projectMigrationSchema, validatePage } from "../core/index.js";
2
- import { createIndexedDBDatabase, isIndexedDBError, rangeAboveKey, rangeBelowKey, rangeBetweenKeys, rangeExactKey, rangeFromKey, rangeToKey } from "@orkestrel/indexeddb";
1
+ import { DatabaseError, applyQuery, bindRowKey, checkAbort, cloneDriverMetadata, cloneMigrationInput, compareValues, equalsValue, extractKey, findColumn, isDatabaseError, isKey, matchesQuery, migrateRows, normalizeDriverSchema, planMigration, projectMigrationSchema, validatePage } from "../core/index.js";
2
+ import { createIndexedDBDatabase, isIndexedDBError, rangeAboveKey, rangeBelowKey, rangeFromKey, rangeToKey } from "@orkestrel/indexeddb";
3
3
  //#region src/browser/constants.ts
4
- var INDEXABLE_STORAGE = /* @__PURE__ */ new Set([
4
+ /**
5
+ * Lists the declared {@link ColumnStorage}s that are valid, orderable IndexedDB keys.
6
+ *
7
+ * @remarks
8
+ * `text` / `integer` / `real` occupy IndexedDB's string / number key space, so a
9
+ * column declared with one of them can back a store or index range read.
10
+ * `boolean` / `json` / `blob` are not valid `IDBValidKey`s and a range over one
11
+ * would silently miss rows, so `selectPlan` never pushes a condition down on
12
+ * them and the core engine answers the read instead. A frozen array, matching
13
+ * `EXACT_COLUMN_STORAGE` in `src/server`: a consumer holding it reads the
14
+ * membership with `includes` and cannot change the driver's pushdown behavior.
15
+ */
16
+ var INDEXABLE_STORAGE = Object.freeze([
5
17
  "text",
6
18
  "integer",
7
19
  "real"
8
20
  ]);
21
+ /**
22
+ * Names the reserved out-of-line store `__metadata__` the {@link IndexedDBDriver}
23
+ * stamps its {@link DriverMetadata} into.
24
+ *
25
+ * @remarks
26
+ * Backs the driver's `metadata` / `stamp` hooks. A user table named `__metadata__`
27
+ * collides with the driver's own bookkeeping, so a caller must avoid it; the
28
+ * collision is caught at `open`.
29
+ */
9
30
  var METADATA_STORE = "__metadata__";
10
31
  //#endregion
11
32
  //#region src/browser/helpers.ts
12
33
  /**
13
- * The `IDBKeyRange` a single {@link Condition} maps to, when its operator is one
14
- * of the six exact key comparisons over scalar operands; otherwise
15
- * `undefined`.
34
+ * Translates one {@link Condition} to the `IDBKeyRange` it maps to, when its
35
+ * operator is one of the exact key comparisons over scalar operands; otherwise
36
+ * returns `undefined`.
16
37
  *
17
38
  * @remarks
18
39
  * Only the comparison operators (`equals`/`above`/`below`/`from`/`to`/`between`)
19
40
  * translate to a key range that a typed (string/number) column can back with an
20
41
  * IndexedDB store/index read — see {@link selectPlan} for the caveats that
21
- * decide WHICH of `below`/`to` may drive a SECONDARY-index read versus the
42
+ * decide which of `below`/`to` may drive a secondary-index read versus the
22
43
  * primary store only (a column-type / absent-row concern, not a range-shape
23
44
  * one). `starts` is excluded — its prefix range can miss strings past U+FFFF;
24
45
  * the membership / negation / pattern / existence operators (`not`/`like`/`glob`/
25
46
  * `ends`/`any`/`none`/`absent`/`present`) have no single exact range. The operand
26
- * guard (`typeof` string/number) rejects a non-scalar value (e.g. an array, a
47
+ * guard (`typeof` string/number) rejects a non-scalar value (for example an array, a
27
48
  * boolean) that is not a usable key. `between` additionally guards against a
28
- * REVERSED pair (`first > second`): native `IDBKeyRange.bound` throws a raw
49
+ * reversed pair (`first > second`): native `IDBKeyRange.bound` throws a raw
29
50
  * `DataError` `DOMException` for a lower bound above the upper bound, so a
30
51
  * reversed pair returns `undefined` here (falls back to a full scan, which the
31
52
  * engine then correctly resolves to an empty result) rather than letting a
@@ -42,12 +63,12 @@ function conditionToRange(condition) {
42
63
  const first = condition.values[0];
43
64
  const second = condition.values[1];
44
65
  switch (condition.operator) {
45
- case "equals": return isKey(first) ? rangeExactKey(first) : void 0;
66
+ case "equals": return isKey(first) ? IDBKeyRange.only(first) : void 0;
46
67
  case "above": return isKey(first) ? rangeAboveKey(first) : void 0;
47
68
  case "below": return isKey(first) ? rangeBelowKey(first) : void 0;
48
69
  case "from": return isKey(first) ? rangeFromKey(first) : void 0;
49
70
  case "to": return isKey(first) ? rangeToKey(first) : void 0;
50
- case "between": return isKey(first) && isKey(second) && compareValues(first, second) <= 0 ? rangeBetweenKeys(first, second) : void 0;
71
+ case "between": return isKey(first) && isKey(second) && compareValues(first, second) <= 0 ? IDBKeyRange.bound(first, second) : void 0;
51
72
  case "not":
52
73
  case "like":
53
74
  case "glob":
@@ -60,11 +81,11 @@ function conditionToRange(condition) {
60
81
  }
61
82
  }
62
83
  /**
63
- * Plan an IndexedDB read for a {@link QueryInput} — pick the index (or the primary
84
+ * Plans an IndexedDB read for a {@link QueryInput} — picks the index (or the primary
64
85
  * store) and {@link IDBKeyRange} to narrow by, falling back to a full scan.
65
86
  *
66
87
  * @remarks
67
- * Pushdown is sound ONLY when every condition is `and`-joined: the engine folds
88
+ * Pushdown is sound only when every condition is `and`-joined: the engine folds
68
89
  * conditions left-to-right (`c1 && c2 && … && cn`), so the result is a subset of
69
90
  * each — narrowing on any one is then a valid superset. A single `or` breaks that
70
91
  * (a row can match through a later condition the range would exclude), so any `or`
@@ -78,21 +99,21 @@ function conditionToRange(condition) {
78
99
  * type (`boolean`/`json`/`blob`), uses a non-comparison operator, or has a
79
100
  * non-scalar operand cannot push and is skipped.
80
101
  *
81
- * **`below`/`to` may drive a SECONDARY-index range only when the column has NO
102
+ * **`below`/`to` may drive a secondary-index range only when the column has no
82
103
  * absent/null rows to lose — which this planner cannot verify from the schema
83
- * alone, so it restricts them to the PRIMARY store, where that is always true.**
104
+ * alone, so it restricts them to the primary store, where that is always true.**
84
105
  * The engine's total order (`compareValues`, see `@src/core`) ranks
85
- * `undefined` (absent) and `null` BELOW every number/string, so
86
- * `matchesCondition('below' | 'to', …)` is TRUE for a row whose field is absent
87
- * or `null` — but a secondary IndexedDB index has NO ENTRY for a row whose
106
+ * `undefined` (absent) and `null` below every number/string, so
107
+ * `matchesCondition('below' | 'to', …)` is true for a row whose field is absent
108
+ * or `null` — but a secondary IndexedDB index has no entry for a row whose
88
109
  * indexed field is absent/`null`, so a `below`/`to` range read against that
89
- * index would SILENTLY DROP those rows (they can never be over-fetched, only
110
+ * index would silently drop those rows (they can never be over-fetched, only
90
111
  * missed — the one shape of lossiness this planner must never produce). The
91
- * table's PRIMARY key is exempt: a row's primary-key value is always present
112
+ * table's primary key is exempt: a row's primary-key value is always present
92
113
  * and never `null` (it is the row's identity, enforced at write time), so a
93
114
  * `below`/`to` range against the primary store can never exclude an
94
115
  * absent/null-keyed row because no such row exists. `equals`/`above`/`from`/
95
- * `between` stay index-eligible on ANY orderable column, primary or secondary:
116
+ * `between` stay index-eligible on any orderable column, primary or secondary:
96
117
  * each is bounded below by a scalar (`equals`/`between`'s lower bound, `above`/
97
118
  * `from`'s lower bound), and every scalar strictly out-ranks `undefined`/`null`
98
119
  * in the total order, so an absent/null-valued row can never satisfy them — the
@@ -106,7 +127,7 @@ function conditionToRange(condition) {
106
127
  * metadata.
107
128
  *
108
129
  * When no condition qualifies the plan is a full scan (`{}`) and the engine
109
- * does everything. The plan is always a SUPERSET of the
130
+ * does everything. The plan is always a superset of the
110
131
  * matching rows — the only correctness contract — so the driver may safely run
111
132
  * the exact engine over it.
112
133
  *
@@ -129,8 +150,8 @@ function selectPlan(input, schema, available) {
129
150
  if (conditions.slice(1).some((condition) => condition.connector === "or")) return {};
130
151
  for (const condition of conditions) {
131
152
  if (typeof condition.column !== "string") continue;
132
- const column = schema.columns.find((candidate) => candidate.name === condition.column);
133
- if (column === void 0 || !INDEXABLE_STORAGE.has(column.storage)) continue;
153
+ const column = findColumn(condition.column, schema);
154
+ if (column === void 0 || !INDEXABLE_STORAGE.includes(column.storage)) continue;
134
155
  const range = conditionToRange(condition);
135
156
  if (range === void 0) continue;
136
157
  if (condition.column === schema.primary) return { range };
@@ -143,7 +164,7 @@ function selectPlan(input, schema, available) {
143
164
  return {};
144
165
  }
145
166
  /**
146
- * Map a backend {@link IndexedDBError} to the portable `DatabaseError` taxonomy
167
+ * Maps a backend {@link IndexedDBError} to the portable `DatabaseError` taxonomy
147
168
  * — the default mapping used everywhere except inside `migrate()`.
148
169
  *
149
170
  * @remarks
@@ -187,8 +208,8 @@ function mapIndexedDBError(error) {
187
208
  }
188
209
  }
189
210
  /**
190
- * Map a backend {@link IndexedDBError} to the portable `DatabaseError` taxonomy
191
- * for use INSIDE `migrate()` — the one context where `UPGRADE` means the
211
+ * Maps a backend {@link IndexedDBError} to the portable `DatabaseError` taxonomy
212
+ * for use inside `migrate()` — the one context where `UPGRADE` means the
192
213
  * migration itself failed, not a generic driver fault.
193
214
  *
194
215
  * @remarks
@@ -207,20 +228,20 @@ function mapMigrationError(error) {
207
228
  return mapIndexedDBError(error);
208
229
  }
209
230
  /**
210
- * Derive an IndexedDB index name for a declared column group — a bare column
231
+ * Derives an IndexedDB index name for a declared column group — a bare column
211
232
  * name for a single-column index, a deterministic collision-free encoding for a
212
233
  * compound one.
213
234
  *
214
235
  * @remarks
215
236
  * Naming a compound index by joining its columns with `_` (`['a', 'b'] →
216
- * 'a_b'`) collides with a single-column index over a column LITERALLY named
237
+ * 'a_b'`) collides with a single-column index over a column literally named
217
238
  * `'a_b'` — the same name, two different key paths (`'a_b'` vs `['a', 'b']`),
218
239
  * which either throws a native `ConstraintError` from a duplicate
219
240
  * `createIndex` call at open, or (worse) lets {@link selectPlan}'s name-based
220
- * lookup match the wrong index. A single-column index keeps the BARE column
241
+ * lookup match the wrong index. A single-column index keeps the bare column
221
242
  * name — {@link selectPlan} matches `available.includes(condition.column)` by
222
243
  * that exact name, so a single-column index must stay named after its column
223
- * verbatim. A compound index instead encodes each column as a LENGTH-PREFIXED
244
+ * verbatim. A compound index instead encodes each column as a length-prefixed
224
245
  * segment (`'2#1:a1:b'`), so the boundary between columns is self-describing
225
246
  * and cannot be reconstructed by any other column list — including one
226
247
  * containing a column that happens to look like an encoded segment.
@@ -240,7 +261,12 @@ function deriveIndexedDBIndexName(columns) {
240
261
  return `${columns.length}#${columns.map((part) => `${part.length}:${part}`).join("")}`;
241
262
  }
242
263
  /**
243
- * Project a table schema into the IndexedDB wrapper's store definition.
264
+ * Projects a table schema into the IndexedDB wrapper's store definition.
265
+ *
266
+ * @remarks
267
+ * The definition is what an ordered versionchange migration creates the store
268
+ * from, carrying each declared index under the name {@link deriveIndexedDBIndexName}
269
+ * derives for its column group.
244
270
  *
245
271
  * @param schema - Portable table schema
246
272
  * @returns Store definition with declared indexes
@@ -257,8 +283,8 @@ function schemaToStore(schema) {
257
283
  //#endregion
258
284
  //#region src/browser/drivers/IndexedDBDriver.ts
259
285
  /**
260
- * The IndexedDB {@link DriverInterface} — the persistent browser backend, built on
261
- * the published `@orkestrel/indexeddb` wrapper.
286
+ * Implements the {@link DriverInterface} over IndexedDB — the persistent browser backend,
287
+ * built on the published `@orkestrel/indexeddb` wrapper.
262
288
  *
263
289
  * @remarks
264
290
  * A thin adapter: it implements the storage primitives the core database layer
@@ -274,12 +300,12 @@ function schemaToStore(schema) {
274
300
  * wrapper's native `getAll` / `getAllKeys`, and `snapshot` rolls back through one
275
301
  * atomic wrapper transaction.
276
302
  *
277
- * It also implements the optional native `records` / `stream` hooks
278
- * (AGENTS §21): `selectPlan` ({@link selectPlan}) turns the {@link QueryInput} into a
303
+ * It also implements the optional native `records` / `stream` hooks:
304
+ * `selectPlan` ({@link selectPlan}) turns the {@link QueryInput} into a
279
305
  * key-range pushdown over the primary key or a single-column secondary index,
280
306
  * fetching a candidate **superset** that the core engine (`applyQuery` /
281
307
  * `matchesQuery`) then refines — so a native read is byte-identical to a full
282
- * scan, just cheaper. Pushdown is conservative: only the exact-comparison
308
+ * scan, only cheaper. Pushdown is conservative: only the exact-comparison
283
309
  * operators over orderable columns narrow to a range; everything else falls back
284
310
  * to a full scan + the engine.
285
311
  *
@@ -291,22 +317,23 @@ function schemaToStore(schema) {
291
317
  * {@link Migration} plan natively: IndexedDB schema DDL (creating/dropping a
292
318
  * store, creating/dropping an index) is legal only inside a versionchange
293
319
  * transaction (`onupgradeneeded`), so `migrate` closes the current connection
294
- * and opens a FRESH one at `version + 1` with an `upgrade` hook that walks the
320
+ * and opens a fresh one at `version + 1` with an `upgrade` hook that walks the
295
321
  * plan's steps — dropping stores, adding/removing indexes on the raw
296
- * `IDBTransaction`, and rewriting rows for `column.remove` via a cursor walk
322
+ * `IDBTransaction`, and rewriting rows for `column.remove` through a cursor walk
297
323
  * (the one step needing to touch existing data; `column.add` is a no-op — this
298
324
  * driver stores whatever a row carries, so there is nothing to backfill). A
299
- * step referencing an unknown table is validated BEFORE the reconnect, so a
325
+ * step referencing an unknown table is validated before the reconnect, so a
300
326
  * `MIGRATION` `DatabaseError` never wastes a version bump.
301
327
  *
302
328
  * @remarks
303
- * This unit deliberately OMITS `aggregate` / `transaction`. There is no native
329
+ * This unit deliberately omits `aggregate` / `transaction`. There is no native
304
330
  * `aggregate` (IndexedDB has no native SUM/AVG); the engine over the narrowed
305
- * `records` covers it. `transaction` is impossible here: the wrapper auto-commits
306
- * an `IDBTransaction` when control yields outside its request chain, so arbitrary
307
- * callback awaits cannot remain inside one native transaction. Every atomic
308
- * multi-operation sequence in this driver
309
- * (`snapshot`'s rollback) instead runs entirely inside ONE `db.write(...)` scope.
331
+ * `records` covers it. `transaction` is impossible here: the wrapper
332
+ * auto-commits an `IDBTransaction` when control yields outside its request
333
+ * chain, so arbitrary callback awaits cannot remain inside one native
334
+ * transaction. Every atomic multi-operation sequence in this driver
335
+ * (`snapshot`'s rollback) instead runs entirely inside one `db.write(...)`
336
+ * scope.
310
337
  */
311
338
  var IndexedDBDriver = class {
312
339
  #name;
@@ -434,30 +461,6 @@ var IndexedDBDriver = class {
434
461
  validatePage(input);
435
462
  return this.#stream(table, input);
436
463
  }
437
- async *#stream(table, input) {
438
- try {
439
- const schema = this.#table(table);
440
- const store = this.#store(table);
441
- const plan = selectPlan(input, schema, store.indexes);
442
- const conditions = input.conditions ?? [];
443
- const offset = input.offset ?? 0;
444
- const limit = input.limit;
445
- let skipped = 0;
446
- let yielded = 0;
447
- for (const row of await this.#candidates(store, schema, plan)) {
448
- if (limit !== void 0 && yielded >= limit) break;
449
- if (!matchesQuery(row, conditions)) continue;
450
- if (skipped < offset) {
451
- skipped += 1;
452
- continue;
453
- }
454
- yielded += 1;
455
- yield row;
456
- }
457
- } catch (error) {
458
- throw this.#wrap(error);
459
- }
460
- }
461
464
  async snapshot(tables) {
462
465
  try {
463
466
  const database = this.#require();
@@ -529,7 +532,7 @@ var IndexedDBDriver = class {
529
532
  }
530
533
  }
531
534
  /**
532
- * Return the persisted {@link DriverMetadata}, or `undefined` when the store has
535
+ * Returns the persisted {@link DriverMetadata}, or `undefined` when the store has
533
536
  * never been stamped.
534
537
  *
535
538
  * @remarks
@@ -548,7 +551,7 @@ var IndexedDBDriver = class {
548
551
  }
549
552
  }
550
553
  /**
551
- * Persist an owned metadata snapshot for a later `metadata()` to return.
554
+ * Persists an owned metadata snapshot for a later `metadata()` to return.
552
555
  *
553
556
  * @param metadata - The {@link DriverMetadata} to persist
554
557
  */
@@ -565,16 +568,16 @@ var IndexedDBDriver = class {
565
568
  }
566
569
  }
567
570
  /**
568
- * Apply a {@link Migration} plan by reconnecting at a bumped version and
571
+ * Applies a {@link Migration} plan by reconnecting at a bumped version and
569
572
  * running the plan's steps inside the wrapper's `upgrade` hook.
570
573
  *
571
574
  * @remarks
572
575
  * IndexedDB schema DDL is legal only inside `onupgradeneeded`, so this closes
573
- * the current connection and opens a FRESH one at `version + 1`, declaring
574
- * every currently-known store (plus {@link METADATA_STORE}) so nothing is lost,
575
- * and applying `table.remove` / `index.add` / `index.remove` /
576
+ * the current connection and opens a fresh one at `version + 1`, declaring
577
+ * every store known at that point (plus {@link METADATA_STORE}) so nothing is
578
+ * lost, and applying `table.remove` / `index.add` / `index.remove` /
576
579
  * `column.remove` inside `upgrade`. Every step's `table` is validated against
577
- * the driver's own `#schema` BEFORE the reconnect — an unknown-table step
580
+ * the driver's own `#schema` before the reconnect — an unknown-table step
578
581
  * throws `DatabaseError` `MIGRATION` without ever bumping the version.
579
582
  * `table.add` / `column.add` need no upgrade-time action: `table.add` is
580
583
  * created by the wrapper's built-in create-missing-stores pass (its
@@ -647,6 +650,30 @@ var IndexedDBDriver = class {
647
650
  throw cause;
648
651
  }
649
652
  }
653
+ async *#stream(table, input) {
654
+ try {
655
+ const schema = this.#table(table);
656
+ const store = this.#store(table);
657
+ const plan = selectPlan(input, schema, store.indexes);
658
+ const conditions = input.conditions ?? [];
659
+ const offset = input.offset ?? 0;
660
+ const limit = input.limit;
661
+ let skipped = 0;
662
+ let yielded = 0;
663
+ for (const row of await this.#candidates(store, schema, plan)) {
664
+ if (limit !== void 0 && yielded >= limit) break;
665
+ if (!matchesQuery(row, conditions)) continue;
666
+ if (skipped < offset) {
667
+ skipped += 1;
668
+ continue;
669
+ }
670
+ yielded += 1;
671
+ yield row;
672
+ }
673
+ } catch (error) {
674
+ throw this.#wrap(error);
675
+ }
676
+ }
650
677
  async #mutate(table, options, scope) {
651
678
  const signal = options?.signal;
652
679
  checkAbort(signal);
@@ -759,31 +786,33 @@ var IndexedDBDriver = class {
759
786
  return schema;
760
787
  }
761
788
  async #upgrade(input, added, context) {
762
- for (const name of added) if (name !== "__metadata__" && context.stores.includes(name)) context.drop(name);
789
+ for (const name of added) if (name !== "__metadata__" && context.stores.names.includes(name)) context.stores.drop(name);
763
790
  for (const step of input.plan.steps) switch (step.operation) {
764
791
  case "table.add":
765
- context.create(step.table.name, schemaToStore(step.table));
792
+ context.stores.create(step.table.name, schemaToStore(step.table));
766
793
  break;
767
794
  case "table.remove":
768
- context.drop(step.table);
795
+ context.stores.drop(step.table);
769
796
  break;
770
797
  case "index.add": {
771
798
  const name = deriveIndexedDBIndexName(step.index);
772
799
  const [column] = step.index;
773
800
  const path = step.index.length === 1 && column !== void 0 ? column : [...step.index];
774
- context.index(step.table, {
801
+ context.indexes.create(step.table, {
775
802
  name,
776
803
  path
777
804
  });
778
805
  break;
779
806
  }
780
807
  case "index.remove":
781
- context.deindex(step.table, deriveIndexedDBIndexName(step.index));
808
+ context.indexes.drop(step.table, deriveIndexedDBIndexName(step.index));
782
809
  break;
783
810
  case "column.remove": {
784
- let cursor = await context.store(step.table).cursor();
811
+ let cursor = await context.stores.store(step.table).cursor();
785
812
  while (cursor !== null) {
786
- const [migrated] = migrateRows([cursor.value], [step]);
813
+ const row = cursor.value;
814
+ if (row === void 0) throw new DatabaseError("MIGRATION", "migrate: stored value is not a record", { table: step.table });
815
+ const [migrated] = migrateRows([row], [step]);
787
816
  if (migrated === void 0) throw new DatabaseError("MIGRATION", "migrate: transformed row is missing", { table: step.table });
788
817
  await cursor.update(migrated);
789
818
  cursor = await cursor.continue();
@@ -791,7 +820,7 @@ var IndexedDBDriver = class {
791
820
  break;
792
821
  }
793
822
  }
794
- if (input.metadata !== void 0) await context.store(METADATA_STORE).set({
823
+ if (input.metadata !== void 0) await context.stores.store(METADATA_STORE).set({
795
824
  version: input.metadata.version,
796
825
  schema: input.metadata.schema
797
826
  }, "metadata");
@@ -800,7 +829,7 @@ var IndexedDBDriver = class {
800
829
  //#endregion
801
830
  //#region src/browser/factories.ts
802
831
  /**
803
- * Create a persistent IndexedDB {@link DriverInterface} for the core database layer.
832
+ * Creates a persistent IndexedDB {@link DriverInterface} for a browser database name.
804
833
  *
805
834
  * @remarks
806
835
  * Pass it to `createDatabase` from `@orkestrel/database` to run the typed database