@orkestrel/database 0.0.5 → 0.0.7

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,17 +1,18 @@
1
- import { DatabaseError, applyCriteria, compareValues, extractKey, isDriverMeta, matchesCriteria, migrateRows } from "../core/index.js";
2
- import { createIndexedDBDatabase, isIndexedDBError, range } from "@orkestrel/indexeddb";
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";
3
3
  //#region src/browser/constants.ts
4
- var INDEXABLE_TYPES = /* @__PURE__ */ new Set([
4
+ var INDEXABLE_STORAGE = /* @__PURE__ */ new Set([
5
5
  "text",
6
6
  "integer",
7
7
  "real"
8
8
  ]);
9
- var META_STORE = "__meta__";
9
+ var METADATA_STORE = "__metadata__";
10
10
  //#endregion
11
11
  //#region src/browser/helpers.ts
12
12
  /**
13
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`.
14
+ * of the six exact key comparisons over scalar operands; otherwise
15
+ * `undefined`.
15
16
  *
16
17
  * @remarks
17
18
  * Only the comparison operators (`equals`/`above`/`below`/`from`/`to`/`between`)
@@ -26,7 +27,7 @@ var META_STORE = "__meta__";
26
27
  * boolean) that is not a usable key. `between` additionally guards against a
27
28
  * REVERSED pair (`first > second`): native `IDBKeyRange.bound` throws a raw
28
29
  * `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
+ * reversed pair returns `undefined` here (falls back to a full scan, which the
30
31
  * engine then correctly resolves to an empty result) rather than letting a
31
32
  * native exception escape untyped — the same defensive posture as every other
32
33
  * backend, which returns empty for a reversed/empty range instead of throwing.
@@ -35,18 +36,18 @@ var META_STORE = "__meta__";
35
36
  * to a (possibly lossy) range.
36
37
  *
37
38
  * @param condition - The condition to translate
38
- * @returns Its exact key range, or `null` when the operator/operands cannot push
39
+ * @returns Its exact key range, or `undefined` when the operator/operands cannot push
39
40
  */
40
- function conditionRange(condition) {
41
+ function conditionToRange(condition) {
41
42
  const first = condition.values[0];
42
43
  const second = condition.values[1];
43
44
  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;
45
+ case "equals": return isKey(first) ? rangeExactKey(first) : void 0;
46
+ case "above": return isKey(first) ? rangeAboveKey(first) : void 0;
47
+ case "below": return isKey(first) ? rangeBelowKey(first) : void 0;
48
+ case "from": return isKey(first) ? rangeFromKey(first) : void 0;
49
+ 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;
50
51
  case "not":
51
52
  case "like":
52
53
  case "glob":
@@ -55,14 +56,11 @@ function conditionRange(condition) {
55
56
  case "any":
56
57
  case "none":
57
58
  case "absent":
58
- case "present": return null;
59
+ case "present": return;
59
60
  }
60
61
  }
61
- function isKey(value) {
62
- return typeof value === "string" || typeof value === "number";
63
- }
64
62
  /**
65
- * Plan an IndexedDB read for a {@link Criteria} — pick the index (or the primary
63
+ * Plan an IndexedDB read for a {@link QueryInput} — pick the index (or the primary
66
64
  * store) and {@link IDBKeyRange} to narrow by, falling back to a full scan.
67
65
  *
68
66
  * @remarks
@@ -72,9 +70,9 @@ function isKey(value) {
72
70
  * (a row can match through a later condition the range would exclude), so any `or`
73
71
  * forces a full scan. Otherwise it scans the conditions in order and selects the
74
72
  * **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
73
+ * operator (`conditionToRange`) over a single, orderable (`text`/`integer`/`real`)
74
+ * column that is either the table's primary key (read the store directly with
75
+ * `index` omitted) or has a single-column secondary index (named exactly the column — read
78
76
  * that index). A condition whose column is a nested {@link FieldPath} array
79
77
  * (descends a json value, not a key), is absent from the schema, is a non-orderable
80
78
  * type (`boolean`/`json`/`blob`), uses a non-comparison operator, or has a
@@ -100,19 +98,19 @@ function isKey(value) {
100
98
  * in the total order, so an absent/null-valued row can never satisfy them — the
101
99
  * index's silence on such a row is harmless (it was never going to match).
102
100
  * **Declared-type trust caveat:** this reasoning holds under the contract that
103
- * an {@link INDEXABLE_TYPES} column, once contract-validated at write time,
101
+ * an {@link INDEXABLE_STORAGE} column, once contract-validated at write time,
104
102
  * holds only `string | number | null` (or is absent) — never some other
105
103
  * runtime value that could rank differently; a driver bypassing the write
106
104
  * contract (writing raw rows directly to the store) could defeat this
107
105
  * argument, but that is out of scope for a planner reading validated schema
108
106
  * metadata.
109
107
  *
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
108
+ * When no condition qualifies the plan is a full scan (`{}`) and the engine
109
+ * does everything. The plan is always a SUPERSET of the
112
110
  * matching rows — the only correctness contract — so the driver may safely run
113
111
  * the exact engine over it.
114
112
  *
115
- * @param criteria - The read specification (its `conditions` drive the plan), or
113
+ * @param input - The read specification (its `conditions` drive the plan), or
116
114
  * `undefined` for an unconditional read
117
115
  * @param schema - The table's schema — its `primary` key and column types
118
116
  * @param available - The secondary-index names that physically exist on the store
@@ -121,37 +119,28 @@ function isKey(value) {
121
119
  *
122
120
  * @example
123
121
  * ```ts
124
- * selectPlan({ conditions: [eq('id', 'u1')] }, schema, []) // { index: null, range: only('u1') }
122
+ * selectPlan({ conditions: [eq('id', 'u1')] }, schema, []) // { range: only('u1') }
125
123
  * selectPlan({ conditions: [from('age', 18)] }, schema, ['age']) // { index: 'age', range: from(18) }
126
- * selectPlan({ conditions: [contains('name', 'a')] }, schema, []) // { index: null, range: null }
124
+ * selectPlan({ conditions: [contains('name', 'a')] }, schema, []) // {}
127
125
  * ```
128
126
  */
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
- };
127
+ function selectPlan(input, schema, available) {
128
+ const conditions = input?.conditions ?? [];
129
+ if (conditions.slice(1).some((condition) => condition.connector === "or")) return {};
135
130
  for (const condition of conditions) {
136
131
  if (typeof condition.column !== "string") continue;
137
132
  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
- };
133
+ if (column === void 0 || !INDEXABLE_STORAGE.has(column.storage)) continue;
134
+ const range = conditionToRange(condition);
135
+ if (range === void 0) continue;
136
+ if (condition.column === schema.primary) return { range };
145
137
  if (condition.operator === "below" || condition.operator === "to") continue;
146
138
  if (available.includes(condition.column)) return {
147
139
  index: condition.column,
148
- range: keyRange
140
+ range
149
141
  };
150
142
  }
151
- return {
152
- index: null,
153
- range: null
154
- };
143
+ return {};
155
144
  }
156
145
  /**
157
146
  * Map a backend {@link IndexedDBError} to the portable `DatabaseError` taxonomy
@@ -162,17 +151,17 @@ function selectPlan(criteria, schema, available) {
162
151
  * `CONSTRAINT` (a unique-key violation) is a `CONFLICT` — the same code every
163
152
  * other backend uses for a duplicate key. `CLOSED`/`NOT_OPEN`/`INVALID` (the
164
153
  * 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.
154
+ * `CLOSED`. `QUOTA` is a genuine infrastructure fault (`DRIVER`) carrying a
155
+ * machine-readable `context.code` so a caller can branch without parsing the
156
+ * message. A blocked open or versionchange is nonterminal in the backend and
157
+ * remains pending until the competing connection closes, so it never reaches
158
+ * this error mapper. Every other code (`UPGRADE` here — see
159
+ * {@link mapMigrationError} for the `migrate()`-only remapping to `MIGRATION` —
160
+ * `ABORTED`, `NOT_FOUND`, `DATA`, `OPEN`, `INACTIVE`, `READONLY`, `UNKNOWN`) is
161
+ * an unexpected infrastructure fault and maps to `DRIVER` — the driver opens
162
+ * its own readwrite transactions, so a `READONLY` fault can only mean the
163
+ * backend behaved unexpectedly. The original error is always preserved as
164
+ * `context.cause` for diagnostics.
176
165
  *
177
166
  * @param error - The backend error to translate
178
167
  * @returns The portable `DatabaseError`
@@ -187,11 +176,6 @@ function mapIndexedDBError(error) {
187
176
  cause: error,
188
177
  code: "QUOTA"
189
178
  });
190
- case "BLOCKED": return new DatabaseError("DRIVER", error.message, {
191
- cause: error,
192
- code: "BLOCKED",
193
- retryable: true
194
- });
195
179
  case "UPGRADE":
196
180
  case "ABORTED":
197
181
  case "NOT_FOUND":
@@ -246,13 +230,29 @@ function mapMigrationError(error) {
246
230
  *
247
231
  * @example
248
232
  * ```ts
249
- * deriveIndexName(['age']) // 'age'
250
- * deriveIndexName(['a', 'b']) // '2#1:a1:b'
233
+ * deriveIndexedDBIndexName(['age']) // 'age'
234
+ * deriveIndexedDBIndexName(['a', 'b']) // '2#1:a1:b'
251
235
  * ```
252
236
  */
253
- function deriveIndexName(columns) {
254
- if (columns.length === 1) return columns[0];
255
- return `${columns.length}#${columns.map((column) => `${column.length}:${column}`).join("")}`;
237
+ function deriveIndexedDBIndexName(columns) {
238
+ const [column] = columns;
239
+ if (columns.length === 1 && column !== void 0) return column;
240
+ return `${columns.length}#${columns.map((part) => `${part.length}:${part}`).join("")}`;
241
+ }
242
+ /**
243
+ * Project a table schema into the IndexedDB wrapper's store definition.
244
+ *
245
+ * @param schema - Portable table schema
246
+ * @returns Store definition with declared indexes
247
+ */
248
+ function schemaToStore(schema) {
249
+ return { indexes: schema.indexes.map((columns) => {
250
+ const [column] = columns;
251
+ return {
252
+ name: deriveIndexedDBIndexName(columns),
253
+ path: columns.length === 1 && column !== void 0 ? column : [...columns]
254
+ };
255
+ }) };
256
256
  }
257
257
  //#endregion
258
258
  //#region src/browser/drivers/IndexedDBDriver.ts
@@ -266,25 +266,27 @@ function deriveIndexName(columns) {
266
266
  * / `snapshot`) by delegating to the wrapper's typed store operations — it never
267
267
  * touches raw IndexedDB. Rows are stored with **out-of-line keys** (the database
268
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.
269
+ * key-path-less store. A fresh database opens in **auto-managed** mode (no fixed
270
+ * version), creating missing declared stores on demand. Once metadata is persisted,
271
+ * bootstrap captures the live stores and version, rejects a missing persisted store,
272
+ * and pins the final open to that version so a competing versionchange cannot
273
+ * silently recreate lost storage. The driver's bulk reads (`scan` / `keys`) use the
274
+ * wrapper's native `getAll` / `getAllKeys`, and `snapshot` rolls back through one
275
+ * atomic wrapper transaction.
274
276
  *
275
- * It also implements the optional native `records` / `count` / `stream` hooks
276
- * (AGENTS §21): `selectPlan` ({@link selectPlan}) turns the {@link Criteria} into a
277
+ * It also implements the optional native `records` / `stream` hooks
278
+ * (AGENTS §21): `selectPlan` ({@link selectPlan}) turns the {@link QueryInput} into a
277
279
  * 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
+ * fetching a candidate **superset** that the core engine (`applyQuery` /
281
+ * `matchesQuery`) then refines — so a native read is byte-identical to a full
280
282
  * scan, just cheaper. Pushdown is conservative: only the exact-comparison
281
283
  * operators over orderable columns narrow to a range; everything else falls back
282
284
  * to a full scan + the engine.
283
285
  *
284
286
  * @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`
287
+ * This driver also implements `migrate` / `metadata` / `stamp`. `metadata` / `stamp`
288
+ * persist the {@link DriverMetadata} in a reserved out-of-line store,
289
+ * {@link METADATA_STORE} (`__metadata__`) — excluded from a whole-store `snapshot`
288
290
  * capture, since it is driver bookkeeping, not caller data. `migrate` applies a
289
291
  * {@link Migration} plan natively: IndexedDB schema DDL (creating/dropping a
290
292
  * store, creating/dropping an index) is legal only inside a versionchange
@@ -301,32 +303,67 @@ function deriveIndexName(columns) {
301
303
  * This unit deliberately OMITS `aggregate` / `transaction`. There is no native
302
304
  * `aggregate` (IndexedDB has no native SUM/AVG); the engine over the narrowed
303
305
  * `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
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
307
309
  * (`snapshot`'s rollback) instead runs entirely inside ONE `db.write(...)` scope.
308
310
  */
309
311
  var IndexedDBDriver = class {
310
312
  #name;
313
+ #identities = /* @__PURE__ */ new Map();
311
314
  #schema = /* @__PURE__ */ new Map();
312
315
  #database;
313
316
  constructor(name) {
314
317
  this.#name = name;
315
318
  }
316
319
  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 });
320
+ const owned = normalizeDriverSchema(schema);
321
+ if (owned.some((table) => table.name === "__metadata__")) throw new DatabaseError("VALIDATION", `open: table name '${METADATA_STORE}' is reserved for driver metadata`, { table: METADATA_STORE });
322
+ this.#database?.close();
323
+ this.#database = void 0;
324
+ this.#schema = /* @__PURE__ */ new Map();
318
325
  try {
319
- this.#database?.close();
326
+ const bootstrap = createIndexedDBDatabase({
327
+ name: this.#name,
328
+ stores: { [METADATA_STORE]: {} }
329
+ });
330
+ let persisted;
331
+ let stores = [];
332
+ let version = 0;
333
+ try {
334
+ await bootstrap.connect();
335
+ stores = bootstrap.stores;
336
+ version = bootstrap.version;
337
+ persisted = await this.#load(bootstrap);
338
+ } finally {
339
+ bootstrap.close();
340
+ }
341
+ if (persisted !== void 0) {
342
+ for (const table of persisted.schema) if (!stores.includes(table.name)) throw new DatabaseError("DRIVER", "Stored IndexedDB store is missing", {
343
+ name: this.#name,
344
+ store: table.name,
345
+ aspect: "missing"
346
+ });
347
+ }
320
348
  const map = /* @__PURE__ */ new Map();
321
- for (const table of schema) map.set(table.name, table);
349
+ for (const table of normalizeDriverSchema(persisted?.schema ?? owned)) map.set(table.name, table);
322
350
  const database = createIndexedDBDatabase({
323
351
  name: this.#name,
352
+ ...persisted === void 0 ? {} : { version },
324
353
  stores: this.#stores(map)
325
354
  });
326
- await database.connect();
327
- this.#database = database;
355
+ try {
356
+ await database.connect();
357
+ } catch (error) {
358
+ database.close();
359
+ throw error;
360
+ }
361
+ const identities = this.#alignIdentities(map);
328
362
  this.#schema = map;
363
+ this.#identities = identities;
364
+ this.#database = database;
329
365
  } catch (error) {
366
+ this.#identities = /* @__PURE__ */ new Map();
330
367
  throw this.#wrap(error);
331
368
  }
332
369
  }
@@ -341,26 +378,29 @@ var IndexedDBDriver = class {
341
378
  throw this.#wrap(error);
342
379
  }
343
380
  }
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
- }
381
+ async write(table, key, row, options) {
382
+ const bound = bindRowKey(row, this.#table(table).primary, key);
383
+ await this.#mutate(table, options, async (store) => {
384
+ await store.set(bound, key);
385
+ });
350
386
  }
