@orkestrel/database 0.0.6 → 0.0.8

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, deepEqual, 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,15 +230,30 @@ 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) {
237
+ function deriveIndexedDBIndexName(columns) {
254
238
  const [column] = columns;
255
239
  if (columns.length === 1 && column !== void 0) return column;
256
240
  return `${columns.length}#${columns.map((part) => `${part.length}:${part}`).join("")}`;
257
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
+ }
258
257
  //#endregion
259
258
  //#region src/browser/drivers/IndexedDBDriver.ts
260
259
  /**
@@ -267,25 +266,27 @@ function deriveIndexName(columns) {
267
266
  * / `snapshot`) by delegating to the wrapper's typed store operations — it never
268
267
  * touches raw IndexedDB. Rows are stored with **out-of-line keys** (the database
269
268
  * passes the key explicitly, `store.set(row, key)`), so each table is declared as a
270
- * key-path-less store. The wrapper opens in **auto-managed** mode (no fixed
271
- * version), creating any missing store on demand, so a table added to the schema is
272
- * created on the next open with no manual version bump. The driver's bulk reads
273
- * (`scan` / `keys`) use the wrapper's native `getAll` / `getAllKeys`, and `snapshot`
274
- * 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.
275
276
  *
276
- * It also implements the optional native `records` / `count` / `stream` hooks
277
- * (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
278
279
  * key-range pushdown over the primary key or a single-column secondary index,
279
- * fetching a candidate **superset** that the core engine (`applyCriteria` /
280
- * `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
281
282
  * scan, just cheaper. Pushdown is conservative: only the exact-comparison
282
283
  * operators over orderable columns narrow to a range; everything else falls back
283
284
  * to a full scan + the engine.
284
285
  *
285
286
  * @remarks
286
- * This driver also implements `migrate` / `meta` / `stamp`. `meta` / `stamp`
287
- * persist the {@link DriverMeta} in a reserved out-of-line store,
288
- * {@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`
289
290
  * capture, since it is driver bookkeeping, not caller data. `migrate` applies a
290
291
  * {@link Migration} plan natively: IndexedDB schema DDL (creating/dropping a
291
292
  * store, creating/dropping an index) is legal only inside a versionchange
@@ -302,32 +303,67 @@ function deriveIndexName(columns) {
302
303
  * This unit deliberately OMITS `aggregate` / `transaction`. There is no native
303
304
  * `aggregate` (IndexedDB has no native SUM/AVG); the engine over the narrowed
304
305
  * `records` covers it. `transaction` is impossible here: the wrapper auto-commits
305
- * an `IDBTransaction` the moment control yields to a non-IDB `await`, so a
306
- * BEGIN-now / commit-or-rollback-later handle spanning arbitrary caller code
307
- * 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
308
309
  * (`snapshot`'s rollback) instead runs entirely inside ONE `db.write(...)` scope.
309
310
  */
310
311
  var IndexedDBDriver = class {
311
312
  #name;
313
+ #identities = /* @__PURE__ */ new Map();
312
314
  #schema = /* @__PURE__ */ new Map();
313
315
  #database;
314
316
  constructor(name) {
315
317
  this.#name = name;
316
318
  }
317
319
  async open(schema) {
318
- 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();
319
325
  try {
320
- 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
+ }
321
348
  const map = /* @__PURE__ */ new Map();
322
- for (const table of schema) map.set(table.name, table);
349
+ for (const table of normalizeDriverSchema(persisted?.schema ?? owned)) map.set(table.name, table);
323
350
  const database = createIndexedDBDatabase({
324
351
  name: this.#name,
352
+ ...persisted === void 0 ? {} : { version },
325
353
  stores: this.#stores(map)
326
354
  });
327
- await database.connect();
328
- 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);
329
362
  this.#schema = map;
363
+ this.#identities = identities;
364
+ this.#database = database;
330
365
  } catch (error) {
366
+ this.#identities = /* @__PURE__ */ new Map();
331
367
  throw this.#wrap(error);
332
368
  }
333
369
  }
@@ -342,26 +378,29 @@ var IndexedDBDriver = class {
342
378
  throw this.#wrap(error);
343
379
  }
344
380
  }
345
- async write(table, key, row) {
346
- try {
347
- await this.#store(table).set(row, key);
348
- } catch (error) {
349
- throw this.#wrap(error);
350
- }
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
+ });
351
386
  }
