@orkestrel/database 0.0.1 → 0.0.3

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,686 @@
1
+ import { DatabaseError, applyCriteria, compareValues, extractKey, isDriverMeta, matchesCriteria, migrateRows } from "../core/index.js";
2
+ import { createIndexedDBDatabase, isIndexedDBError, range } from "@orkestrel/indexeddb";
3
+ //#region src/browser/constants.ts
4
+ var INDEXABLE_TYPES = /* @__PURE__ */ new Set([
5
+ "text",
6
+ "integer",
7
+ "real"
8
+ ]);
9
+ var META_STORE = "__meta__";
10
+ //#endregion
11
+ //#region src/browser/helpers.ts
12
+ /**
13
+ * The `IDBKeyRange` a single {@link Condition} maps to, when its operator is one
14
+ * of the six exact key comparisons over scalar operands — else `null`.
15
+ *
16
+ * @remarks
17
+ * Only the comparison operators (`equals`/`above`/`below`/`from`/`to`/`between`)
18
+ * translate to a key range that a typed (string/number) column can back with an
19
+ * IndexedDB store/index read — see {@link selectPlan} for the caveats that
20
+ * decide WHICH of `below`/`to` may drive a SECONDARY-index read versus the
21
+ * primary store only (a column-type / absent-row concern, not a range-shape
22
+ * one). `starts` is excluded — its prefix range can miss strings past U+FFFF;
23
+ * the membership / negation / pattern / existence operators (`not`/`like`/`glob`/
24
+ * `ends`/`any`/`none`/`absent`/`present`) have no single exact range. The operand
25
+ * guard (`typeof` string/number) rejects a non-scalar value (e.g. an array, a
26
+ * boolean) that is not a usable key. `between` additionally guards against a
27
+ * REVERSED pair (`first > second`): native `IDBKeyRange.bound` throws a raw
28
+ * `DataError` `DOMException` for a lower bound above the upper bound, so a
29
+ * reversed pair returns `null` here (falls back to a full scan, which the
30
+ * engine then correctly resolves to an empty result) rather than letting a
31
+ * native exception escape untyped — the same defensive posture as every other
32
+ * backend, which returns empty for a reversed/empty range instead of throwing.
33
+ * The switch is exhaustive over every {@link ConditionOperator}, so a new
34
+ * operator forces a deliberate decision here rather than silently defaulting
35
+ * to a (possibly lossy) range.
36
+ *
37
+ * @param condition - The condition to translate
38
+ * @returns Its exact key range, or `null` when the operator/operands cannot push
39
+ */
40
+ function conditionRange(condition) {
41
+ const first = condition.values[0];
42
+ const second = condition.values[1];
43
+ switch (condition.operator) {
44
+ case "equals": return isKey(first) ? range.only(first) : null;
45
+ case "above": return isKey(first) ? range.above(first) : null;
46
+ case "below": return isKey(first) ? range.below(first) : null;
47
+ case "from": return isKey(first) ? range.from(first) : null;
48
+ case "to": return isKey(first) ? range.to(first) : null;
49
+ case "between": return isKey(first) && isKey(second) && compareValues(first, second) <= 0 ? range.between(first, second) : null;
50
+ case "not":
51
+ case "like":
52
+ case "glob":
53
+ case "starts":
54
+ case "ends":
55
+ case "any":
56
+ case "none":
57
+ case "absent":
58
+ case "present": return null;
59
+ }
60
+ }
61
+ function isKey(value) {
62
+ return typeof value === "string" || typeof value === "number";
63
+ }
64
+ /**
65
+ * Plan an IndexedDB read for a {@link Criteria} — pick the index (or the primary
66
+ * store) and {@link IDBKeyRange} to narrow by, falling back to a full scan.
67
+ *
68
+ * @remarks
69
+ * Pushdown is sound ONLY when every condition is `and`-joined: the engine folds
70
+ * conditions left-to-right (`c1 && c2 && … && cn`), so the result is a subset of
71
+ * each — narrowing on any one is then a valid superset. A single `or` breaks that
72
+ * (a row can match through a later condition the range would exclude), so any `or`
73
+ * forces a full scan. Otherwise it scans the conditions in order and selects the
74
+ * **first** one that is provably range-exact and backed by a key: a comparison
75
+ * operator (`conditionRange`) over a single, orderable (`text`/`integer`/`real`)
76
+ * column that is either the table's primary key (read the store directly, `index:
77
+ * null`) or has a single-column secondary index (named exactly the column — read
78
+ * that index). A condition whose column is a nested {@link FieldPath} array
79
+ * (descends a json value, not a key), is absent from the schema, is a non-orderable
80
+ * type (`boolean`/`json`/`blob`), uses a non-comparison operator, or has a
81
+ * non-scalar operand cannot push and is skipped.
82
+ *
83
+ * **`below`/`to` may drive a SECONDARY-index range only when the column has NO
84
+ * absent/null rows to lose — which this planner cannot verify from the schema
85
+ * alone, so it restricts them to the PRIMARY store, where that is always true.**
86
+ * The engine's total order (`compareValues`, see `@src/core`) ranks
87
+ * `undefined` (absent) and `null` BELOW every number/string, so
88
+ * `matchesCondition('below' | 'to', …)` is TRUE for a row whose field is absent
89
+ * or `null` — but a secondary IndexedDB index has NO ENTRY for a row whose
90
+ * indexed field is absent/`null`, so a `below`/`to` range read against that
91
+ * index would SILENTLY DROP those rows (they can never be over-fetched, only
92
+ * missed — the one shape of lossiness this planner must never produce). The
93
+ * table's PRIMARY key is exempt: a row's primary-key value is always present
94
+ * and never `null` (it is the row's identity, enforced at write time), so a
95
+ * `below`/`to` range against the primary store can never exclude an
96
+ * absent/null-keyed row because no such row exists. `equals`/`above`/`from`/
97
+ * `between` stay index-eligible on ANY orderable column, primary or secondary:
98
+ * each is bounded below by a scalar (`equals`/`between`'s lower bound, `above`/
99
+ * `from`'s lower bound), and every scalar strictly out-ranks `undefined`/`null`
100
+ * in the total order, so an absent/null-valued row can never satisfy them — the
101
+ * index's silence on such a row is harmless (it was never going to match).
102
+ * **Declared-type trust caveat:** this reasoning holds under the contract that
103
+ * an {@link INDEXABLE_TYPES} column, once contract-validated at write time,
104
+ * holds only `string | number | null` (or is absent) — never some other
105
+ * runtime value that could rank differently; a driver bypassing the write
106
+ * contract (writing raw rows directly to the store) could defeat this
107
+ * argument, but that is out of scope for a planner reading validated schema
108
+ * metadata.
109
+ *
110
+ * When no condition qualifies the plan is a full scan (`{ index: null, range:
111
+ * null }`) and the engine does everything. The plan is always a SUPERSET of the
112
+ * matching rows — the only correctness contract — so the driver may safely run
113
+ * the exact engine over it.
114
+ *
115
+ * @param criteria - The read specification (its `conditions` drive the plan), or
116
+ * `undefined` for an unconditional read
117
+ * @param schema - The table's schema — its `primary` key and column types
118
+ * @param available - The secondary-index names that physically exist on the store
119
+ * (`store.indexes`); a single-column index is named exactly its column
120
+ * @returns The index + range to read, narrowing to a superset (never lossy)
121
+ *
122
+ * @example
123
+ * ```ts
124
+ * selectPlan({ conditions: [eq('id', 'u1')] }, schema, []) // { index: null, range: only('u1') }
125
+ * selectPlan({ conditions: [from('age', 18)] }, schema, ['age']) // { index: 'age', range: from(18) }
126
+ * selectPlan({ conditions: [contains('name', 'a')] }, schema, []) // { index: null, range: null }
127
+ * ```
128
+ */
129
+ function selectPlan(criteria, schema, available) {
130
+ const conditions = criteria?.conditions ?? [];
131
+ if (conditions.slice(1).some((condition) => condition.connector === "or")) return {
132
+ index: null,
133
+ range: null
134
+ };
135
+ for (const condition of conditions) {
136
+ if (typeof condition.column !== "string") continue;
137
+ const column = schema.columns.find((candidate) => candidate.name === condition.column);
138
+ if (column === void 0 || !INDEXABLE_TYPES.has(column.type)) continue;
139
+ const keyRange = conditionRange(condition);
140
+ if (keyRange === null) continue;
141
+ if (condition.column === schema.primary) return {
142
+ index: null,
143
+ range: keyRange
144
+ };
145
+ if (condition.operator === "below" || condition.operator === "to") continue;
146
+ if (available.includes(condition.column)) return {
147
+ index: condition.column,
148
+ range: keyRange
149
+ };
150
+ }
151
+ return {
152
+ index: null,
153
+ range: null
154
+ };
155
+ }
156
+ /**
157
+ * Map a backend {@link IndexedDBError} to the portable `DatabaseError` taxonomy
158
+ * — the default mapping used everywhere except inside `migrate()`.
159
+ *
160
+ * @remarks
161
+ * No backend fault may leak through `DriverInterface` as a raw `IndexedDBError`.
162
+ * `CONSTRAINT` (a unique-key violation) is a `CONFLICT` — the same code every
163
+ * other backend uses for a duplicate key. `CLOSED`/`NOT_OPEN`/`INVALID` (the
164
+ * connection is gone, never opened, or the native handle is stale) collapse to
165
+ * `CLOSED`. `QUOTA` and `BLOCKED` are genuine infrastructure faults (`DRIVER`),
166
+ * carrying a machine-readable `context.code` (`'QUOTA'` / `'BLOCKED'`) so a
167
+ * caller can branch without parsing the message; `BLOCKED` additionally marks
168
+ * `context.retryable: true` — a concurrent connection holding the database open
169
+ * is a transient condition, not a permanent one. Every other code (`UPGRADE`
170
+ * here — see {@link mapMigrationError} for the `migrate()`-only remapping to
171
+ * `MIGRATION` — `ABORTED`, `NOT_FOUND`, `DATA`, `OPEN`, `INACTIVE`, `READONLY`,
172
+ * `UNKNOWN`) is an unexpected infrastructure fault and maps to `DRIVER` — the
173
+ * driver opens its own readwrite transactions, so a `READONLY` fault can only
174
+ * mean the backend behaved unexpectedly. The original error is always
175
+ * preserved as `context.cause` for diagnostics.
176
+ *
177
+ * @param error - The backend error to translate
178
+ * @returns The portable `DatabaseError`
179
+ */
180
+ function mapIndexedDBError(error) {
181
+ switch (error.code) {
182
+ case "CONSTRAINT": return new DatabaseError("CONFLICT", error.message, { cause: error });
183
+ case "CLOSED":
184
+ case "NOT_OPEN":
185
+ case "INVALID": return new DatabaseError("CLOSED", error.message, { cause: error });
186
+ case "QUOTA": return new DatabaseError("DRIVER", error.message, {
187
+ cause: error,
188
+ code: "QUOTA"
189
+ });
190
+ case "BLOCKED": return new DatabaseError("DRIVER", error.message, {
191
+ cause: error,
192
+ code: "BLOCKED",
193
+ retryable: true
194
+ });
195
+ case "UPGRADE":
196
+ case "ABORTED":
197
+ case "NOT_FOUND":
198
+ case "DATA":
199
+ case "OPEN":
200
+ case "INACTIVE":
201
+ case "READONLY":
202
+ case "UNKNOWN": return new DatabaseError("DRIVER", error.message, { cause: error });
203
+ }
204
+ }
205
+ /**
206
+ * Map a backend {@link IndexedDBError} to the portable `DatabaseError` taxonomy
207
+ * for use INSIDE `migrate()` — the one context where `UPGRADE` means the
208
+ * migration itself failed, not a generic driver fault.
209
+ *
210
+ * @remarks
211
+ * `migrate()` reconnects at a bumped version inside `onupgradeneeded`; a
212
+ * rejection there (an inapplicable step, a native `ConstraintError` from a
213
+ * duplicate index, …) surfaces as `IndexedDBError` `UPGRADE` and must become a
214
+ * `MIGRATION` `DatabaseError` so a caller can distinguish "this migration plan
215
+ * failed" from "the driver hit an unrelated infrastructure fault". Every other
216
+ * code defers to {@link mapIndexedDBError} unchanged.
217
+ *
218
+ * @param error - The backend error to translate
219
+ * @returns The portable `DatabaseError`
220
+ */
221
+ function mapMigrationError(error) {
222
+ if (error.code === "UPGRADE") return new DatabaseError("MIGRATION", error.message, { cause: error });
223
+ return mapIndexedDBError(error);
224
+ }
225
+ /**
226
+ * Derive an IndexedDB index name for a declared column group — a bare column
227
+ * name for a single-column index, a deterministic collision-free encoding for a
228
+ * compound one.
229
+ *
230
+ * @remarks
231
+ * Naming a compound index by joining its columns with `_` (`['a', 'b'] →
232
+ * 'a_b'`) collides with a single-column index over a column LITERALLY named
233
+ * `'a_b'` — the same name, two different key paths (`'a_b'` vs `['a', 'b']`),
234
+ * which either throws a native `ConstraintError` from a duplicate
235
+ * `createIndex` call at open, or (worse) lets {@link selectPlan}'s name-based
236
+ * lookup match the wrong index. A single-column index keeps the BARE column
237
+ * name — {@link selectPlan} matches `available.includes(condition.column)` by
238
+ * that exact name, so a single-column index must stay named after its column
239
+ * verbatim. A compound index instead encodes each column as a LENGTH-PREFIXED
240
+ * segment (`'2#1:a1:b'`), so the boundary between columns is self-describing
241
+ * and cannot be reconstructed by any other column list — including one
242
+ * containing a column that happens to look like an encoded segment.
243
+ *
244
+ * @param columns - The index's column group, in declared order
245
+ * @returns The index name to pass to `createIndex` / read back from `indexNames`
246
+ *
247
+ * @example
248
+ * ```ts
249
+ * deriveIndexName(['age']) // 'age'
250
+ * deriveIndexName(['a', 'b']) // '2#1:a1:b'
251
+ * ```
252
+ */
253
+ function deriveIndexName(columns) {
254
+ if (columns.length === 1) return columns[0];
255
+ return `${columns.length}#${columns.map((column) => `${column.length}:${column}`).join("")}`;
256
+ }
257
+ //#endregion
258
+ //#region src/browser/drivers/IndexedDBDriver.ts
259
+ /**
260
+ * The IndexedDB {@link DriverInterface} — the persistent browser backend, built on
261
+ * the published `@orkestrel/indexeddb` wrapper.
262
+ *
263
+ * @remarks
264
+ * A thin adapter: it implements the storage primitives the core database layer
265
+ * needs (`open` / `close` / `read` / `write` / `delete` / `keys` / `scan` / `clear`
266
+ * / `snapshot`) by delegating to the wrapper's typed store operations — it never
267
+ * touches raw IndexedDB. Rows are stored with **out-of-line keys** (the database
268
+ * passes the key explicitly, `store.set(row, key)`), so each table is declared as a
269
+ * key-path-less store. The wrapper opens in **auto-managed** mode (no fixed
270
+ * version), creating any missing store on demand, so a table added to the schema is
271
+ * created on the next open with no manual version bump. The driver's bulk reads
272
+ * (`scan` / `keys`) use the wrapper's native `getAll` / `getAllKeys`, and `snapshot`
273
+ * rolls back through one atomic wrapper transaction.
274
+ *
275
+ * It also implements the optional native `records` / `count` / `stream` hooks
276
+ * (AGENTS §21): `selectPlan` ({@link selectPlan}) turns the {@link Criteria} into a
277
+ * key-range pushdown over the primary key or a single-column secondary index,
278
+ * fetching a candidate **superset** that the core engine (`applyCriteria` /
279
+ * `matchesCriteria`) then refines — so a native read is byte-identical to a full
280
+ * scan, just cheaper. Pushdown is conservative: only the exact-comparison
281
+ * operators over orderable columns narrow to a range; everything else falls back
282
+ * to a full scan + the engine.
283
+ *
284
+ * @remarks
285
+ * This driver also implements `migrate` / `meta` / `stamp`. `meta` / `stamp`
286
+ * persist the {@link DriverMeta} in a reserved out-of-line store,
287
+ * {@link META_STORE} (`__meta__`) — excluded from a whole-store `snapshot`
288
+ * capture, since it is driver bookkeeping, not caller data. `migrate` applies a
289
+ * {@link Migration} plan natively: IndexedDB schema DDL (creating/dropping a
290
+ * store, creating/dropping an index) is legal only inside a versionchange
291
+ * transaction (`onupgradeneeded`), so `migrate` closes the current connection
292
+ * and opens a FRESH one at `version + 1` with an `upgrade` hook that walks the
293
+ * plan's steps — dropping stores, adding/removing indexes on the raw
294
+ * `IDBTransaction`, and rewriting rows for `column.remove` via a cursor walk
295
+ * (the one step needing to touch existing data; `column.add` is a no-op — this
296
+ * driver stores whatever a row carries, so there is nothing to backfill). A
297
+ * step referencing an unknown table is validated BEFORE the reconnect, so a
298
+ * `MIGRATION` `DatabaseError` never wastes a version bump.
299
+ *
300
+ * @remarks
301
+ * This unit deliberately OMITS `aggregate` / `transaction`. There is no native
302
+ * `aggregate` (IndexedDB has no native SUM/AVG); the engine over the narrowed
303
+ * `records` covers it. `transaction` is impossible here: the wrapper auto-commits
304
+ * an `IDBTransaction` the moment control yields to a non-IDB `await`, so a
305
+ * BEGIN-now / commit-or-rollback-later handle spanning arbitrary caller code
306
+ * cannot be built on top of it — every atomic multi-op sequence in this driver
307
+ * (`snapshot`'s rollback) instead runs entirely inside ONE `db.write(...)` scope.
308
+ */
309
+ var IndexedDBDriver = class {
310
+ #name;
311
+ #schema = /* @__PURE__ */ new Map();
312
+ #database;
313
+ constructor(name) {
314
+ this.#name = name;
315
+ }
316
+ async open(schema) {
317
+ if (schema.some((table) => table.name === "__meta__")) throw new DatabaseError("VALIDATION", `open: table name '${META_STORE}' is reserved for driver metadata`, { table: META_STORE });
318
+ try {
319
+ this.#database?.close();
320
+ const map = /* @__PURE__ */ new Map();
321
+ for (const table of schema) map.set(table.name, table);
322
+ const database = createIndexedDBDatabase({
323
+ name: this.#name,
324
+ stores: this.#stores(map)
325
+ });
326
+ await database.connect();
327
+ this.#database = database;
328
+ this.#schema = map;
329
+ } catch (error) {
330
+ throw this.#wrap(error);
331
+ }
332
+ }
333
+ async close() {
334
+ this.#database?.close();
335
+ this.#database = void 0;
336
+ }
337
+ async read(table, key) {
338
+ try {
339
+ return await this.#store(table).get(key);
340
+ } catch (error) {
341
+ throw this.#wrap(error);
342
+ }
343
+ }
344
+ async write(table, key, row) {
345
+ try {
346
+ await this.#store(table).set(row, key);
347
+ } catch (error) {
348
+ throw this.#wrap(error);
349
+ }
350
+ }
351
+ async delete(table, key) {
352
+ try {
353
+ const store = this.#store(table);
354
+ const present = await store.has(key);
355
+ await store.remove(key);
356
+ return present;
357
+ } catch (error) {
358
+ throw this.#wrap(error);
359
+ }
360
+ }
361
+ async keys(table) {
362
+ try {
363
+ return (await this.#store(table).keys()).filter((key) => typeof key === "string" || typeof key === "number");
364
+ } catch (error) {
365
+ throw this.#wrap(error);
366
+ }
367
+ }
368
+ async *scan(table) {
369
+ try {
370
+ for (const row of await this.#store(table).records()) yield row;
371
+ } catch (error) {
372
+ throw this.#wrap(error);
373
+ }
374
+ }
375
+ async clear(table) {
376
+ try {
377
+ await this.#store(table).clear();
378
+ } catch (error) {
379
+ throw this.#wrap(error);
380
+ }
381
+ }
382
+ async records(table, criteria) {
383
+ try {
384
+ const schema = this.#table(table);
385
+ const store = this.#store(table);
386
+ const plan = selectPlan(criteria, schema, store.indexes);
387
+ return applyCriteria(await this.#candidates(store, schema, plan), criteria);
388
+ } catch (error) {
389
+ throw this.#wrap(error);
390
+ }
391
+ }
392
+ async count(table, criteria) {
393
+ try {
394
+ const schema = this.#table(table);
395
+ const store = this.#store(table);
396
+ const conditions = criteria.conditions ?? [];
397
+ if (conditions.length === 0) return await store.count();
398
+ const plan = selectPlan(criteria, schema, store.indexes);
399
+ if (conditions.length === 1 && plan.range !== null) return plan.index === null ? await store.count(plan.range) : await store.index(plan.index).count(plan.range);
400
+ return (plan.index === null ? await store.records(plan.range) : await store.index(plan.index).records(plan.range)).reduce((total, row) => matchesCriteria(row, conditions) ? total + 1 : total, 0);
401
+ } catch (error) {
402
+ throw this.#wrap(error);
403
+ }
404
+ }
405
+ async *stream(table, criteria) {
406
+ try {
407
+ const schema = this.#table(table);
408
+ const store = this.#store(table);
409
+ const plan = selectPlan(criteria, schema, store.indexes);
410
+ const conditions = criteria.conditions ?? [];
411
+ const offset = criteria.offset ?? 0;
412
+ const limit = criteria.limit;
413
+ let skipped = 0;
414
+ let yielded = 0;
415
+ for (const row of await this.#candidates(store, schema, plan)) {
416
+ if (limit !== void 0 && yielded >= limit) break;
417
+ if (!matchesCriteria(row, conditions)) continue;
418
+ if (skipped < offset) {
419
+ skipped += 1;
420
+ continue;
421
+ }
422
+ yielded += 1;
423
+ yield row;
424
+ }
425
+ } catch (error) {
426
+ throw this.#wrap(error);
427
+ }
428
+ }
429
+ async snapshot(tables) {
430
+ try {
431
+ const database = this.#require();
432
+ const names = tables ?? database.stores.filter((name) => name !== "__meta__");
433
+ const captured = /* @__PURE__ */ new Map();
434
+ if (names.length > 0) await database.read(names, async (transaction) => {
435
+ for (const name of names) {
436
+ const store = transaction.store(name);
437
+ captured.set(name, {
438
+ keys: await store.keys(),
439
+ rows: await store.records()
440
+ });
441
+ }
442
+ });
443
+ return async () => {
444
+ try {
445
+ const current = this.#require();
446
+ const restorable = names.filter((name) => current.stores.includes(name));
447
+ if (restorable.length === 0) return;
448
+ await current.write(restorable, async (transaction) => {
449
+ for (const name of restorable) {
450
+ const snapshot = captured.get(name);
451
+ if (snapshot === void 0) continue;
452
+ const store = transaction.store(name);
453
+ await store.clear();
454
+ for (let index = 0; index < snapshot.keys.length; index += 1) await store.set(snapshot.rows[index], snapshot.keys[index]);
455
+ }
456
+ });
457
+ } catch (error) {
458
+ throw this.#wrap(error);
459
+ }
460
+ };
461
+ } catch (error) {
462
+ throw this.#wrap(error);
463
+ }
464
+ }
465
+ /**
466
+ * Return the persisted {@link DriverMeta}, or `undefined` when the store has
467
+ * never been stamped.
468
+ *
469
+ * @remarks
470
+ * Reads `'meta'` from the reserved {@link META_STORE}, narrowing the
471
+ * structured-clone value with the core {@link isDriverMeta} guard (never
472
+ * asserted, AGENTS §14) — a missing or malformed record returns `undefined`,
473
+ * exactly like a fresh, never-stamped store.
474
+ *
475
+ * @returns The last-stamped {@link DriverMeta}, or `undefined`
476
+ */
477
+ async meta() {
478
+ try {
479
+ const record = await this.#require().store(META_STORE).get("meta");
480
+ if (!isDriverMeta(record)) return void 0;
481
+ return record;
482
+ } catch (error) {
483
+ throw this.#wrap(error);
484
+ }
485
+ }
486
+ /**
487
+ * Persist `meta` verbatim for a later `meta()` to return.
488
+ *
489
+ * @param meta - The {@link DriverMeta} to persist
490
+ */
491
+ async stamp(meta) {
492
+ try {
493
+ await this.#require().store(META_STORE).set({ ...meta }, "meta");
494
+ } catch (error) {
495
+ throw this.#wrap(error);
496
+ }
497
+ }
498
+ /**
499
+ * Apply a {@link Migration} plan by reconnecting at a bumped version and
500
+ * running the plan's steps inside the wrapper's `upgrade` hook.
501
+ *
502
+ * @remarks
503
+ * IndexedDB schema DDL is legal only inside `onupgradeneeded`, so this closes
504
+ * the current connection and opens a FRESH one at `version + 1`, declaring
505
+ * every currently-known store (plus {@link META_STORE}) so nothing is lost,
506
+ * and applying `table.remove` / `index.add` / `index.remove` /
507
+ * `column.remove` inside `upgrade`. Every step's `table` is validated against
508
+ * the driver's own `#schema` BEFORE the reconnect — an unknown-table step
509
+ * throws `DatabaseError` `MIGRATION` without ever bumping the version.
510
+ * `table.add` / `column.add` need no upgrade-time action: `table.add` is
511
+ * created by the wrapper's built-in create-missing-stores pass (its
512
+ * definition is already in the declared `stores`), and this driver stores
513
+ * whatever a row carries — there is nothing to backfill for a new column.
514
+ * `#schema` bookkeeping is updated to match the applied plan, mirroring what
515
+ * `open` tracks, so subsequent pushdown planning and a later `migrate` /
516
+ * `open` see the new shape.
517
+ *
518
+ * @param plan - The migration plan to apply
519
+ */
520
+ async migrate(plan) {
521
+ for (const step of plan.steps) if (step.operation !== "table.add" && !this.#schema.has(step.table)) throw new DatabaseError("MIGRATION", `migrate: unknown table '${step.table}'`, { table: step.table });
522
+ const current = this.#require();
523
+ const version = current.version;
524
+ const schema = new Map(this.#schema);
525
+ this.#applySteps(schema, plan.steps);
526
+ current.close();
527
+ try {
528
+ const database = createIndexedDBDatabase({
529
+ name: this.#name,
530
+ version: version + 1,
531
+ stores: this.#stores(schema),
532
+ upgrade: (context) => this.#upgrade(context, plan.steps)
533
+ });
534
+ await database.connect();
535
+ this.#database = database;
536
+ this.#schema = schema;
537
+ } catch (error) {
538
+ await this.#reopen();
539
+ throw isIndexedDBError(error) ? mapMigrationError(error) : error;
540
+ }
541
+ }
542
+ #require() {
543
+ if (this.#database === void 0) throw new DatabaseError("CLOSED", `IndexedDB database '${this.#name}' is not open`, { name: this.#name });
544
+ return this.#database;
545
+ }
546
+ #wrap(error) {
547
+ return isIndexedDBError(error) ? mapIndexedDBError(error) : error;
548
+ }
549
+ #store(table) {
550
+ return this.#require().store(table);
551
+ }
552
+ #stores(schema) {
553
+ const stores = { [META_STORE]: {} };
554
+ for (const table of schema.values()) stores[table.name] = { indexes: table.indexes.map((columns) => ({
555
+ name: deriveIndexName(columns),
556
+ path: columns.length === 1 ? columns[0] : [...columns]
557
+ })) };
558
+ return stores;
559
+ }
560
+ async #reopen() {
561
+ const database = createIndexedDBDatabase({
562
+ name: this.#name,
563
+ stores: this.#stores(this.#schema)
564
+ });
565
+ await database.connect();
566
+ this.#database = database;
567
+ }
568
+ async #candidates(store, schema, plan) {
569
+ if (plan.index === null) return store.records(plan.range);
570
+ const rows = [...await store.index(plan.index).records(plan.range)];
571
+ rows.sort((left, right) => compareValues(extractKey(left, schema.primary), extractKey(right, schema.primary)));
572
+ return rows;
573
+ }
574
+ #table(name) {
575
+ const schema = this.#schema.get(name);
576
+ if (schema === void 0) throw new DatabaseError("NOT_FOUND", `table '${name}' is not declared`, { table: name });
577
+ return schema;
578
+ }
579
+ #applySteps(schema, steps) {
580
+ const sameIndex = (left, right) => left.length === right.length && left.every((column, position) => column === right[position]);
581
+ for (const step of steps) switch (step.operation) {
582
+ case "table.add":
583
+ if (!schema.has(step.table.name)) schema.set(step.table.name, step.table);
584
+ break;
585
+ case "table.remove":
586
+ schema.delete(step.table);
587
+ break;
588
+ case "column.add": {
589
+ const table = schema.get(step.table);
590
+ if (table !== void 0 && !table.columns.some((column) => column.name === step.column.name)) schema.set(step.table, {
591
+ ...table,
592
+ columns: [...table.columns, step.column]
593
+ });
594
+ break;
595
+ }
596
+ case "column.remove": {
597
+ const table = schema.get(step.table);
598
+ if (table !== void 0) schema.set(step.table, {
599
+ ...table,
600
+ columns: table.columns.filter((column) => column.name !== step.column)
601
+ });
602
+ break;
603
+ }
604
+ case "index.add": {
605
+ const table = schema.get(step.table);
606
+ if (table !== void 0) schema.set(step.table, {
607
+ ...table,
608
+ indexes: [...table.indexes, step.index]
609
+ });
610
+ break;
611
+ }
612
+ case "index.remove": {
613
+ const table = schema.get(step.table);
614
+ if (table !== void 0) schema.set(step.table, {
615
+ ...table,
616
+ indexes: table.indexes.filter((index) => !sameIndex(index, step.index))
617
+ });
618
+ break;
619
+ }
620
+ }
621
+ }
622
+ async #upgrade(context, steps) {
623
+ for (const step of steps) switch (step.operation) {
624
+ case "table.remove":
625
+ context.drop(step.table);
626
+ break;
627
+ case "index.add": {
628
+ const name = deriveIndexName(step.index);
629
+ const path = step.index.length === 1 ? step.index[0] : [...step.index];
630
+ context.transaction.objectStore(step.table).createIndex(name, path);
631
+ break;
632
+ }
633
+ case "index.remove":
634
+ context.transaction.objectStore(step.table).deleteIndex(deriveIndexName(step.index));
635
+ break;
636
+ case "column.remove": {
637
+ let cursor = await context.store(step.table).cursor();
638
+ while (cursor !== null) {
639
+ const [migrated] = migrateRows([cursor.value], [step]);
640
+ await cursor.update(migrated);
641
+ cursor = await cursor.continue();
642
+ }
643
+ break;
644
+ }
645
+ case "table.add":
646
+ case "column.add": break;
647
+ }
648
+ }
649
+ };
650
+ //#endregion
651
+ //#region src/browser/factories.ts
652
+ /**
653
+ * Create a persistent IndexedDB {@link DriverInterface} for the core database layer.
654
+ *
655
+ * @remarks
656
+ * Pass it to `createDatabase` from `@orkestrel/database` to run the whole typed database +
657
+ * relations stack against IndexedDB instead of memory — the `Database` / `Table` /
658
+ * `Query` / relations API is unchanged; only where the bytes live changes. The
659
+ * driver is built on the published `@orkestrel/indexeddb` wrapper in auto-managed
660
+ * mode, so a table added to the `tables` map is created on the next open with no
661
+ * version bump. This unit omits `transaction` / `aggregate` (see
662
+ * {@link IndexedDBDriver} `@remarks`).
663
+ *
664
+ * @param name - The IndexedDB database name to open or create
665
+ * @returns A {@link DriverInterface} backed by IndexedDB
666
+ *
667
+ * @example
668
+ * ```ts
669
+ * import { createDatabase } from '@orkestrel/database'
670
+ * import { stringShape } from '@orkestrel/contract'
671
+ * import { createIndexedDBDriver } from '@orkestrel/database/browser'
672
+ *
673
+ * const db = createDatabase({
674
+ * driver: createIndexedDBDriver('app'),
675
+ * tables: { users: { id: stringShape(), name: stringShape() } },
676
+ * })
677
+ * await db.table('users').set({ id: 'u1', name: 'Ada' }) // persisted to IndexedDB
678
+ * ```
679
+ */
680
+ function createIndexedDBDriver(name) {
681
+ return new IndexedDBDriver(name);
682
+ }
683
+ //#endregion
684
+ export { INDEXABLE_TYPES, IndexedDBDriver, META_STORE, conditionRange, createIndexedDBDriver, deriveIndexName, isKey, mapIndexedDBError, mapMigrationError, selectPlan };
685
+
686
+ //# sourceMappingURL=index.js.map