351
- async delete(table, key) {
352
- try {
353
- const store = this.#store(table);
354
- const present = await store.has(key);
387
+ async insert(table, key, row, options) {
388
+ const bound = bindRowKey(row, this.#table(table).primary, key);
389
+ await this.#mutate(table, options, async (store) => {
390
+ await store.add(bound, key);
391
+ });
392
+ }
393
+ async delete(table, key, options) {
394
+ let present = false;
395
+ await this.#mutate(table, options, async (store) => {
396
+ present = await store.has(key);
355
397
  await store.remove(key);
356
- return present;
357
- } catch (error) {
358
- throw this.#wrap(error);
359
- }
398
+ });
399
+ return present;
360
400
  }
361
401
  async keys(table) {
362
402
  try {
363
- return (await this.#store(table).keys()).filter((key) => typeof key === "string" || typeof key === "number");
403
+ return (await this.#store(table).keys()).filter(isKey);
364
404
  } catch (error) {
365
405
  throw this.#wrap(error);
366
406
  }
@@ -379,42 +419,34 @@ var IndexedDBDriver = class {
379
419
  throw this.#wrap(error);
380
420
  }
381
421
  }
382
- async records(table, criteria) {
422
+ async records(table, input) {
423
+ validatePage(input);
383
424
  try {
384
425
  const schema = this.#table(table);
385
426
  const store = this.#store(table);
386
- const plan = selectPlan(criteria, schema, store.indexes);
387
- return applyCriteria(await this.#candidates(store, schema, plan), criteria);
427
+ const plan = selectPlan(input, schema, store.indexes);
428
+ return applyQuery(await this.#candidates(store, schema, plan), input);
388
429
  } catch (error) {
389
430
  throw this.#wrap(error);
390
431
  }
391
432
  }
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
- }
433
+ stream(table, input) {
434
+ validatePage(input);
435
+ return this.#stream(table, input);
404
436
  }
405
- async *stream(table, criteria) {
437
+ async *#stream(table, input) {
406
438
  try {
407
439
  const schema = this.#table(table);
408
440
  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;
441
+ const plan = selectPlan(input, schema, store.indexes);
442
+ const conditions = input.conditions ?? [];
443
+ const offset = input.offset ?? 0;
444
+ const limit = input.limit;
413
445
  let skipped = 0;
414
446
  let yielded = 0;
415
447
  for (const row of await this.#candidates(store, schema, plan)) {
416
448
  if (limit !== void 0 && yielded >= limit) break;
417
- if (!matchesCriteria(row, conditions)) continue;
449
+ if (!matchesQuery(row, conditions)) continue;
418
450
  if (skipped < offset) {
419
451
  skipped += 1;
420
452
  continue;
@@ -429,29 +461,63 @@ var IndexedDBDriver = class {
429
461
  async snapshot(tables) {
430
462
  try {
431
463
  const database = this.#require();
432
- const names = tables ?? database.stores.filter((name) => name !== "__meta__");
464
+ const requested = tables ?? [...this.#schema.keys()];
465
+ const names = [...new Set(requested)].filter((name) => name !== "__metadata__" && this.#schema.has(name) && database.stores.includes(name));
433
466
  const captured = /* @__PURE__ */ new Map();
434
467
  if (names.length > 0) await database.read(names, async (transaction) => {
435
468
  for (const name of names) {
469
+ const schema = this.#schema.get(name);
470
+ const identity = this.#identities.get(name);
471
+ if (schema === void 0 || identity === void 0) continue;
436
472
  const store = transaction.store(name);
437
473
  captured.set(name, {
474
+ identity,
438
475
  keys: await store.keys(),
439
- rows: await store.records()
476
+ rows: await store.records(),
477
+ schema
440
478
  });
441
479
  }
442
480
  });
443
481
  return async () => {
444
482
  try {
445
483
  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;
484
+ const replacements = /* @__PURE__ */ new Map();
485
+ for (const [name, snapshot] of captured) {
486
+ const schema = this.#schema.get(name);
487
+ if (schema === void 0 || this.#identities.get(name) !== snapshot.identity || !current.stores.includes(name)) continue;
488
+ const plan = planMigration([snapshot.schema], [schema]);
489
+ const migrated = migrateRows(snapshot.rows, plan.steps);
490
+ if (snapshot.keys.length !== snapshot.rows.length || migrated.length !== snapshot.rows.length) throw new DatabaseError("MIGRATION", "IndexedDB snapshot keys and rows have different cardinality", { table: name });
491
+ const keys = [];
492
+ const rows = [];
493
+ for (const [index, key] of snapshot.keys.entries()) {
494
+ const row = migrated[index];
495
+ if (!isKey(key) || row === void 0) throw new DatabaseError("MIGRATION", "IndexedDB snapshot row has no usable primary key", {
496
+ table: name,
497
+ column: schema.primary,
498
+ index
499
+ });
500
+ keys.push(key);
501
+ rows.push(bindRowKey(row, schema.primary, key));
502
+ }
503
+ replacements.set(name, {
504
+ keys,
505
+ rows
506
+ });
507
+ }
508
+ if (replacements.size === 0) return;
509
+ await current.write([...replacements.keys()], async (transaction) => {
510
+ for (const [name, replacement] of replacements) {
452
511
  const store = transaction.store(name);
453
512
  await store.clear();
454
- for (let index = 0; index < snapshot.keys.length; index += 1) await store.set(snapshot.rows[index], snapshot.keys[index]);
513
+ for (const [index, key] of replacement.keys.entries()) {
514
+ const row = replacement.rows[index];
515
+ if (row === void 0 || key === void 0) throw new DatabaseError("DRIVER", "IndexedDB snapshot entry is incomplete", {
516
+ table: name,
517
+ index
518
+ });
519
+ await store.set(row, key);
520
+ }
455
521
  }
456
522
  });
457
523
  } catch (error) {
@@ -463,34 +529,37 @@ var IndexedDBDriver = class {
463
529
  }
464
530
  }
465
531
  /**
466
- * Return the persisted {@link DriverMeta}, or `undefined` when the store has
532
+ * Return the persisted {@link DriverMetadata}, or `undefined` when the store has
467
533
  * never been stamped.
468
534
  *
469
535
  * @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.
536
+ * Reads `'metadata'` from the reserved {@link METADATA_STORE} in one readonly
537
+ * transaction that distinguishes key absence from a present `undefined`
538
+ * value. Only absence returns `undefined`; present malformed state fails
539
+ * closed with a payload-safe `DRIVER` error.
474
540
  *
475
- * @returns The last-stamped {@link DriverMeta}, or `undefined`
541
+ * @returns The last-stamped {@link DriverMetadata}, or `undefined`
476
542
  */
477
- async meta() {
543
+ async metadata() {
478
544
  try {
479
- const record = await this.#require().store(META_STORE).get("meta");
480
- if (!isDriverMeta(record)) return void 0;
481
- return record;
545
+ return await this.#load(this.#require());
482
546
  } catch (error) {
483
547
  throw this.#wrap(error);
484
548
  }
485
549
  }
486
550
  /**
487
- * Persist `meta` verbatim for a later `meta()` to return.
551
+ * Persist an owned metadata snapshot for a later `metadata()` to return.
488
552
  *
489
- * @param meta - The {@link DriverMeta} to persist
553
+ * @param metadata - The {@link DriverMetadata} to persist
490
554
  */
491
- async stamp(meta) {
555
+ async stamp(metadata) {
556
+ const database = this.#require();
557
+ const owned = cloneDriverMetadata(metadata);
492
558
  try {
493
- await this.#require().store(META_STORE).set({ ...meta }, "meta");
559
+ await database.store(METADATA_STORE).set({
560
+ version: owned.version,
561
+ schema: owned.schema
562
+ }, "metadata");
494
563
  } catch (error) {
495
564
  throw this.#wrap(error);
496
565
  }
@@ -502,7 +571,7 @@ var IndexedDBDriver = class {
502
571
  * @remarks
503
572
  * IndexedDB schema DDL is legal only inside `onupgradeneeded`, so this closes
504
573
  * 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,
574
+ * every currently-known store (plus {@link METADATA_STORE}) so nothing is lost,
506
575
  * and applying `table.remove` / `index.add` / `index.remove` /
507
576
  * `column.remove` inside `upgrade`. Every step's `table` is validated against
508
577
  * the driver's own `#schema` BEFORE the reconnect — an unknown-table step
@@ -515,46 +584,154 @@ var IndexedDBDriver = class {
515
584
  * `open` tracks, so subsequent pushdown planning and a later `migrate` /
516
585
  * `open` see the new shape.
517
586
  *
518
- * @param plan - The migration plan to apply
587
+ * @param input - The migration plan and optional metadata stamp to apply atomically
519
588
  */
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 });
589
+ async migrate(input) {
522
590
  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();
591
+ const owned = cloneMigrationInput(input);
592
+ const projected = projectMigrationSchema([...this.#schema.values()], owned.plan.steps);
593
+ if (owned.metadata !== void 0 && !equalsValue(normalizeDriverSchema(owned.metadata.schema), projected)) throw new DatabaseError("MIGRATION", "Migration metadata schema does not match the plan", {
594
+ projected,
595
+ metadata: owned.metadata.schema
596
+ });
597
+ const schema = new Map(projected.map((table) => [table.name, table]));
598
+ const identities = this.#projectIdentities(this.#identities, owned.plan.steps);
599
+ if (owned.plan.steps.length === 0) {
600
+ const metadata = owned.metadata;
601
+ if (metadata !== void 0) try {
602
+ await current.write(METADATA_STORE, async (transaction) => {
603
+ await transaction.store(METADATA_STORE).set({
604
+ version: metadata.version,
605
+ schema: metadata.schema
606
+ }, "metadata");
607
+ });
608
+ } catch (error) {
609
+ throw this.#wrap(error);
610
+ }
611
+ return;
612
+ }
527
613
  try {
614
+ await current.connect();
615
+ const version = current.version;
616
+ current.close();
617
+ this.#database = void 0;
618
+ const added = new Set([...schema.keys()].filter((name) => !this.#schema.has(name)));
528
619
  const database = createIndexedDBDatabase({
529
620
  name: this.#name,
530
621
  version: version + 1,
531
622
  stores: this.#stores(schema),
532
- upgrade: (context) => this.#upgrade(context, plan.steps)
623
+ upgrade: this.#upgrade.bind(this, owned, added)
533
624
  });
534
- await database.connect();
625
+ try {
626
+ await database.connect();
627
+ } catch (error) {
628
+ database.close();
629
+ throw error;
630
+ }
535
631
  this.#database = database;
536
632
  this.#schema = schema;
633
+ this.#identities = identities;
537
634
  } catch (error) {
538
- await this.#reopen();
539
- throw isIndexedDBError(error) ? mapMigrationError(error) : error;
635
+ current.close();
636
+ this.#database = void 0;
637
+ const cause = this.#migrationError(error);
638
+ try {
639
+ await this.#reopen();
640
+ } catch (recoveryError) {
641
+ this.#database = void 0;
642
+ throw new DatabaseError("DRIVER", "IndexedDB migration and recovery failed", {
643
+ cause,
644
+ recovery: this.#recoveryError(recoveryError)
645
+ });
646
+ }
647
+ throw cause;
648
+ }
649
+ }
650
+ async #mutate(table, options, scope) {
651
+ const signal = options?.signal;
652
+ checkAbort(signal);
653
+ const cleanup = new AbortController();
654
+ let aborted = false;
655
+ try {
656
+ await this.#require().write(table, async (transaction) => {
657
+ checkAbort(signal);
658
+ signal?.addEventListener("abort", () => {
659
+ if (!transaction.active) return;
660
+ try {
661
+ transaction.abort();
662
+ aborted = true;
663
+ } catch {}
664
+ }, {
665
+ once: true,
666
+ signal: cleanup.signal
667
+ });
668
+ checkAbort(signal);
669
+ await scope(transaction.store(table));
670
+ });
671
+ } catch (error) {
672
+ if (aborted) checkAbort(signal);
673
+ throw this.#wrap(error);
674
+ } finally {
675
+ cleanup.abort();
540
676
  }
541
677
  }
542
678
  #require() {
543
679
  if (this.#database === void 0) throw new DatabaseError("CLOSED", `IndexedDB database '${this.#name}' is not open`, { name: this.#name });
544
680
  return this.#database;
545
681
  }
682
+ async #load(database) {
683
+ let present = false;
684
+ let value;
685
+ await database.read(METADATA_STORE, async (transaction) => {
686
+ const store = transaction.store(METADATA_STORE);
687
+ present = await store.has("metadata");
688
+ if (present) value = await store.get("metadata");
689
+ });
690
+ if (!present) return void 0;
691
+ try {
692
+ return cloneDriverMetadata(value);
693
+ } catch {
694
+ const cause = new DatabaseError("VALIDATION", "Stored IndexedDB metadata failed validation", { path: "metadata" });
695
+ throw new DatabaseError("DRIVER", "Stored IndexedDB metadata is invalid", {
696
+ name: this.#name,
697
+ store: METADATA_STORE,
698
+ key: "metadata",
699
+ cause
700
+ });
701
+ }
702
+ }
703
+ #migrationError(error) {
704
+ if (isDatabaseError(error)) return error;
705
+ if (isIndexedDBError(error)) return mapMigrationError(error);
706
+ return new DatabaseError("DRIVER", "IndexedDB migration failed", { cause: error });
707
+ }
708
+ #recoveryError(error) {
709
+ if (isDatabaseError(error)) return error;
710
+ if (isIndexedDBError(error)) return mapIndexedDBError(error);
711
+ return new DatabaseError("DRIVER", "IndexedDB recovery failed", { cause: error });
712
+ }
546
713
  #wrap(error) {
547
714
  return isIndexedDBError(error) ? mapIndexedDBError(error) : error;
548
715
  }
549
716
  #store(table) {
550
717
  return this.#require().store(table);
551
718
  }
719
+ #alignIdentities(schema) {
720
+ const aligned = /* @__PURE__ */ new Map();
721
+ for (const table of schema.values()) aligned.set(table.name, this.#identities.get(table.name) ?? {});
722
+ return aligned;
723
+ }
724
+ #projectIdentities(identities, steps) {
725
+ const projected = new Map(identities);
726
+ for (const step of steps) {
727
+ if (step.operation === "table.add") projected.set(step.table.name, {});
728
+ if (step.operation === "table.remove") projected.delete(step.table);
729
+ }
730
+ return projected;
731
+ }
552
732
  #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
- })) };
733
+ const stores = { [METADATA_STORE]: {} };
734
+ for (const table of schema.values()) stores[table.name] = schemaToStore(table);
558
735
  return stores;
559
736
  }
560
737
  async #reopen() {
@@ -562,11 +739,16 @@ var IndexedDBDriver = class {
562
739
  name: this.#name,
563
740
  stores: this.#stores(this.#schema)
564
741
  });
565
- await database.connect();
566
- this.#database = database;
742
+ try {
743
+ await database.connect();
744
+ this.#database = database;
745
+ } catch (error) {
746
+ database.close();
747
+ throw error;
748
+ }
567
749
  }
568
750
  async #candidates(store, schema, plan) {
569
- if (plan.index === null) return store.records(plan.range);
751
+ if (plan.index === void 0) return store.records(plan.range);
570
752
  const rows = [...await store.index(plan.index).records(plan.range)];
571
753
  rows.sort((left, right) => compareValues(extractKey(left, schema.primary), extractKey(right, schema.primary)));
572
754
  return rows;
@@ -576,75 +758,43 @@ var IndexedDBDriver = class {
576
758
  if (schema === void 0) throw new DatabaseError("NOT_FOUND", `table '${name}' is not declared`, { table: name });
577
759
  return schema;
578
760
  }
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) {
761
+ async #upgrade(input, added, context) {
762
+ for (const name of added) if (name !== "__metadata__" && context.stores.includes(name)) context.drop(name);
763
+ for (const step of input.plan.steps) switch (step.operation) {
582
764
  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
- });
765
+ context.create(step.table.name, schemaToStore(step.table));
618
766
  break;
619
- }
620
- }
621
- }
622
- async #upgrade(context, steps) {
623
- for (const step of steps) switch (step.operation) {
624
767
  case "table.remove":
625
768
  context.drop(step.table);
626
769
  break;
627
770
  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);
771
+ const name = deriveIndexedDBIndexName(step.index);
772
+ const [column] = step.index;
773
+ const path = step.index.length === 1 && column !== void 0 ? column : [...step.index];
774
+ context.index(step.table, {
775
+ name,
776
+ path
777
+ });
631
778
  break;
632
779
  }
633
780
  case "index.remove":
634
- context.transaction.objectStore(step.table).deleteIndex(deriveIndexName(step.index));
781
+ context.deindex(step.table, deriveIndexedDBIndexName(step.index));
635
782
  break;
636
783
  case "column.remove": {
637
784
  let cursor = await context.store(step.table).cursor();
638
785
  while (cursor !== null) {
639
786
  const [migrated] = migrateRows([cursor.value], [step]);
787
+ if (migrated === void 0) throw new DatabaseError("MIGRATION", "migrate: transformed row is missing", { table: step.table });
640
788
  await cursor.update(migrated);
641
789
  cursor = await cursor.continue();
642
790
  }
643
791
  break;
644
792
  }
645
- case "table.add":
646
- case "column.add": break;
647
793
  }