352
- async delete(table, key) {
353
- try {
354
- const store = this.#store(table);
355
- 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);
356
397
  await store.remove(key);
357
- return present;
358
- } catch (error) {
359
- throw this.#wrap(error);
360
- }
398
+ });
399
+ return present;
361
400
  }
362
401
  async keys(table) {
363
402
  try {
364
- return (await this.#store(table).keys()).filter((key) => typeof key === "string" || typeof key === "number");
403
+ return (await this.#store(table).keys()).filter(isKey);
365
404
  } catch (error) {
366
405
  throw this.#wrap(error);
367
406
  }
@@ -380,42 +419,34 @@ var IndexedDBDriver = class {
380
419
  throw this.#wrap(error);
381
420
  }
382
421
  }
383
- async records(table, criteria) {
422
+ async records(table, input) {
423
+ validatePage(input);
384
424
  try {
385
425
  const schema = this.#table(table);
386
426
  const store = this.#store(table);
387
- const plan = selectPlan(criteria, schema, store.indexes);
388
- 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);
389
429
  } catch (error) {
390
430
  throw this.#wrap(error);
391
431
  }
392
432
  }
393
- async count(table, criteria) {
394
- try {
395
- const schema = this.#table(table);
396
- const store = this.#store(table);
397
- const conditions = criteria.conditions ?? [];
398
- if (conditions.length === 0) return await store.count();
399
- const plan = selectPlan(criteria, schema, store.indexes);
400
- if (conditions.length === 1 && plan.range !== null) return plan.index === null ? await store.count(plan.range) : await store.index(plan.index).count(plan.range);
401
- 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);
402
- } catch (error) {
403
- throw this.#wrap(error);
404
- }
433
+ stream(table, input) {
434
+ validatePage(input);
435
+ return this.#stream(table, input);
405
436
  }
406
- async *stream(table, criteria) {
437
+ async *#stream(table, input) {
407
438
  try {
408
439
  const schema = this.#table(table);
409
440
  const store = this.#store(table);
410
- const plan = selectPlan(criteria, schema, store.indexes);
411
- const conditions = criteria.conditions ?? [];
412
- const offset = criteria.offset ?? 0;
413
- 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;
414
445
  let skipped = 0;
415
446
  let yielded = 0;
416
447
  for (const row of await this.#candidates(store, schema, plan)) {
417
448
  if (limit !== void 0 && yielded >= limit) break;
418
- if (!matchesCriteria(row, conditions)) continue;
449
+ if (!matchesQuery(row, conditions)) continue;
419
450
  if (skipped < offset) {
420
451
  skipped += 1;
421
452
  continue;
@@ -430,31 +461,57 @@ var IndexedDBDriver = class {
430
461
  async snapshot(tables) {
431
462
  try {
432
463
  const database = this.#require();
433
- 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));
434
466
  const captured = /* @__PURE__ */ new Map();
435
467
  if (names.length > 0) await database.read(names, async (transaction) => {
436
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;
437
472
  const store = transaction.store(name);
438
473
  captured.set(name, {
474
+ identity,
439
475
  keys: await store.keys(),
440
- rows: await store.records()
476
+ rows: await store.records(),
477
+ schema
441
478
  });
442
479
  }
443
480
  });
444
481
  return async () => {
445
482
  try {
446
483
  const current = this.#require();
447
- const restorable = names.filter((name) => current.stores.includes(name));
448
- if (restorable.length === 0) return;
449
- await current.write(restorable, async (transaction) => {
450
- for (const name of restorable) {
451
- const snapshot = captured.get(name);
452
- 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) {
453
511
  const store = transaction.store(name);
454
512
  await store.clear();
455
- for (let index = 0; index < snapshot.keys.length; index += 1) {
456
- const row = snapshot.rows[index];
457
- const key = snapshot.keys[index];
513
+ for (const [index, key] of replacement.keys.entries()) {
514
+ const row = replacement.rows[index];
458
515
  if (row === void 0 || key === void 0) throw new DatabaseError("DRIVER", "IndexedDB snapshot entry is incomplete", {
459
516
  table: name,
460
517
  index
@@ -472,34 +529,37 @@ var IndexedDBDriver = class {
472
529
  }
473
530
  }
474
531
  /**
475
- * Return the persisted {@link DriverMeta}, or `undefined` when the store has
532
+ * Return the persisted {@link DriverMetadata}, or `undefined` when the store has
476
533
  * never been stamped.
477
534
  *
478
535
  * @remarks
479
- * Reads `'meta'` from the reserved {@link META_STORE}, narrowing the
480
- * structured-clone value with the core {@link isDriverMeta} guard (never
481
- * asserted, AGENTS §14) a missing or malformed record returns `undefined`,
482
- * 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.
483
540
  *
484
- * @returns The last-stamped {@link DriverMeta}, or `undefined`
541
+ * @returns The last-stamped {@link DriverMetadata}, or `undefined`
485
542
  */
486
- async meta() {
543
+ async metadata() {
487
544
  try {
488
- const record = await this.#require().store(META_STORE).get("meta");
489
- if (!isDriverMeta(record)) return void 0;
490
- return record;
545
+ return await this.#load(this.#require());
491
546
  } catch (error) {
492
547
  throw this.#wrap(error);
493
548
  }
494
549
  }
495
550
  /**
496
- * Persist `meta` verbatim for a later `meta()` to return.
551
+ * Persist an owned metadata snapshot for a later `metadata()` to return.
497
552
  *
498
- * @param meta - The {@link DriverMeta} to persist
553
+ * @param metadata - The {@link DriverMetadata} to persist
499
554
  */
500
- async stamp(meta) {
555
+ async stamp(metadata) {
556
+ const database = this.#require();
557
+ const owned = cloneDriverMetadata(metadata);
501
558
  try {
502
- 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");
503
563
  } catch (error) {
504
564
  throw this.#wrap(error);
505
565
  }
@@ -511,7 +571,7 @@ var IndexedDBDriver = class {
511
571
  * @remarks
512
572
  * IndexedDB schema DDL is legal only inside `onupgradeneeded`, so this closes
513
573
  * the current connection and opens a FRESH one at `version + 1`, declaring
514
- * 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,
515
575
  * and applying `table.remove` / `index.add` / `index.remove` /
516
576
  * `column.remove` inside `upgrade`. Every step's `table` is validated against
517
577
  * the driver's own `#schema` BEFORE the reconnect — an unknown-table step
@@ -524,49 +584,154 @@ var IndexedDBDriver = class {
524
584
  * `open` tracks, so subsequent pushdown planning and a later `migrate` /
525
585
  * `open` see the new shape.
526
586
  *
527
- * @param plan - The migration plan to apply
587
+ * @param input - The migration plan and optional metadata stamp to apply atomically
528
588
  */
529
- async migrate(plan) {
530
- 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) {
531
590
  const current = this.#require();
532
- const version = current.version;
533
- const schema = new Map(this.#schema);
534
- this.#applySteps(schema, plan.steps);
535
- 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
+ }
536
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)));
537
619
  const database = createIndexedDBDatabase({
538
620
  name: this.#name,
539
621
  version: version + 1,
540
622
  stores: this.#stores(schema),
541
- upgrade: this.#upgrade.bind(this, plan.steps)
623
+ upgrade: this.#upgrade.bind(this, owned, added)
542
624
  });
543
- await database.connect();
625
+ try {
626
+ await database.connect();
627
+ } catch (error) {
628
+ database.close();
629
+ throw error;
630
+ }
544
631
  this.#database = database;
545
632
  this.#schema = schema;
633
+ this.#identities = identities;
546
634
  } catch (error) {
547
- await this.#reopen();
548
- 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();
549
676
  }
550
677
  }
551
678
  #require() {
552
679
  if (this.#database === void 0) throw new DatabaseError("CLOSED", `IndexedDB database '${this.#name}' is not open`, { name: this.#name });
553
680
  return this.#database;
554
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
+ }
555
713
  #wrap(error) {
556
714
  return isIndexedDBError(error) ? mapIndexedDBError(error) : error;
557
715
  }
558
716
  #store(table) {
559
717
  return this.#require().store(table);
560
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
+ }
561
732
  #stores(schema) {
562
- const stores = { [META_STORE]: {} };
563
- for (const table of schema.values()) stores[table.name] = { indexes: table.indexes.map((columns) => {
564
- const [column] = columns;
565
- return {
566
- name: deriveIndexName(columns),
567
- path: columns.length === 1 && column !== void 0 ? column : [...columns]
568
- };
569
- }) };
733
+ const stores = { [METADATA_STORE]: {} };
734
+ for (const table of schema.values()) stores[table.name] = schemaToStore(table);
570
735
  return stores;
571
736
  }
572
737
  async #reopen() {
@@ -574,11 +739,16 @@ var IndexedDBDriver = class {
574
739
  name: this.#name,
575
740
  stores: this.#stores(this.#schema)
576
741
  });
577
- await database.connect();
578
- this.#database = database;
742
+ try {
743
+ await database.connect();
744
+ this.#database = database;
745
+ } catch (error) {
746
+ database.close();
747
+ throw error;
748
+ }
579
749
  }
580
750
  async #candidates(store, schema, plan) {
581
- if (plan.index === null) return store.records(plan.range);
751
+ if (plan.index === void 0) return store.records(plan.range);
582
752
  const rows = [...await store.index(plan.index).records(plan.range)];
583
753
  rows.sort((left, right) => compareValues(extractKey(left, schema.primary), extractKey(right, schema.primary)));
584
754
  return rows;
@@ -588,62 +758,27 @@ var IndexedDBDriver = class {
588
758
  if (schema === void 0) throw new DatabaseError("NOT_FOUND", `table '${name}' is not declared`, { table: name });
589
759
  return schema;
590
760
  }
591
- #applySteps(schema, steps) {
592
- 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) {
593
764
  case "table.add":
594
- if (!schema.has(step.table.name)) schema.set(step.table.name, step.table);
765
+ context.create(step.table.name, schemaToStore(step.table));
595
766
  break;
596
- case "table.remove":
597
- schema.delete(step.table);
598
- break;
599
- case "column.add": {
600
- const table = schema.get(step.table);
601
- if (table !== void 0 && !table.columns.some((column) => column.name === step.column.name)) schema.set(step.table, {
602
- ...table,
603
- columns: [...table.columns, step.column]
604
- });
605
- break;
606
- }
607
- case "column.remove": {
608
- const table = schema.get(step.table);
609
- if (table !== void 0) schema.set(step.table, {
610
- ...table,
611
- columns: table.columns.filter((column) => column.name !== step.column)
612
- });
613
- break;
614
- }
615
- case "index.add": {
616
- const table = schema.get(step.table);
617
- if (table !== void 0) schema.set(step.table, {
618
- ...table,
619
- indexes: [...table.indexes, step.index]
620
- });
621
- break;
622
- }
623
- case "index.remove": {
624
- const table = schema.get(step.table);
625
- if (table !== void 0) schema.set(step.table, {
626
- ...table,
627
- indexes: table.indexes.filter((index) => !deepEqual(index, step.index))
628
- });
629
- break;
630
- }
631
- }
632
- }
633
- async #upgrade(steps, context) {
634
- for (const step of steps) switch (step.operation) {
635
767
  case "table.remove":
636
768
  context.drop(step.table);
637
769
  break;
638
770
  case "index.add": {
639
- const name = deriveIndexName(step.index);
771
+ const name = deriveIndexedDBIndexName(step.index);
640
772
  const [column] = step.index;
641
773
  const path = step.index.length === 1 && column !== void 0 ? column : [...step.index];
642
- context.transaction.objectStore(step.table).createIndex(name, path);
774
+ context.index(step.table, {
775
+ name,
776
+ path
777
+ });
643
778
  break;
644
779
  }
645
780
  case "index.remove":
646
- context.transaction.objectStore(step.table).deleteIndex(deriveIndexName(step.index));
781
+ context.deindex(step.table, deriveIndexedDBIndexName(step.index));
647
782
  break;
648
783
  case "column.remove": {
649
784
  let cursor = await context.store(step.table).cursor();
@@ -655,9 +790,11 @@ var IndexedDBDriver = class {
655
790
  }
656
791
  break;
657
792
  }
658
- case "table.add":
659
- case "column.add": break;
660
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");
661
798
  }
662
799
  };
663
800
  //#endregion
@@ -666,9 +803,9 @@ var IndexedDBDriver = class {
666
803
  * Create a persistent IndexedDB {@link DriverInterface} for the core database layer.
667
804
  *
668
805
  * @remarks
669
- * Pass it to `createDatabase` from `@orkestrel/database` to run the whole typed database +
670
- * relations stack against IndexedDB instead of memory — the `Database` / `Table` /
671
- * `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
672
809
  * driver is built on the published `@orkestrel/indexeddb` wrapper in auto-managed
673
810
  * mode, so a table added to the `tables` map is created on the next open with no
674
811
  * version bump. This unit omits `transaction` / `aggregate` (see
@@ -694,6 +831,6 @@ function createIndexedDBDriver(name) {
694
831
  return new IndexedDBDriver(name);
695
832
  }
696
833
  //#endregion
697
- 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 };
698
835
 
699
836
  //# sourceMappingURL=index.js.map