794
+ if (input.metadata !== void 0) await context.store(METADATA_STORE).set({
795
+ version: input.metadata.version,
796
+ schema: input.metadata.schema
797
+ }, "metadata");
648
798
  }
649
799
  };
650
800
  //#endregion
@@ -653,9 +803,9 @@ var IndexedDBDriver = class {
653
803
  * Create a persistent IndexedDB {@link DriverInterface} for the core database layer.
654
804
  *
655
805
  * @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
806
+ * Pass it to `createDatabase` from `@orkestrel/database` to run the typed database
807
+ * layer against IndexedDB instead of memory — the `Database` / `Table` / `Query`
808
+ * API is unchanged; only where the bytes live changes. The
659
809
  * driver is built on the published `@orkestrel/indexeddb` wrapper in auto-managed
660
810
  * mode, so a table added to the `tables` map is created on the next open with no
661
811
  * version bump. This unit omits `transaction` / `aggregate` (see
@@ -681,6 +831,6 @@ function createIndexedDBDriver(name) {
681
831
  return new IndexedDBDriver(name);
682
832
  }
683
833
  //#endregion
684
- export { INDEXABLE_TYPES, IndexedDBDriver, META_STORE, conditionRange, createIndexedDBDriver, deriveIndexName, isKey, mapIndexedDBError, mapMigrationError, selectPlan };
834
+ export { INDEXABLE_STORAGE, IndexedDBDriver, METADATA_STORE, conditionToRange, createIndexedDBDriver, deriveIndexedDBIndexName, mapIndexedDBError, mapMigrationError, schemaToStore, selectPlan };
685
835
 
686
836
  //# sourceMappingURL=index.js.map