@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,12 +1,12 @@
1
- import { compileSchema, createContract, isArray, isBoolean, isFiniteNumber, isRecord, isString, objectShape, parseNumber, resolveField } from "@orkestrel/contract";
1
+ import { cloneJSONRecord, cloneJSONValue, compileGuard, compileSchema, createContract, isArray, isRecord, isString, objectShape, parseNumber, resolveField } from "@orkestrel/contract";
2
2
  import { Emitter } from "@orkestrel/emitter";
3
3
  //#region src/core/constants.ts
4
4
  /**
5
- * The primary-key column assumed when {@link TableKeys} does not name one.
5
+ * The primary-key column assumed when {@link PrimaryMap} does not name one.
6
6
  *
7
7
  * @remarks
8
8
  * `id` is the convention IndexedDB (`keyPath: 'id'`) and SQL (`id` / rowid) both
9
- * lean on, so a table that omits `key` keys its rows by `id`.
9
+ * lean on, so a table without a `primary` override keys its rows by `id`.
10
10
  */
11
11
  var DEFAULT_PRIMARY = "id";
12
12
  /**
@@ -14,7 +14,7 @@ var DEFAULT_PRIMARY = "id";
14
14
  *
15
15
  * @remarks
16
16
  * A ReDoS bound (AGENTS §6.5): the SA1–SA4 migration lets a model supply `list`
17
- * criteria over the wire, so `likeMatch` / `globMatch` run attacker-controlled
17
+ * input over the wire, so `matchesLikePattern` / `matchesGlobPattern` run attacker-controlled
18
18
  * patterns. The matcher is the LINEAR greedy two-pointer wildcard match — never a
19
19
  * backtracking regex (`.*`-segments-separated-by-literals against a long input is the
20
20
  * catastrophic shape JS cannot bound without atomic groups), so it is O(value ×
@@ -23,10 +23,6 @@ var DEFAULT_PRIMARY = "id";
23
23
  * `VALIDATION` {@link DatabaseError}; the cap is generous for any legitimate search.
24
24
  */
25
25
  var MAX_PATTERN_LENGTH = 1024;
26
- /** The number of bytes encoded by an RFC 4122 UUID. */
27
- var UUID_BYTE_COUNT = 16;
28
- /** The number of distinct values one UUID byte may hold. */
29
- var UUID_BYTE_RANGE = 256;
30
26
  //#endregion
31
27
  //#region src/core/errors.ts
32
28
  /**
@@ -36,8 +32,8 @@ var UUID_BYTE_RANGE = 256;
36
32
  * Carries a {@link DatabaseErrorCode} and an optional `context` bag naming the
37
33
  * offending table / key. Thrown for: operating on a closed database (`CLOSED`), a
38
34
  * `resolve` miss (`NOT_FOUND`), an `add` onto an existing key (`CONFLICT`), a
39
- * row that fails its table's contract (`VALIDATION`), a cancelled operation whose
40
- * {@link ReadOptions.signal} aborted (`ABORTED`, carrying `signal.reason` in
35
+ * row that fails its table's contract (`VALIDATION`), an aborted operation whose
36
+ * {@link OperationOptions.signal} aborted (`ABORTED`, carrying `signal.reason` in
41
37
  * `context`), an inapplicable {@link Migration} plan (`MIGRATION`), a
42
38
  * driver that violates a {@link DriverInterface} invariant, thrown by the
43
39
  * `conformDriver` helper (`CONFORMANCE`), and an unexpected infrastructure
@@ -74,6 +70,219 @@ function isDatabaseError(value) {
74
70
  return value instanceof DatabaseError;
75
71
  }
76
72
  //#endregion
73
+ //#region src/core/validators.ts
74
+ /**
75
+ * Validate the paging fields of a portable query.
76
+ *
77
+ * @remarks
78
+ * A present `limit` or `offset` must be a finite nonnegative integer; zero is
79
+ * valid. Validation is deterministic (`limit` before `offset`). Non-finite
80
+ * values are rendered as strings in error context so JSON serialization cannot
81
+ * collapse `NaN` or infinity to `null`.
82
+ *
83
+ * @param input - The portable query whose paging fields to validate
84
+ * @throws {@link DatabaseError} `VALIDATION` when a paging field is invalid
85
+ */
86
+ function validatePage(input) {
87
+ const limit = input?.limit;
88
+ if (limit !== void 0 && (!Number.isInteger(limit) || limit < 0)) throw new DatabaseError("VALIDATION", "Query limit must be a nonnegative integer", {
89
+ field: "limit",
90
+ value: Number.isFinite(limit) ? limit : String(limit)
91
+ });
92
+ const offset = input?.offset;
93
+ if (offset !== void 0 && (!Number.isInteger(offset) || offset < 0)) throw new DatabaseError("VALIDATION", "Query offset must be a nonnegative integer", {
94
+ field: "offset",
95
+ value: Number.isFinite(offset) ? offset : String(offset)
96
+ });
97
+ }
98
+ /**
99
+ * Test whether a value is a usable database key.
100
+ *
101
+ * @param value - The value to test
102
+ * @returns Whether `value` is a string or finite number
103
+ */
104
+ function isKey(value) {
105
+ return typeof value === "string" || typeof value === "number" && Number.isFinite(value);
106
+ }
107
+ /**
108
+ * Test whether a value is a portable column schema.
109
+ *
110
+ * @param value - The value to test
111
+ * @returns Whether `value` is a complete {@link ColumnSchema}
112
+ */
113
+ function isColumnSchema(value) {
114
+ try {
115
+ const column = cloneJSONRecord(value);
116
+ const keys = Object.keys(column);
117
+ return keys.length === 4 && keys.includes("name") && keys.includes("storage") && keys.includes("optional") && keys.includes("nullable") && typeof column.name === "string" && column.name.length > 0 && (column.storage === "text" || column.storage === "integer" || column.storage === "real" || column.storage === "boolean" || column.storage === "json" || column.storage === "blob") && typeof column.optional === "boolean" && typeof column.nullable === "boolean";
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+ /**
123
+ * Test whether a value is a portable table schema.
124
+ *
125
+ * @param value - The value to test
126
+ * @returns Whether `value` is a complete {@link TableSchema}
127
+ */
128
+ function isTableSchema(value) {
129
+ try {
130
+ const table = cloneJSONRecord(value);
131
+ const keys = Object.keys(table);
132
+ if (keys.length !== 4 || !keys.includes("name") || !keys.includes("primary") || !keys.includes("columns") || !keys.includes("indexes") || typeof table.name !== "string" || table.name.length === 0 || typeof table.primary !== "string" || table.primary.length === 0 || !Array.isArray(table.columns) || !Array.isArray(table.indexes) || !table.columns.every(isColumnSchema)) return false;
133
+ const names = table.columns.map((column) => column.name);
134
+ if (new Set(names).size !== names.length || !names.includes(table.primary) || !table.indexes.every((index) => Array.isArray(index) && index.length > 0 && index.every((column) => typeof column === "string" && names.includes(column)))) return false;
135
+ const indexes = table.indexes.map((index) => JSON.stringify(index));
136
+ return new Set(indexes).size === indexes.length;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+ /**
142
+ * Test whether a value is a complete portable driver schema.
143
+ *
144
+ * @param value - The value to test
145
+ * @returns Whether `value` is a table-schema collection with unique table names
146
+ */
147
+ function isDriverSchema(value) {
148
+ try {
149
+ const schema = cloneJSONValue(value);
150
+ if (!Array.isArray(schema) || !schema.every(isTableSchema)) return false;
151
+ const names = schema.map((table) => table.name);
152
+ return new Set(names).size === names.length;
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+ /**
158
+ * Test whether a value is one ordered migration step.
159
+ *
160
+ * @param value - The value to test
161
+ * @returns Whether `value` is a complete {@link MigrationStep}
162
+ */
163
+ function isMigrationStep(value) {
164
+ try {
165
+ const step = cloneJSONRecord(value);
166
+ if (typeof step.operation !== "string") return false;
167
+ const keys = Object.keys(step);
168
+ switch (step.operation) {
169
+ case "table.add": return keys.length === 2 && keys.includes("operation") && keys.includes("table") && isTableSchema(step.table);
170
+ case "table.remove": return keys.length === 2 && keys.includes("operation") && keys.includes("table") && typeof step.table === "string" && step.table.length > 0;
171
+ case "column.add": return keys.length === 3 && keys.includes("operation") && keys.includes("table") && keys.includes("column") && typeof step.table === "string" && step.table.length > 0 && isColumnSchema(step.column);
172
+ case "column.remove": return keys.length === 3 && keys.includes("operation") && keys.includes("table") && keys.includes("column") && typeof step.table === "string" && step.table.length > 0 && typeof step.column === "string" && step.column.length > 0;
173
+ case "index.add":
174
+ case "index.remove": return keys.length === 3 && keys.includes("operation") && keys.includes("table") && keys.includes("index") && typeof step.table === "string" && step.table.length > 0 && Array.isArray(step.index) && step.index.length > 0 && step.index.every((column) => typeof column === "string" && column.length > 0);
175
+ default: return false;
176
+ }
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+ /**
182
+ * Test whether a value is an ordered migration plan.
183
+ *
184
+ * @param value - The value to test
185
+ * @returns Whether `value` is a complete {@link Migration}
186
+ */
187
+ function isMigration(value) {
188
+ try {
189
+ const migration = cloneJSONRecord(value);
190
+ const keys = Object.keys(migration);
191
+ return keys.length === 3 && keys.includes("from") && keys.includes("to") && keys.includes("steps") && typeof migration.from === "number" && Number.isFinite(migration.from) && typeof migration.to === "number" && Number.isFinite(migration.to) && Array.isArray(migration.steps) && migration.steps.every(isMigrationStep);
192
+ } catch {
193
+ return false;
194
+ }
195
+ }
196
+ /**
197
+ * Test whether a value is persisted driver metadata.
198
+ *
199
+ * @param value - The value to test
200
+ * @returns Whether `value` is complete {@link DriverMetadata}
201
+ */
202
+ function isDriverMetadata(value) {
203
+ try {
204
+ const metadata = cloneJSONRecord(value);
205
+ const keys = Object.keys(metadata);
206
+ return keys.length === 2 && keys.includes("version") && keys.includes("schema") && typeof metadata.version === "number" && Number.isFinite(metadata.version) && isDriverSchema(metadata.schema);
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+ /**
212
+ * Test whether a value is one atomic migration request.
213
+ *
214
+ * @param value - The value to test
215
+ * @returns Whether `value` is a complete {@link MigrationInput}
216
+ */
217
+ function isMigrationInput(value) {
218
+ try {
219
+ const input = cloneJSONRecord(value);
220
+ const keys = Object.keys(input);
221
+ return (keys.length === 1 || keys.length === 2) && keys.includes("plan") && (keys.length === 1 || keys.includes("metadata")) && isMigration(input.plan) && (input.metadata === void 0 || isDriverMetadata(input.metadata));
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+ //#endregion
227
+ //#region src/core/cloners.ts
228
+ /**
229
+ * Clone unknown driver metadata into a distinct deeply frozen snapshot.
230
+ *
231
+ * @param value - Unknown metadata
232
+ * @returns Owned driver metadata
233
+ */
234
+ function cloneDriverMetadata(value) {
235
+ try {
236
+ const metadata = cloneJSONRecord(value);
237
+ if (isDriverMetadata(metadata)) return metadata;
238
+ throw new DatabaseError("VALIDATION", "Driver metadata is invalid", { path: "metadata" });
239
+ } catch (error) {
240
+ if (error instanceof DatabaseError) throw error;
241
+ throw new DatabaseError("VALIDATION", "Driver metadata is invalid", {
242
+ path: "metadata",
243
+ cause: error
244
+ });
245
+ }
246
+ }
247
+ /**
248
+ * Clone unknown driver schema into a distinct deeply frozen snapshot.
249
+ *
250
+ * @param value - Unknown table schema collection
251
+ * @returns Owned driver schema
252
+ */
253
+ function cloneDriverSchema(value) {
254
+ try {
255
+ const schema = cloneJSONValue(value);
256
+ if (isDriverSchema(schema)) return schema;
257
+ throw new DatabaseError("VALIDATION", "Driver schema is invalid", { path: "schema" });
258
+ } catch (error) {
259
+ if (error instanceof DatabaseError) throw error;
260
+ throw new DatabaseError("VALIDATION", "Driver schema is invalid", {
261
+ path: "schema",
262
+ cause: error
263
+ });
264
+ }
265
+ }
266
+ /**
267
+ * Clone unknown migration input into a distinct deeply frozen snapshot.
268
+ *
269
+ * @param value - Unknown migration input
270
+ * @returns Owned migration input
271
+ */
272
+ function cloneMigrationInput(value) {
273
+ try {
274
+ const input = cloneJSONRecord(value);
275
+ if (isMigrationInput(input)) return input;
276
+ throw new DatabaseError("VALIDATION", "Migration input is invalid", { path: "migration" });
277
+ } catch (error) {
278
+ if (error instanceof DatabaseError) throw error;
279
+ throw new DatabaseError("VALIDATION", "Migration input is invalid", {
280
+ path: "migration",
281
+ cause: error
282
+ });
283
+ }
284
+ }
285
+ //#endregion
77
286
  //#region src/core/helpers.ts
78
287
  /**
79
288
  * A total ordering over arbitrary values — the comparator behind sorting and the
@@ -106,14 +315,15 @@ function compareValues(left, right) {
106
315
  *
107
316
  * @remarks
108
317
  * Primitives compare by SameValueZero (`NaN` equals itself; `+0` equals `-0`).
109
- * Arrays compare by index (same length, every element `deepEqual`). Plain
318
+ * Arrays compare by index (same length, every element `equalsValue`). Plain
110
319
  * records (via `isRecord`) compare by their OWN enumerable keys: same key
111
320
  * COUNT and, for every key in `left`, `right` has that key (`Object.hasOwn`)
112
- * with a `deepEqual` value — so a key present with value `undefined` is NOT
321
+ * with a `equalsValue` value — so a key present with value `undefined` is NOT
113
322
  * equal to that key being absent (both differ in `Object.keys` membership).
114
323
  * Anything else (functions, class instances, mismatched shapes) falls through
115
- * to `false`. There is no cycle detection a cyclic input recurses forever;
116
- * callers pass acyclic data (rows, plans, config).
324
+ * to `false`. Container pairs are tracked iteratively, so self-referential and
325
+ * mutually cyclic arrays/records terminate without consuming the call stack.
326
+ * Hostile proxy traps and accessors are contained as a non-match.
117
327
  *
118
328
  * @param left - The left value
119
329
  * @param right - The right value
@@ -121,32 +331,67 @@ function compareValues(left, right) {
121
331
  *
122
332
  * @example
123
333
  * ```ts
124
- * deepEqual(Number.NaN, Number.NaN) // true
125
- * deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }) // true
126
- * deepEqual({ a: undefined }, {}) // false — present-undefined ≠ absent
334
+ * equalsValue(Number.NaN, Number.NaN) // true
335
+ * equalsValue({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }) // true
336
+ * equalsValue({ a: undefined }, {}) // false — present-undefined ≠ absent
127
337
  * ```
128
338
  */
129
- function deepEqual(left, right) {
130
- if (typeof left === "number" && typeof right === "number") return Number.isNaN(left) && Number.isNaN(right) || left === right;
131
- if (left === right) return true;
132
- if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((item, index) => deepEqual(item, right[index]));
133
- if (isRecord(left) && isRecord(right)) {
134
- const leftKeys = Object.keys(left);
135
- const rightKeys = Object.keys(right);
136
- if (leftKeys.length !== rightKeys.length) return false;
137
- return leftKeys.every((key) => Object.hasOwn(right, key) && deepEqual(left[key], right[key]));
138
- }
139
- return false;
339
+ function equalsValue(left, right) {
340
+ const pending = [[left, right]];
341
+ const compared = /* @__PURE__ */ new WeakMap();
342
+ try {
343
+ while (pending.length > 0) {
344
+ const pair = pending.pop();
345
+ if (pair === void 0) continue;
346
+ const [currentLeft, currentRight] = pair;
347
+ if (typeof currentLeft === "number" && typeof currentRight === "number") {
348
+ if (Number.isNaN(currentLeft) && Number.isNaN(currentRight) || currentLeft === currentRight) continue;
349
+ return false;
350
+ }
351
+ if (currentLeft === currentRight) continue;
352
+ const leftArray = Array.isArray(currentLeft);
353
+ const rightArray = Array.isArray(currentRight);
354
+ const leftRecord = isRecord(currentLeft);
355
+ const rightRecord = isRecord(currentRight);
356
+ if (leftArray !== rightArray || leftRecord !== rightRecord) return false;
357
+ if (!leftArray && !leftRecord || !rightArray && !rightRecord) return false;
358
+ const prior = compared.get(currentLeft);
359
+ if (prior?.has(currentRight)) continue;
360
+ if (prior === void 0) compared.set(currentLeft, new WeakSet([currentRight]));
361
+ else prior.add(currentRight);
362
+ if (leftArray && rightArray) {
363
+ if (currentLeft.length !== currentRight.length) return false;
364
+ for (let index = 0; index < currentLeft.length; index += 1) {
365
+ const leftOwn = Object.hasOwn(currentLeft, index);
366
+ if (leftOwn !== Object.hasOwn(currentRight, index)) return false;
367
+ if (leftOwn) pending.push([currentLeft[index], currentRight[index]]);
368
+ }
369
+ continue;
370
+ }
371
+ if (leftRecord && rightRecord) {
372
+ const leftKeys = Object.keys(currentLeft);
373
+ const rightKeys = Object.keys(currentRight);
374
+ if (leftKeys.length !== rightKeys.length) return false;
375
+ for (const key of leftKeys) {
376
+ if (!Object.hasOwn(currentRight, key)) return false;
377
+ pending.push([currentLeft[key], currentRight[key]]);
378
+ }
379
+ }
380
+ }
381
+ return true;
382
+ } catch {
383
+ return false;
384
+ }
140
385
  }
141
386
  /**
142
387
  * Match a value against a wildcard pattern in LINEAR time — the shared, ReDoS-SAFE
143
- * engine behind {@link likeMatch} and {@link globMatch}.
388
+ * engine behind {@link matchesLikePattern} and {@link matchesGlobPattern}.
144
389
  *
145
390
  * @remarks
146
391
  * A backtracking RegExp (`a%b%c` → `^a.*b.*c$`) is CATASTROPHIC on a hostile pattern:
147
392
  * `.*` segments separated by literals, matched against a long non-matching input, blow
148
393
  * up super-linearly — and JS has no atomic groups / possessive quantifiers to bound it
149
- * (AGENTS §6.5, now that the authed server runs model-supplied `list` criteria over the
394
+ * (AGENTS §6.5, now that the authed server runs model-supplied `list` input over the
150
395
  * wire). So this builds NO regex. It runs the classic GREEDY TWO-POINTER wildcard match:
151
396
  * the `any` wildcard records its position and, on a later mismatch, backtracks ONLY to
152
397
  * that last `any` (letting it absorb one more char) — so the work is O(value × pattern),
@@ -169,7 +414,7 @@ function deepEqual(left, right) {
169
414
  * @returns Whether `value` matches `pattern`
170
415
  * @throws A `VALIDATION` {@link DatabaseError} when `pattern` exceeds {@link MAX_PATTERN_LENGTH}
171
416
  */
172
- function wildcardMatch(value, pattern, any, single, fold) {
417
+ function matchesWildcardPattern(value, pattern, any, single, fold) {
173
418
  if (pattern.length > 1024) throw new DatabaseError("VALIDATION", `Pattern exceeds the maximum length of ${MAX_PATTERN_LENGTH}`, {
174
419
  length: pattern.length,
175
420
  limit: MAX_PATTERN_LENGTH
@@ -198,11 +443,11 @@ function wildcardMatch(value, pattern, any, single, fold) {
198
443
  while (pi < needle.length && needle[pi] === any) pi += 1;
199
444
  return pi === needle.length;
200
445
  }
201
- function likeMatch(value, pattern) {
202
- return wildcardMatch(value, pattern, "%", "_", true);
446
+ function matchesLikePattern(value, pattern) {
447
+ return matchesWildcardPattern(value, pattern, "%", "_", true);
203
448
  }
204
- function globMatch(value, pattern) {
205
- return wildcardMatch(value, pattern, "*", "?", false);
449
+ function matchesGlobPattern(value, pattern) {
450
+ return matchesWildcardPattern(value, pattern, "*", "?", false);
206
451
  }
207
452
  /**
208
453
  * Evaluate one {@link Condition} against a row — the per-operator predicate.
@@ -212,10 +457,10 @@ function globMatch(value, pattern) {
212
457
  * string is one column; an array descends a nested value) — and applies the
213
458
  * operator. Range operators (`above` / `below` / `from` / `to` / `between`) use
214
459
  * {@link compareValues}, the total order; the equality family (`equals` / `not`
215
- * / `any` / `none`) uses {@link deepEqual} — STRUCTURAL equality, not the total
460
+ * / `any` / `none`) uses {@link equalsValue} — STRUCTURAL equality, not the total
216
461
  * order's rank-5-collapses-all-objects behavior, so `equals` on an object/array
217
462
  * operand only matches a structurally-equal value, never every row holding any
218
- * object. This is a semantics change from ranking: `deepEqual` is SameValueZero
463
+ * object. This is a semantics change from ranking: `equalsValue` is SameValueZero
219
464
  * on leaves, so `NaN` now equals `NaN` under `equals` / `any` (it never matched
220
465
  * anything under the old rank-based comparison). `like` / `glob` / `starts` /
221
466
  * `ends` match only strings; `absent` / `present` test nullishness. Total — a
@@ -230,19 +475,19 @@ function matchesCondition(row, condition) {
230
475
  const first = condition.values[0];
231
476
  const second = condition.values[1];
232
477
  switch (condition.operator) {
233
- case "equals": return deepEqual(value, first);
234
- case "not": return !deepEqual(value, first);
478
+ case "equals": return equalsValue(value, first);
479
+ case "not": return !equalsValue(value, first);
235
480
  case "above": return compareValues(value, first) > 0;
236
481
  case "below": return compareValues(value, first) < 0;
237
482
  case "from": return compareValues(value, first) >= 0;
238
483
  case "to": return compareValues(value, first) <= 0;
239
484
  case "between": return compareValues(value, first) >= 0 && compareValues(value, second) <= 0;
240
- case "like": return isString(value) && isString(first) && likeMatch(value, first);
241
- case "glob": return isString(value) && isString(first) && globMatch(value, first);
485
+ case "like": return isString(value) && isString(first) && matchesLikePattern(value, first);
486
+ case "glob": return isString(value) && isString(first) && matchesGlobPattern(value, first);
242
487
  case "starts": return isString(value) && isString(first) && value.startsWith(first);
243
488
  case "ends": return isString(value) && isString(first) && value.endsWith(first);
244
- case "any": return condition.values.some((candidate) => deepEqual(value, candidate));
245
- case "none": return !condition.values.some((candidate) => deepEqual(value, candidate));
489
+ case "any": return condition.values.some((candidate) => equalsValue(value, candidate));
490
+ case "none": return !condition.values.some((candidate) => equalsValue(value, candidate));
246
491
  case "absent": return value === void 0 || value === null;
247
492
  case "present": return value !== void 0 && value !== null;
248
493
  }
@@ -260,7 +505,7 @@ function matchesCondition(row, condition) {
260
505
  * @param conditions - The conditions to fold
261
506
  * @returns Whether the row satisfies the combined conditions
262
507
  */
263
- function matchesCriteria(row, conditions) {
508
+ function matchesQuery(row, conditions) {
264
509
  let result = true;
265
510
  let seeded = false;
266
511
  for (const condition of conditions) {
@@ -274,11 +519,11 @@ function matchesCriteria(row, conditions) {
274
519
  }
275
520
  /**
276
521
  * Filter rows by a list of conditions — the shared basis for a table's count
277
- * and aggregate paths (no sort/page, unlike {@link applyCriteria}).
522
+ * and aggregate paths (no sort/page, unlike {@link applyQuery}).
278
523
  *
279
524
  * @remarks
280
525
  * An empty condition list matches every row (returned as-is, no copy). Folds
281
- * each row through {@link matchesCriteria}.
526
+ * each row through {@link matchesQuery}.
282
527
  *
283
528
  * @param rows - The rows to filter
284
529
  * @param conditions - The conditions to apply (empty matches everything)
@@ -294,7 +539,7 @@ function matchesCriteria(row, conditions) {
294
539
  */
295
540
  function filterRows(rows, conditions) {
296
541
  if (conditions.length === 0) return rows;
297
- return rows.filter((row) => matchesCriteria(row, conditions));
542
+ return rows.filter((row) => matchesQuery(row, conditions));
298
543
  }
299
544
  /**
300
545
  * Sort rows by an ordering specification, leaving the input untouched.
@@ -319,26 +564,27 @@ function sortRows(rows, order) {
319
564
  return sorted;
320
565
  }
321
566
  /**
322
- * Apply a {@link Criteria} to rows — filter, then sort, then page.
567
+ * Apply a {@link QueryInput} to rows — filter, then sort, then page.
323
568
  *
324
569
  * @remarks
325
570
  * The whole portable read pipeline in one place: conditions filter, `order`
326
571
  * sorts, and `offset` / `limit` window the result. Each step is skipped when its
327
- * part of the criteria is absent. The reference {@link DriverInterface} backends
572
+ * part of the input is absent. The reference {@link DriverInterface} backends
328
573
  * lean on this rather than each re-deriving it.
329
574
  *
330
575
  * @param rows - The rows to process (typically a table's full `scan`)
331
- * @param criteria - The read specification, or `undefined` for all rows as-is
576
+ * @param input - The read specification, or `undefined` for all rows as-is
332
577
  * @returns The filtered, sorted, paged rows
333
578
  */
334
- function applyCriteria(rows, criteria) {
579
+ function applyQuery(rows, input) {
580
+ validatePage(input);
335
581
  let result = rows;
336
- const conditions = criteria?.conditions;
337
- if (conditions !== void 0 && conditions.length > 0) result = result.filter((row) => matchesCriteria(row, conditions));
338
- const order = criteria?.order;
582
+ const conditions = input?.conditions;
583
+ if (conditions !== void 0 && conditions.length > 0) result = result.filter((row) => matchesQuery(row, conditions));
584
+ const order = input?.order;
339
585
  if (order !== void 0 && order.length > 0) result = sortRows(result, order);
340
- const offset = criteria?.offset ?? 0;
341
- const limit = criteria?.limit;
586
+ const offset = input?.offset ?? 0;
587
+ const limit = input?.limit;
342
588
  if (offset > 0 || limit !== void 0) result = result.slice(offset, limit !== void 0 ? offset + limit : void 0);
343
589
  return result;
344
590
  }
@@ -380,11 +626,24 @@ function computeAggregate(rows, operation, column) {
380
626
  */
381
627
  function extractKey(row, column) {
382
628
  const value = row[column];
383
- if (isString(value)) return value;
384
- if (isFiniteNumber(value)) return value;
629
+ return isKey(value) ? value : void 0;
630
+ }
631
+ /**
632
+ * Return a fresh row whose primary column is authoritatively bound to its storage key.
633
+ *
634
+ * @param row - The caller row
635
+ * @param primary - The primary column
636
+ * @param key - The authoritative storage key
637
+ * @returns A fresh row with the bound primary
638
+ */
639
+ function bindRowKey(row, primary, key) {
640
+ return {
641
+ ...row,
642
+ [primary]: key
643
+ };
385
644
  }
386
645
  /**
387
- * Map a column's {@link ContractShape} to its portable {@link ColumnType} — the
646
+ * Map a column's {@link ContractShape} to its portable {@link ColumnStorage} — the
388
647
  * value a `TableSchema` carries so a native backend can declare a real column.
389
648
  *
390
649
  * @remarks
@@ -401,13 +660,13 @@ function extractKey(row, column) {
401
660
  *
402
661
  * @example
403
662
  * ```ts
404
- * shapeToColumnType(stringShape()) // 'text'
405
- * shapeToColumnType(integerShape()) // 'integer'
406
- * shapeToColumnType(optionalShape(integerShape())) // 'integer'
407
- * shapeToColumnType(objectShape({ a: stringShape() })) // 'json'
663
+ * shapeToColumnStorage(stringShape()) // 'text'
664
+ * shapeToColumnStorage(integerShape()) // 'integer'
665
+ * shapeToColumnStorage(optionalShape(integerShape())) // 'integer'
666
+ * shapeToColumnStorage(objectShape({ a: stringShape() })) // 'json'
408
667
  * ```
409
668
  */
410
- function shapeToColumnType(shape) {
669
+ function shapeToColumnStorage(shape) {
411
670
  switch (shape.type) {
412
671
  case "string": return "text";
413
672
  case "number": return shape.integer === true ? "integer" : "real";
@@ -417,7 +676,7 @@ function shapeToColumnType(shape) {
417
676
  if (shape.values.every((value) => typeof value === "number")) return shape.values.every((value) => Number.isInteger(value)) ? "integer" : "real";
418
677
  return "text";
419
678
  case "optional":
420
- case "nullable": return shapeToColumnType(shape.inner);
679
+ case "nullable": return shapeToColumnStorage(shape.inner);
421
680
  case "null":
422
681
  case "object":
423
682
  case "array":
@@ -427,48 +686,31 @@ function shapeToColumnType(shape) {
427
686
  }
428
687
  }
429
688
  /**
430
- * Whether a value is a well-formed {@link DriverMeta} — the boundary guard a
431
- * versioning driver's `meta()` narrows a stored (structured-clone or
432
- * `JSON.parse`d) value through before trusting it, replacing the per-driver
433
- * duplicated narrowing every backend used to hand-roll (AGENTS §14: never `as`).
434
- *
435
- * @remarks
436
- * Total and total-recursive over the whole shape: a finite `version`, and a
437
- * `schema` array of well-formed {@link TableSchema} entries — each a `name` /
438
- * `primary` string pair, a `columns` array of well-formed {@link ColumnSchema}
439
- * entries (a `name` string, a {@link ColumnType} literal, a `nullable`
440
- * boolean), and an `indexes` array of string arrays. Anything off-shape
441
- * (including a non-record) returns `false` rather than throwing.
689
+ * Project one contract shape into a portable column schema.
442
690
  *
443
- * @param value - The value to test
444
- * @returns `true` when `value` is a well-formed `DriverMeta`
445
- *
446
- * @example
447
- * ```ts
448
- * isDriverMeta({ version: 1, schema: [] }) // true
449
- * isDriverMeta({ version: 1, schema: [{ name: 'users' }] }) // false
450
- * ```
691
+ * @param name - The column name
692
+ * @param shape - The column contract shape
693
+ * @returns The portable storage and independent absence/null acceptance
451
694
  */
452
- function isDriverMeta(value) {
453
- return isRecord(value) && isFiniteNumber(value.version) && isArray(value.schema) && value.schema.every((table) => isRecord(table) && isString(table.name) && isString(table.primary) && isArray(table.columns) && table.columns.every((column) => isRecord(column) && isString(column.name) && isString(column.type) && [
454
- "text",
455
- "integer",
456
- "real",
457
- "boolean",
458
- "json",
459
- "blob"
460
- ].some((type) => type === column.type) && isBoolean(column.nullable)) && isArray(table.indexes) && table.indexes.every((index) => isArray(index) && index.every((column) => isString(column))));
695
+ function shapeToColumnSchema(name, shape) {
696
+ const isColumn = compileGuard(objectShape({ value: shape }));
697
+ return {
698
+ name,
699
+ storage: shapeToColumnStorage(shape),
700
+ optional: isColumn({}),
701
+ nullable: isColumn({ value: null })
702
+ };
461
703
  }
462
704
  /**
463
- * Throw when an {@link ReadOptions.signal | AbortSignal} has fired — the shared
464
- * cancellation gate checked at operation boundaries and between streamed rows.
705
+ * Throw when an {@link OperationOptions.signal | AbortSignal} has fired — the shared
706
+ * abort gate checked at operation boundaries and between streamed rows.
465
707
  *
466
708
  * @remarks
467
709
  * A no-op for `undefined` or a live signal, so callers thread `options?.signal`
468
710
  * straight through. When the signal has aborted, throws an `ABORTED`
469
711
  * {@link DatabaseError} carrying the signal's `reason` in its context — callers
470
- * mint signals with whatever tool they like (`AbortSignal.timeout(ms)`,
471
- * `new AbortController()`, `@orkestrel/abort`).
712
+ * mint signals with native APIs such as `AbortSignal.timeout(ms)` or
713
+ * `new AbortController()`.
472
714
  *
473
715
  * @param signal - The signal to check, if any
474
716
  * @returns Nothing — returns normally while the signal is live
@@ -500,11 +742,12 @@ function checkAbort(signal) {
500
742
  * `column.remove` / `index.add` / `index.remove` steps. Step order is
501
743
  * deterministic: every `table.remove`, then every `table.add`, then each
502
744
  * shared table's column/index changes in `declared` order. `from` / `to` are
503
- * plan labels only version tracking itself is deferred to persistent
504
- * backends.
745
+ * plan labels only; versioning drivers persist and reconcile them through
746
+ * {@link DriverMetadata}.
505
747
  *
506
748
  * A column present in BOTH schemas under the same name but with a different
507
- * `type` or `nullable` throws a `MIGRATION` {@link DatabaseError} naming the
749
+ * `storage`, `optional`, or `nullable` value throws a `MIGRATION`
750
+ * {@link DatabaseError} naming the
508
751
  * table, the column, and the from→to difference — a name-only diff would
509
752
  * otherwise silently produce NO step for the drift, and versioned
510
753
  * reconciliation would stamp over it. There is no automatic in-place
@@ -517,42 +760,66 @@ function checkAbort(signal) {
517
760
  * @param from - The plan's source version label (defaults to `0`)
518
761
  * @param to - The plan's target version label (defaults to `1`)
519
762
  * @returns The migration plan moving `deployed` toward `declared`
520
- * @throws A `MIGRATION` {@link DatabaseError} when a shared column's `type` or
521
- * `nullable` differs between `deployed` and `declared`
763
+ * @throws A `MIGRATION` {@link DatabaseError} when a shared table's primary or
764
+ * a shared column's `storage`, `optional`, or `nullable` differs, or when a
765
+ * required non-null column would be added to an existing table without a
766
+ * portable backfill
522
767
  *
523
768
  * @example
524
769
  * ```ts
525
770
  * const plan = planMigration(
526
- * [{ name: 'users', primary: 'id', columns: [], indexes: [] }],
527
- * [{ name: 'users', primary: 'id', columns: [{ name: 'age', type: 'integer', nullable: false }], indexes: [] }],
771
+ * [{ name: 'users', primary: 'id', columns: [{ name: 'id', storage: 'text', optional: false, nullable: false }], indexes: [] }],
772
+ * [{ name: 'users', primary: 'id', columns: [{ name: 'id', storage: 'text', optional: false, nullable: false }, { name: 'age', storage: 'integer', optional: true, nullable: false }], indexes: [] }],
528
773
  * )
529
774
  * // plan.steps === [{ operation: 'column.add', table: 'users', column: { name: 'age', ... } }]
530
775
  * ```
531
776
  */
532
777
  function planMigration(deployed, declared, from = 0, to = 1) {
533
- const deployedByName = new Map(deployed.map((table) => [table.name, table]));
534
- const declaredByName = new Map(declared.map((table) => [table.name, table]));
778
+ if (!Number.isFinite(from) || !Number.isFinite(to)) throw new DatabaseError("MIGRATION", "Migration versions must be finite", {
779
+ from,
780
+ to
781
+ });
782
+ let beforeSchema;
783
+ let targetSchema;
784
+ try {
785
+ beforeSchema = normalizeDriverSchema(deployed);
786
+ targetSchema = normalizeDriverSchema(declared);
787
+ } catch (error) {
788
+ throw new DatabaseError("MIGRATION", "Migration schema is invalid", { cause: error });
789
+ }
790
+ const deployedByName = new Map(beforeSchema.map((table) => [table.name, table]));
791
+ const declaredByName = new Map(targetSchema.map((table) => [table.name, table]));
535
792
  const steps = [];
536
- for (const table of deployed) if (!declaredByName.has(table.name)) steps.push({
793
+ for (const table of beforeSchema) if (!declaredByName.has(table.name)) steps.push({
537
794
  operation: "table.remove",
538
795
  table: table.name
539
796
  });
540
- for (const table of declared) if (!deployedByName.has(table.name)) steps.push({
797
+ for (const table of targetSchema) if (!deployedByName.has(table.name)) steps.push({
541
798
  operation: "table.add",
542
799
  table
543
800
  });
544
- for (const table of declared) {
801
+ for (const table of targetSchema) {
545
802
  const before = deployedByName.get(table.name);
546
803
  if (before === void 0) continue;
547
- const beforeColumns = new Map(before.columns.map((column) => [column.name, column]));
548
- const afterColumns = new Map(table.columns.map((column) => [column.name, column]));
549
- for (const column of before.columns) if (!afterColumns.has(column.name)) steps.push({
804
+ if (before.primary !== table.primary) throw new DatabaseError("MIGRATION", `planMigration: primary column on table '${table.name}' changed from '${before.primary}' to '${table.primary}'`, {
805
+ table: table.name,
806
+ from: before.primary,
807
+ to: table.primary
808
+ });
809
+ const beforeColumnMap = new Map(before.columns.map((column) => [column.name, column]));
810
+ const afterColumnMap = new Map(table.columns.map((column) => [column.name, column]));
811
+ for (const index of before.indexes) if (!table.indexes.some((candidate) => equalsValue(candidate, index))) steps.push({
812
+ operation: "index.remove",
813
+ table: table.name,
814
+ index
815
+ });
816
+ for (const column of before.columns) if (!afterColumnMap.has(column.name)) steps.push({
550
817
  operation: "column.remove",
551
818
  table: table.name,
552
819
  column: column.name
553
820
  });
554
821
  for (const column of table.columns) {
555
- const previous = beforeColumns.get(column.name);
822
+ const previous = beforeColumnMap.get(column.name);
556
823
  if (previous === void 0) {
557
824
  steps.push({
558
825
  operation: "column.add",
@@ -561,35 +828,158 @@ function planMigration(deployed, declared, from = 0, to = 1) {
561
828
  });
562
829
  continue;
563
830
  }
564
- if (previous.type !== column.type || previous.nullable !== column.nullable) throw new DatabaseError("MIGRATION", `planMigration: column '${column.name}' on table '${table.name}' changed shape (type ${previous.type}→${column.type}, nullable ${previous.nullable}→${column.nullable}) — in-place type/nullability changes are not auto-migrated; add a new column, copy/convert the data, then remove the old column`, {
831
+ if (previous.storage !== column.storage || previous.optional !== column.optional || previous.nullable !== column.nullable) throw new DatabaseError("MIGRATION", `planMigration: column '${column.name}' on table '${table.name}' changed shape (storage ${previous.storage}→${column.storage}, optional ${previous.optional}→${column.optional}, nullable ${previous.nullable}→${column.nullable}) — in-place storage/optionality/nullability changes are not auto-migrated; add a new column, copy/convert the data, then remove the old column`, {
565
832
  table: table.name,
566
833
  column: column.name,
567
834
  from: {
568
- type: previous.type,
835
+ storage: previous.storage,
836
+ optional: previous.optional,
569
837
  nullable: previous.nullable
570
838
  },
571
839
  to: {
572
- type: column.type,
840
+ storage: column.storage,
841
+ optional: column.optional,
573
842
  nullable: column.nullable
574
843
  }
575
844
  });
576
845
  }
577
- for (const index of before.indexes) if (!table.indexes.some((candidate) => deepEqual(candidate, index))) steps.push({
578
- operation: "index.remove",
579
- table: table.name,
580
- index
581
- });
582
- for (const index of table.indexes) if (!before.indexes.some((candidate) => deepEqual(candidate, index))) steps.push({
846
+ for (const index of table.indexes) if (!before.indexes.some((candidate) => equalsValue(candidate, index))) steps.push({
583
847
  operation: "index.add",
584
848
  table: table.name,
585
849
  index
586
850
  });
587
851
  }
588
- return {
852
+ const projected = projectMigrationSchema(beforeSchema, steps);
853
+ if (!equalsValue(projected, targetSchema)) throw new DatabaseError("MIGRATION", "Migration plan does not project to the declared schema", {
854
+ projected,
855
+ declared: targetSchema
856
+ });
857
+ return cloneMigrationInput({ plan: {
589
858
  from,
590
859
  to,
591
860
  steps
592
- };
861
+ } }).plan;
862
+ }
863
+ /**
864
+ * Sequentially project migration steps over a canonical validated owned schema.
865
+ * Adding a required non-null column to an existing table rejects with
866
+ * `MIGRATION`; optional-only and nullable-only additions remain portable.
867
+ *
868
+ * @param schema - The initial deployed schema
869
+ * @param steps - The ordered migration steps
870
+ * @returns A fresh owned final schema
871
+ */
872
+ function projectMigrationSchema(schema, steps) {
873
+ let owned;
874
+ let projectedSteps;
875
+ try {
876
+ owned = normalizeDriverSchema(schema);
877
+ projectedSteps = cloneMigrationInput({ plan: {
878
+ from: 0,
879
+ to: 1,
880
+ steps
881
+ } }).plan.steps;
882
+ } catch (error) {
883
+ throw new DatabaseError("MIGRATION", "Migration input is invalid", { cause: error });
884
+ }
885
+ const tables = new Map(owned.map((table) => [table.name, table]));
886
+ for (const step of projectedSteps) {
887
+ if (step.operation === "table.add") {
888
+ if (tables.has(step.table.name)) throw new DatabaseError("MIGRATION", `migrate: table '${step.table.name}' already exists`, { table: step.table.name });
889
+ tables.set(step.table.name, step.table);
890
+ continue;
891
+ }
892
+ const table = tables.get(step.table);
893
+ if (table === void 0) throw new DatabaseError("MIGRATION", `migrate: table '${step.table}' does not exist`, { table: step.table });
894
+ if (step.operation === "table.remove") {
895
+ tables.delete(step.table);
896
+ continue;
897
+ }
898
+ if (step.operation === "column.add") {
899
+ if (table.columns.some((column) => column.name === step.column.name)) throw new DatabaseError("MIGRATION", `migrate: column '${step.column.name}' already exists`, {
900
+ table: step.table,
901
+ column: step.column.name
902
+ });
903
+ if (!step.column.optional && !step.column.nullable) throw new DatabaseError("MIGRATION", `migrate: required non-null column '${step.column.name}' cannot be added automatically to existing table '${step.table}'`, {
904
+ table: step.table,
905
+ column: step.column.name
906
+ });
907
+ tables.set(step.table, {
908
+ ...table,
909
+ columns: [...table.columns, step.column]
910
+ });
911
+ continue;
912
+ }
913
+ if (step.operation === "column.remove") {
914
+ if (!table.columns.some((column) => column.name === step.column)) throw new DatabaseError("MIGRATION", `migrate: column '${step.column}' does not exist`, {
915
+ table: step.table,
916
+ column: step.column
917
+ });
918
+ if (table.primary === step.column) throw new DatabaseError("MIGRATION", "migrate: cannot remove the primary column", {
919
+ table: step.table,
920
+ column: step.column
921
+ });
922
+ if (table.indexes.some((index) => index.includes(step.column))) throw new DatabaseError("MIGRATION", "migrate: cannot remove an indexed column", {
923
+ table: step.table,
924
+ column: step.column
925
+ });
926
+ tables.set(step.table, {
927
+ ...table,
928
+ columns: table.columns.filter((column) => column.name !== step.column)
929
+ });
930
+ continue;
931
+ }
932
+ if (step.operation === "index.add") {
933
+ if (step.index.length === 0 || step.index.some((name) => !table.columns.some((column) => column.name === name))) throw new DatabaseError("MIGRATION", "migrate: index references a missing column", {
934
+ table: step.table,
935
+ index: step.index
936
+ });
937
+ if (table.indexes.some((index) => equalsValue(index, step.index))) throw new DatabaseError("MIGRATION", "migrate: index already exists", {
938
+ table: step.table,
939
+ index: step.index
940
+ });
941
+ tables.set(step.table, {
942
+ ...table,
943
+ indexes: [...table.indexes, step.index]
944
+ });
945
+ continue;
946
+ }
947
+ if (!table.indexes.some((index) => equalsValue(index, step.index))) throw new DatabaseError("MIGRATION", "migrate: index does not exist", {
948
+ table: step.table,
949
+ index: step.index
950
+ });
951
+ tables.set(step.table, {
952
+ ...table,
953
+ indexes: table.indexes.filter((index) => !equalsValue(index, step.index))
954
+ });
955
+ }
956
+ try {
957
+ return normalizeDriverSchema([...tables.values()]);
958
+ } catch (error) {
959
+ throw new DatabaseError("MIGRATION", "Projected migration schema is invalid", { cause: error });
960
+ }
961
+ }
962
+ /**
963
+ * Canonicalize an unknown driver schema into a distinct deeply frozen snapshot.
964
+ *
965
+ * @remarks
966
+ * Table and column lists are sorted by name. The index list is sorted by the
967
+ * complete serialized tuple while column order inside each compound index is
968
+ * preserved because it carries index semantics. Validation and ownership flow
969
+ * through {@link cloneDriverSchema} before and after projection.
970
+ *
971
+ * @param value - Unknown driver schema
972
+ * @returns A validated, owned canonical schema
973
+ */
974
+ function normalizeDriverSchema(value) {
975
+ const tables = cloneDriverSchema(value).map((table) => ({
976
+ name: table.name,
977
+ primary: table.primary,
978
+ columns: [...table.columns].sort((left, right) => compareValues(left.name, right.name)),
979
+ indexes: [...table.indexes].sort((left, right) => compareValues(JSON.stringify(left), JSON.stringify(right)))
980
+ }));
981
+ tables.sort((left, right) => compareValues(left.name, right.name));
982
+ return cloneDriverSchema(tables);
593
983
  }
594
984
  /**
595
985
  * Apply one table's {@link MigrationStep}s to its rows — a pure row transform.
@@ -637,21 +1027,24 @@ function migrateRows(rows, steps) {
637
1027
  * `read` of a missing key returns `undefined`; `write`/`read` round-trip with
638
1028
  * DEEP copy-in/copy-out isolation (mutating the caller's row — including a
639
1029
  * NESTED field — after `write`, or a row `read` returns, never perturbs
640
- * stored state) and upsert-overwrite; `delete` returns `true` then `false`;
1030
+ * stored state) and upsert-overwrite; simultaneous same-key `insert` calls
1031
+ * produce exactly one commit and one `CONFLICT`; pre-aborted `write`,
1032
+ * `insert`, and `delete` calls leave storage unchanged; `delete` returns
1033
+ * `true` then `false`;
641
1034
  * `keys`/`scan` yield in ascending key order; `clear` empties only its target
642
1035
  * table; `snapshot`'s rollback thunk restores pre-snapshot state, including a
643
1036
  * NESTED field mutated in place on a read-back row between capture and
644
1037
  * restore; a scoped `snapshot(['users'])` rolls back only the named table,
645
1038
  * leaving a concurrent mutation to another table intact; a
646
1039
  * non-`id` primary key (`posts.slug`) round-trips; a nested-object row
647
- * round-trips structurally (via {@link deepEqual}). The optional surface is
1040
+ * round-trips structurally (via {@link equalsValue}). The optional surface is
648
1041
  * presence-gated: when `migrate` exists, a `column.remove` plan strips the
649
1042
  * column from stored rows and a plan referencing an unknown table throws
650
1043
  * `DatabaseError` `MIGRATION`; when `stream` exists, it yields only
651
1044
  * condition-matching rows and honors `offset`/`limit`; when `transaction`
652
- * exists, `commit` persists and `rollback` restores; when both `meta` and
653
- * `stamp` exist, a fresh store's `meta()` is `undefined`, and after
654
- * `stamp({ version, schema })`, `meta()` returns the exact stamped value.
1045
+ * exists, `commit` persists and `rollback` restores; when both `metadata` and
1046
+ * `stamp` exist, a fresh store's `metadata()` is `undefined`, and after
1047
+ * `stamp({ version, schema })`, `metadata()` returns the exact stamped value.
655
1048
  *
656
1049
  * Each phase runs within a `try`/`catch`: an EXPECTED mismatch yields a
657
1050
  * finding built from the assertion, while an UNEXPECTED throw (a driver
@@ -683,23 +1076,27 @@ async function* driverFindings(factory) {
683
1076
  columns: [
684
1077
  {
685
1078
  name: "id",
686
- type: "text",
1079
+ storage: "text",
1080
+ optional: false,
687
1081
  nullable: false
688
1082
  },
689
1083
  {
690
1084
  name: "name",
691
- type: "text",
1085
+ storage: "text",
1086
+ optional: false,
692
1087
  nullable: false
693
1088
  },
694
1089
  {
695
1090
  name: "age",
696
- type: "integer",
697
- nullable: true
1091
+ storage: "integer",
1092
+ optional: true,
1093
+ nullable: false
698
1094
  },
699
1095
  {
700
1096
  name: "meta",
701
- type: "json",
702
- nullable: true
1097
+ storage: "json",
1098
+ optional: true,
1099
+ nullable: false
703
1100
  }
704
1101
  ],
705
1102
  indexes: []
@@ -709,11 +1106,13 @@ async function* driverFindings(factory) {
709
1106
  primary: "slug",
710
1107
  columns: [{
711
1108
  name: "slug",
712
- type: "text",
1109
+ storage: "text",
1110
+ optional: false,
713
1111
  nullable: false
714
1112
  }, {
715
1113
  name: "title",
716
- type: "text",
1114
+ storage: "text",
1115
+ optional: false,
717
1116
  nullable: false
718
1117
  }],
719
1118
  indexes: []
@@ -755,7 +1154,7 @@ async function* driverFindings(factory) {
755
1154
  const driver = factory();
756
1155
  await driver.open(CONFORMANCE_SCHEMA);
757
1156
  const input = {
758
- id: "u1",
1157
+ id: "caller",
759
1158
  name: "Ada",
760
1159
  age: 30,
761
1160
  meta: { tags: ["a"] }
@@ -770,7 +1169,7 @@ async function* driverFindings(factory) {
770
1169
  age: 30,
771
1170
  meta: { tags: ["a"] }
772
1171
  };
773
- if (stored === void 0 || !deepEqual(stored, original)) {
1172
+ if (stored === void 0 || !equalsValue(stored, original)) {
774
1173
  await driver.close();
775
1174
  yield {
776
1175
  check: "copy-in",
@@ -786,7 +1185,7 @@ async function* driverFindings(factory) {
786
1185
  stored.name = "Mutated after read";
787
1186
  if (isRecord(stored.meta) && Array.isArray(stored.meta.tags)) stored.meta.tags.push("mutated");
788
1187
  const reread = await driver.read("users", "u1");
789
- if (reread === void 0 || !deepEqual(reread, original)) {
1188
+ if (reread === void 0 || !equalsValue(reread, original)) {
790
1189
  await driver.close();
791
1190
  yield {
792
1191
  check: "copy-out",
@@ -800,19 +1199,23 @@ async function* driverFindings(factory) {
800
1199
  break writeRead;
801
1200
  }
802
1201
  const overwrite = {
803
- id: "u1",
1202
+ id: "caller",
804
1203
  name: "Ada Overwritten",
805
1204
  age: 31
806
1205
  };
807
1206
  await driver.write("users", "u1", overwrite);
808
1207
  const overwritten = await driver.read("users", "u1");
809
1208
  await driver.close();
810
- if (overwritten === void 0 || !deepEqual(overwritten, overwrite)) yield {
1209
+ const expectedOverwrite = {
1210
+ ...overwrite,
1211
+ id: "u1"
1212
+ };
1213
+ if (overwritten === void 0 || !equalsValue(overwritten, expectedOverwrite)) yield {
811
1214
  check: "upsert",
812
1215
  message: "write must upsert-overwrite an existing key",
813
1216
  context: {
814
1217
  table: "users",
815
- expected: overwrite,
1218
+ expected: expectedOverwrite,
816
1219
  actual: overwritten
817
1220
  }
818
1221
  };
@@ -823,6 +1226,47 @@ async function* driverFindings(factory) {
823
1226
  context: { error }
824
1227
  };
825
1228
  }
1229
+ try {
1230
+ const driver = factory();
1231
+ await driver.open(CONFORMANCE_SCHEMA);
1232
+ const outcomes = await Promise.allSettled([driver.insert("users", "u1", {
1233
+ id: "u1",
1234
+ name: "Ada",
1235
+ age: 30
1236
+ }), driver.insert("users", "u1", {
1237
+ id: "u1",
1238
+ name: "Grace",
1239
+ age: 40
1240
+ })]);
1241
+ let fulfilled = 0;
1242
+ let conflicted = 0;
1243
+ for (const outcome of outcomes) if (outcome.status === "fulfilled") fulfilled += 1;
1244
+ else if (isDatabaseError(outcome.reason) && outcome.reason.code === "CONFLICT") conflicted += 1;
1245
+ const keys = await driver.keys("users");
1246
+ await driver.close();
1247
+ if (fulfilled !== 1 || conflicted !== 1 || !equalsValue(keys, ["u1"])) yield {
1248
+ check: "insert-atomic",
1249
+ message: "concurrent same-key inserts must produce one commit and one CONFLICT",
1250
+ context: {
1251
+ expected: {
1252
+ fulfilled: 1,
1253
+ conflicted: 1,
1254
+ keys: ["u1"]
1255
+ },
1256
+ actual: {
1257
+ fulfilled,
1258
+ conflicted,
1259
+ keys
1260
+ }
1261
+ }
1262
+ };
1263
+ } catch (error) {
1264
+ yield {
1265
+ check: "insert-atomic",
1266
+ message: error instanceof Error ? error.message : String(error),
1267
+ context: { error }
1268
+ };
1269
+ }
826
1270
  deletePhase: try {
827
1271
  const driver = factory();
828
1272
  await driver.open(CONFORMANCE_SCHEMA);
@@ -863,6 +1307,69 @@ async function* driverFindings(factory) {
863
1307
  context: { error }
864
1308
  };
865
1309
  }
1310
+ try {
1311
+ const driver = factory();
1312
+ await driver.open(CONFORMANCE_SCHEMA);
1313
+ await driver.write("users", "u1", {
1314
+ id: "u1",
1315
+ name: "Ada",
1316
+ age: 30
1317
+ });
1318
+ const controller = new AbortController();
1319
+ controller.abort("conformance abort");
1320
+ let writeError;
1321
+ let insertError;
1322
+ let deleteError;
1323
+ try {
1324
+ await driver.write("users", "u2", {
1325
+ id: "u2",
1326
+ name: "Grace",
1327
+ age: 40
1328
+ }, { signal: controller.signal });
1329
+ } catch (error) {
1330
+ writeError = error;
1331
+ }
1332
+ try {
1333
+ await driver.insert("users", "u2", {
1334
+ id: "u2",
1335
+ name: "Grace",
1336
+ age: 40
1337
+ }, { signal: controller.signal });
1338
+ } catch (error) {
1339
+ insertError = error;
1340
+ }
1341
+ try {
1342
+ await driver.delete("users", "u1", { signal: controller.signal });
1343
+ } catch (error) {
1344
+ deleteError = error;
1345
+ }
1346
+ const keys = await driver.keys("users");
1347
+ await driver.close();
1348
+ if (!isDatabaseError(writeError) || writeError.code !== "ABORTED" || !isDatabaseError(insertError) || insertError.code !== "ABORTED" || !isDatabaseError(deleteError) || deleteError.code !== "ABORTED" || !equalsValue(keys, ["u1"])) yield {
1349
+ check: "mutation-abort",
1350
+ message: "pre-aborted write/insert/delete must reject ABORTED without changing rows",
1351
+ context: {
1352
+ expected: {
1353
+ write: "ABORTED",
1354
+ insert: "ABORTED",
1355
+ delete: "ABORTED",
1356
+ keys: ["u1"]
1357
+ },
1358
+ actual: {
1359
+ write: isDatabaseError(writeError) ? writeError.code : writeError,
1360
+ insert: isDatabaseError(insertError) ? insertError.code : insertError,
1361
+ delete: isDatabaseError(deleteError) ? deleteError.code : deleteError,
1362
+ keys
1363
+ }
1364
+ }
1365
+ };
1366
+ } catch (error) {
1367
+ yield {
1368
+ check: "mutation-abort",
1369
+ message: error instanceof Error ? error.message : String(error),
1370
+ context: { error }
1371
+ };
1372
+ }
866
1373
  orderPhase: try {
867
1374
  const driver = factory();
868
1375
  await driver.open(CONFORMANCE_SCHEMA);
@@ -889,7 +1396,7 @@ async function* driverFindings(factory) {
889
1396
  "c"
890
1397
  ];
891
1398
  const keys = [...await driver.keys("users")];
892
- if (!deepEqual(keys, expected)) {
1399
+ if (!equalsValue(keys, expected)) {
893
1400
  await driver.close();
894
1401
  yield {
895
1402
  check: "keys-order",
@@ -906,7 +1413,7 @@ async function* driverFindings(factory) {
906
1413
  for await (const row of driver.scan("users")) scanned.push(row);
907
1414
  const scannedIds = scanned.map((row) => row.id);
908
1415
  await driver.close();
909
- if (!deepEqual(scannedIds, expected)) yield {
1416
+ if (!equalsValue(scannedIds, expected)) yield {
910
1417
  check: "scan-order",
911
1418
  message: "scan must yield rows in ascending key order",
912
1419
  context: {
@@ -984,7 +1491,7 @@ async function* driverFindings(factory) {
984
1491
  await driver.delete("users", "u1");
985
1492
  await rollback();
986
1493
  const keys = [...await driver.keys("users")];
987
- if (!deepEqual(keys, ["u1"])) {
1494
+ if (!equalsValue(keys, ["u1"])) {
988
1495
  await driver.close();
989
1496
  yield {
990
1497
  check: "snapshot-rollback",
@@ -999,7 +1506,7 @@ async function* driverFindings(factory) {
999
1506
  }
1000
1507
  const restored = await driver.read("users", "u1");
1001
1508
  await driver.close();
1002
- if (restored === void 0 || !deepEqual(restored, original)) yield {
1509
+ if (restored === void 0 || !equalsValue(restored, original)) yield {
1003
1510
  check: "snapshot-rollback-value",
1004
1511
  message: "snapshot rollback must restore pre-snapshot row values",
1005
1512
  context: {
@@ -1037,7 +1544,7 @@ async function* driverFindings(factory) {
1037
1544
  await rollback();
1038
1545
  const restored = await driver.read("users", "u3");
1039
1546
  await driver.close();
1040
- if (restored === void 0 || !deepEqual(restored, original)) yield {
1547
+ if (restored === void 0 || !equalsValue(restored, original)) yield {
1041
1548
  check: "snapshot-nested",
1042
1549
  message: "snapshot rollback must restore pre-snapshot nested field values, unaffected by a later in-place mutation of a read-back row",
1043
1550
  context: {
@@ -1057,7 +1564,7 @@ async function* driverFindings(factory) {
1057
1564
  const driver = factory();
1058
1565
  await driver.open(CONFORMANCE_SCHEMA);
1059
1566
  await driver.write("posts", "hello-world", {
1060
- slug: "hello-world",
1567
+ slug: "caller",
1061
1568
  title: "Hello"
1062
1569
  });
1063
1570
  const post = await driver.read("posts", "hello-world");
@@ -1094,7 +1601,7 @@ async function* driverFindings(factory) {
1094
1601
  await driver.write("users", "u3", nested);
1095
1602
  const readBack = await driver.read("users", "u3");
1096
1603
  await driver.close();
1097
- if (readBack === void 0 || !deepEqual(readBack, nested)) yield {
1604
+ if (readBack === void 0 || !equalsValue(readBack, nested)) yield {
1098
1605
  check: "nested-roundtrip",
1099
1606
  message: "a nested-object row must round-trip structurally",
1100
1607
  context: {
@@ -1117,8 +1624,9 @@ async function* driverFindings(factory) {
1117
1624
  ...CONFORMANCE_USERS_SCHEMA,
1118
1625
  columns: [...CONFORMANCE_USERS_SCHEMA.columns, {
1119
1626
  name: "legacy",
1120
- type: "boolean",
1121
- nullable: true
1627
+ storage: "boolean",
1628
+ optional: true,
1629
+ nullable: false
1122
1630
  }]
1123
1631
  };
1124
1632
  await driver.open([deployedUsers, CONFORMANCE_POSTS_SCHEMA]);
@@ -1129,7 +1637,7 @@ async function* driverFindings(factory) {
1129
1637
  legacy: true
1130
1638
  });
1131
1639
  const removePlan = planMigration([deployedUsers], [CONFORMANCE_USERS_SCHEMA]);
1132
- await driver.migrate(removePlan);
1640
+ await driver.migrate({ plan: removePlan });
1133
1641
  const migrated = await driver.read("users", "u1");
1134
1642
  if (migrated === void 0 || "legacy" in migrated) {
1135
1643
  await driver.close();
@@ -1146,14 +1654,14 @@ async function* driverFindings(factory) {
1146
1654
  }
1147
1655
  let caught;
1148
1656
  try {
1149
- await driver.migrate({
1657
+ await driver.migrate({ plan: {
1150
1658
  from: 0,
1151
1659
  to: 1,
1152
1660
  steps: [{
1153
1661
  operation: "table.remove",
1154
1662
  table: "ghost"
1155
1663
  }]
1156
- });
1664
+ } });
1157
1665
  } catch (error) {
1158
1666
  caught = error;
1159
1667
  }
@@ -1195,16 +1703,16 @@ async function* driverFindings(factory) {
1195
1703
  age: 30
1196
1704
  }
1197
1705
  ]) await driver.write("users", row.id, row);
1198
- const criteria = { conditions: [{
1706
+ const input = { conditions: [{
1199
1707
  column: "age",
1200
1708
  operator: "above",
1201
1709
  values: [10],
1202
1710
  connector: "and"
1203
1711
  }] };
1204
1712
  const matched = [];
1205
- for await (const row of driver.stream("users", criteria)) matched.push(row);
1713
+ for await (const row of driver.stream("users", input)) matched.push(row);
1206
1714
  const matchedIds = matched.map((row) => row.id).sort();
1207
- if (!deepEqual(matchedIds, ["b", "c"])) {
1715
+ if (!equalsValue(matchedIds, ["b", "c"])) {
1208
1716
  await driver.close();
1209
1717
  yield {
1210
1718
  check: "stream-match",
@@ -1248,15 +1756,15 @@ async function* driverFindings(factory) {
1248
1756
  name: "Ada",
1249
1757
  age: 30
1250
1758
  });
1251
- const committing = await driver.transaction();
1252
- await driver.write("users", "u2", {
1253
- id: "u2",
1254
- name: "Grace",
1255
- age: 40
1759
+ await driver.transaction(async (transaction) => {
1760
+ await transaction.write("users", "u2", {
1761
+ id: "u2",
1762
+ name: "Grace",
1763
+ age: 40
1764
+ });
1256
1765
  });
1257
- await committing.commit();
1258
1766
  const afterCommit = [...await driver.keys("users")].sort();
1259
- if (!deepEqual(afterCommit, ["u1", "u2"])) {
1767
+ if (!equalsValue(afterCommit, ["u1", "u2"])) {
1260
1768
  await driver.close();
1261
1769
  yield {
1262
1770
  check: "transaction-commit",
@@ -1269,16 +1777,22 @@ async function* driverFindings(factory) {
1269
1777
  };
1270
1778
  break transactionPhase;
1271
1779
  }
1272
- const rollingBack = await driver.transaction();
1273
- await driver.write("users", "u3", {
1274
- id: "u3",
1275
- name: "Marie",
1276
- age: 50
1277
- });
1278
- await rollingBack.rollback();
1780
+ const reason = {};
1781
+ try {
1782
+ await driver.transaction(async (transaction) => {
1783
+ await transaction.write("users", "u3", {
1784
+ id: "u3",
1785
+ name: "Marie",
1786
+ age: 50
1787
+ });
1788
+ throw reason;
1789
+ });
1790
+ } catch (error) {
1791
+ if (error !== reason) throw error;
1792
+ }
1279
1793
  const afterRollback = [...await driver.keys("users")].sort();
1280
1794
  await driver.close();
1281
- if (!deepEqual(afterRollback, ["u1", "u2"])) yield {
1795
+ if (!equalsValue(afterRollback, ["u1", "u2"])) yield {
1282
1796
  check: "transaction-rollback",
1283
1797
  message: "transaction rollback must restore pre-transaction state",
1284
1798
  context: {
@@ -1294,33 +1808,33 @@ async function* driverFindings(factory) {
1294
1808
  context: { error }
1295
1809
  };
1296
1810
  }
1297
- metaPhase: try {
1811
+ metadataPhase: try {
1298
1812
  const driver = factory();
1299
- if (driver.meta === void 0 || driver.stamp === void 0) break metaPhase;
1813
+ if (driver.metadata === void 0 || driver.stamp === void 0) break metadataPhase;
1300
1814
  await driver.open(CONFORMANCE_SCHEMA);
1301
- const fresh = await driver.meta();
1815
+ const fresh = await driver.metadata();
1302
1816
  if (fresh !== void 0) {
1303
1817
  await driver.close();
1304
1818
  yield {
1305
- check: "meta-fresh",
1306
- message: "a fresh store must report undefined meta",
1819
+ check: "metadata-fresh",
1820
+ message: "a fresh store must report undefined metadata",
1307
1821
  context: {
1308
1822
  expected: void 0,
1309
1823
  actual: fresh
1310
1824
  }
1311
1825
  };
1312
- break metaPhase;
1826
+ break metadataPhase;
1313
1827
  }
1314
1828
  const stamped = {
1315
1829
  version: 1,
1316
1830
  schema: CONFORMANCE_SCHEMA
1317
1831
  };
1318
1832
  await driver.stamp(stamped);
1319
- const read = await driver.meta();
1833
+ const read = await driver.metadata();
1320
1834
  await driver.close();
1321
- if (read === void 0 || !deepEqual(read, stamped)) yield {
1322
- check: "meta-stamp",
1323
- message: "meta() must return exactly the last-stamped value",
1835
+ if (read === void 0 || !equalsValue(read, stamped)) yield {
1836
+ check: "metadata-stamp",
1837
+ message: "metadata() must return exactly the last-stamped value",
1324
1838
  context: {
1325
1839
  expected: stamped,
1326
1840
  actual: read
@@ -1328,7 +1842,7 @@ async function* driverFindings(factory) {
1328
1842
  };
1329
1843
  } catch (error) {
1330
1844
  yield {
1331
- check: "meta-stamp",
1845
+ check: "metadata-stamp",
1332
1846
  message: error instanceof Error ? error.message : String(error),
1333
1847
  context: { error }
1334
1848
  };
@@ -1357,7 +1871,7 @@ async function* driverFindings(factory) {
1357
1871
  });
1358
1872
  await rollback();
1359
1873
  const usersKeys = [...await driver.keys("users")];
1360
- if (!deepEqual(usersKeys, ["u1"])) {
1874
+ if (!equalsValue(usersKeys, ["u1"])) {
1361
1875
  await driver.close();
1362
1876
  yield {
1363
1877
  check: "snapshot-scoped-users",
@@ -1372,7 +1886,7 @@ async function* driverFindings(factory) {
1372
1886
  }
1373
1887
  const postsKeys = [...await driver.keys("posts")].sort();
1374
1888
  await driver.close();
1375
- if (!deepEqual(postsKeys, ["p1", "p2"])) yield {
1889
+ if (!equalsValue(postsKeys, ["p1", "p2"])) yield {
1376
1890
  check: "snapshot-scoped-posts",
1377
1891
  message: "a scoped snapshot must leave an unnamed table's mutations intact",
1378
1892
  context: {
@@ -1445,62 +1959,451 @@ async function auditDriver(factory) {
1445
1959
  for await (const finding of driverFindings(factory)) findings.push(finding);
1446
1960
  return findings;
1447
1961
  }
1448
- /**
1449
- * Generate an RFC 4122 version 4 UUID from a number source — no host crypto global.
1450
- *
1451
- * @remarks
1452
- * Draws exactly {@link UUID_BYTE_COUNT} values from `random`, one per byte, then
1453
- * forces the version (`4`) and variant (`10xx`) bits. The default source is
1454
- * `Math.random` — a pure-ECMAScript intrinsic, so generation works on every host;
1455
- * pass a seeded source (`seededRandom` from `@orkestrel/contract`) and reuse it
1456
- * across calls for reproducible sequences in tests and fixtures — production
1457
- * identifiers should keep the default source, whose engine entropy is far larger
1458
- * than a 32-bit seed. Each byte is floored and masked, so a source straying
1459
- * outside `[0, 1)` (negative, `>= 1`, `NaN`, `Infinity`) can never yield a
1460
- * malformed UUID. Suitable as a collision-resistant record identifier — not a
1461
- * cryptographic token; never use one as a secret.
1462
- *
1463
- * @param random - A number source returning values in the half-open range `[0, 1)` (defaults to `Math.random`)
1464
- * @returns A lowercase RFC 4122 version 4 UUID
1465
- *
1466
- * @example
1467
- * ```ts
1468
- * import { generateUUID } from '@orkestrel/database'
1469
- * import { seededRandom } from '@orkestrel/contract'
1470
- *
1471
- * generateUUID() // e.g. '9b2f7c1e-3d4a-4f6b-8e2d-5a1c0b9f8e7d'
1472
- * generateUUID(seededRandom(42)) // the same UUID on every run
1473
- * ```
1474
- */
1475
- function generateUUID(random = Math.random) {
1476
- const hex = Array.from({ length: 16 }, (_, index) => {
1477
- const byte = Math.floor(random() * 256) & 255;
1478
- if (index === 6) return byte & 15 | 64;
1479
- if (index === 8) return byte & 63 | 128;
1480
- return byte;
1481
- }).map((byte) => byte.toString(16).padStart(2, "0"));
1482
- return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
1483
- }
1484
1962
  //#endregion
1485
- //#region src/core/Cursor.ts
1963
+ //#region src/core/TransactionIterator.ts
1486
1964
  /**
1487
- * A forward row cursor for bulk in-place mutation.
1965
+ * The internal continuation boundary for one transaction-scoped async iterable.
1488
1966
  *
1489
1967
  * @remarks
1490
- * Iterates a snapshot of the table's keys captured when the cursor was opened,
1491
- * reading each row lazily through the owning table so a mutation made during
1492
- * iteration cannot corrupt the walk, and a key removed mid-iteration is simply
1493
- * skipped. `update` and `remove` act on the row at the current position.
1968
+ * Each active continuation enters the owning transaction ledger independently,
1969
+ * so an idle iterator never pins settlement. A continuation requested after
1970
+ * admission closes rejects while still attempting source cleanup exactly once.
1494
1971
  */
1495
- var Cursor = class {
1496
- #table;
1497
- #keys;
1498
- #index = -1;
1972
+ var TransactionIterator = class {
1973
+ #source;
1974
+ #scope;
1975
+ #cleaned = false;
1976
+ constructor(source, scope) {
1977
+ this.#source = source[Symbol.asyncIterator]();
1978
+ this.#scope = scope;
1979
+ }
1980
+ [Symbol.asyncIterator]() {
1981
+ return this;
1982
+ }
1983
+ next() {
1984
+ return this.#continue(() => this.#next());
1985
+ }
1986
+ return() {
1987
+ return this.#continue(() => this.#return());
1988
+ }
1989
+ throw(error) {
1990
+ return this.#continue(() => this.#throw(error));
1991
+ }
1992
+ async #next() {
1993
+ if (this.#cleaned) return {
1994
+ done: true,
1995
+ value: void 0
1996
+ };
1997
+ const result = await this.#source.next();
1998
+ if (result.done === true) this.#cleaned = true;
1999
+ return result;
2000
+ }
2001
+ async #return() {
2002
+ if (this.#cleaned || this.#source.return === void 0) {
2003
+ this.#cleaned = true;
2004
+ return {
2005
+ done: true,
2006
+ value: void 0
2007
+ };
2008
+ }
2009
+ this.#cleaned = true;
2010
+ return this.#source.return();
2011
+ }
2012
+ async #throw(error) {
2013
+ if (this.#source.throw !== void 0) {
2014
+ const result = await this.#source.throw(error);
2015
+ if (result.done === true) this.#cleaned = true;
2016
+ return result;
2017
+ }
2018
+ try {
2019
+ await this.#return();
2020
+ } catch {}
2021
+ throw error;
2022
+ }
2023
+ #continue(operation) {
2024
+ if (!this.#scope.accepting) this.#cleanup();
2025
+ return this.#scope.track(operation);
2026
+ }
2027
+ #cleanup() {
2028
+ if (this.#cleaned) return;
2029
+ this.#cleaned = true;
2030
+ try {
2031
+ (this.#source.return?.())?.catch(() => {});
2032
+ } catch {}
2033
+ }
2034
+ };
2035
+ //#endregion
2036
+ //#region src/core/TransactionScope.ts
2037
+ /**
2038
+ * The internal lifetime boundary for one database transaction callback.
2039
+ *
2040
+ * @remarks
2041
+ * Promise operations enter synchronously through {@link track}. Closing stops new
2042
+ * work while {@link drain} contains every operation already accepted, including
2043
+ * work the callback started without awaiting. {@link stream} applies the same
2044
+ * boundary to each iterator continuation without retaining an idle iterator.
2045
+ */
2046
+ var TransactionScope = class {
2047
+ #operations = /* @__PURE__ */ new Set();
2048
+ #accepting = true;
2049
+ #failed = false;
2050
+ #error;
2051
+ get accepting() {
2052
+ return this.#accepting;
2053
+ }
2054
+ check() {
2055
+ if (!this.#accepting) throw new DatabaseError("CONFLICT", "Transaction scope has settled");
2056
+ }
2057
+ track(operation) {
2058
+ try {
2059
+ this.check();
2060
+ } catch (error) {
2061
+ return Promise.reject(error);
2062
+ }
2063
+ let promise;
2064
+ try {
2065
+ promise = operation();
2066
+ } catch (error) {
2067
+ promise = Promise.reject(error);
2068
+ }
2069
+ this.#operations.add(promise);
2070
+ promise.then(() => {
2071
+ this.#operations.delete(promise);
2072
+ }, (error) => {
2073
+ this.#operations.delete(promise);
2074
+ if (!this.#failed) {
2075
+ this.#failed = true;
2076
+ this.#error = error;
2077
+ }
2078
+ });
2079
+ return promise;
2080
+ }
2081
+ stream(source) {
2082
+ return new TransactionIterator(source, this);
2083
+ }
2084
+ stop() {
2085
+ this.#accepting = false;
2086
+ }
2087
+ async drain() {
2088
+ while (this.#operations.size > 0) await Promise.allSettled(this.#operations);
2089
+ if (this.#failed) throw this.#error;
2090
+ }
2091
+ };
2092
+ //#endregion
2093
+ //#region src/core/DatabaseContext.ts
2094
+ /**
2095
+ * The internal shared owner behind every typed view of one database.
2096
+ *
2097
+ * @remarks
2098
+ * A context owns the driver, merged physical schema, lifecycle, observation,
2099
+ * migration, and single transaction admission. It is deliberately omitted from
2100
+ * the public barrel; {@link Database} is the consumer-facing typed view.
2101
+ */
2102
+ var DatabaseContext = class {
2103
+ #driver;
2104
+ #name;
2105
+ #version;
2106
+ #error;
2107
+ #emitter;
2108
+ #operations = /* @__PURE__ */ new Set();
2109
+ #schema = [];
2110
+ #transaction;
2111
+ #status = "idle";
2112
+ #ready;
2113
+ #failure;
2114
+ constructor(options) {
2115
+ this.#driver = options.driver;
2116
+ this.#name = options.name ?? "database";
2117
+ this.#version = options.version;
2118
+ this.#error = options.error;
2119
+ this.#emitter = new Emitter({
2120
+ ...options.on === void 0 ? {} : { on: options.on },
2121
+ ...options.error === void 0 ? {} : { error: options.error }
2122
+ });
2123
+ }
2124
+ get driver() {
2125
+ return this.#driver;
2126
+ }
2127
+ get emitter() {
2128
+ return this.#emitter;
2129
+ }
2130
+ get error() {
2131
+ return this.#error;
2132
+ }
2133
+ get name() {
2134
+ return this.#name;
2135
+ }
2136
+ get accepting() {
2137
+ return this.#status !== "closed" && this.#transaction === void 0;
2138
+ }
2139
+ get status() {
2140
+ return this.#status;
2141
+ }
2142
+ get version() {
2143
+ return this.#version;
2144
+ }
2145
+ register(schema) {
2146
+ if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
2147
+ if (this.#ready !== void 0 || this.#transaction !== void 0) throw new DatabaseError("CONFLICT", `Database '${this.#name}' cannot import tables after opening has started`, {
2148
+ name: this.#name,
2149
+ status: this.#status
2150
+ });
2151
+ const registered = normalizeDriverSchema(schema);
2152
+ const merged = [...this.#schema];
2153
+ for (const table of registered) {
2154
+ const existing = merged.find((candidate) => candidate.name === table.name);
2155
+ if (existing === void 0) {
2156
+ merged.push(table);
2157
+ continue;
2158
+ }
2159
+ if (!equalsValue(existing, table)) throw new DatabaseError("VALIDATION", `Table '${table.name}' conflicts with its registered schema`, { table: table.name });
2160
+ }
2161
+ this.#schema = normalizeDriverSchema(merged);
2162
+ }
2163
+ async open() {
2164
+ this.#outside();
2165
+ await this.connect();
2166
+ }
2167
+ async close() {
2168
+ this.#outside();
2169
+ if (this.#status === "closed") return;
2170
+ this.#status = "closed";
2171
+ await this.#drain();
2172
+ const ready = this.#ready;
2173
+ if (ready !== void 0) await ready.catch(() => {});
2174
+ this.#ready = void 0;
2175
+ await this.#driver.close();
2176
+ this.#emitter.emit("close");
2177
+ }
2178
+ connect() {
2179
+ return this.#connect();
2180
+ }
2181
+ track(operation) {
2182
+ try {
2183
+ this.#admit();
2184
+ } catch (error) {
2185
+ return Promise.reject(error);
2186
+ }
2187
+ let promise;
2188
+ try {
2189
+ promise = operation();
2190
+ } catch (error) {
2191
+ promise = Promise.reject(error);
2192
+ }
2193
+ this.#operations.add(promise);
2194
+ promise.then(() => {
2195
+ this.#operations.delete(promise);
2196
+ }, () => {
2197
+ this.#operations.delete(promise);
2198
+ });
2199
+ return promise;
2200
+ }
2201
+ async transaction(scope, options) {
2202
+ checkAbort(options?.signal);
2203
+ this.#admit();
2204
+ const token = {};
2205
+ this.#transaction = token;
2206
+ try {
2207
+ await this.#drain();
2208
+ await this.#connect();
2209
+ if (this.#driver.transaction !== void 0) {
2210
+ const rejection = {
2211
+ rejected: false,
2212
+ error: void 0,
2213
+ marker: {}
2214
+ };
2215
+ try {
2216
+ const value = await this.#driver.transaction(async (storage) => {
2217
+ this.#emitter.emit("transaction");
2218
+ const outcome = await scope(storage, new TransactionScope());
2219
+ if (!outcome.success) {
2220
+ rejection.rejected = true;
2221
+ rejection.error = outcome.error;
2222
+ throw rejection.marker;
2223
+ }
2224
+ return outcome.value;
2225
+ });
2226
+ this.#emitter.emit("commit");
2227
+ return value;
2228
+ } catch (error) {
2229
+ if (rejection.rejected) {
2230
+ if (Object.is(error, rejection.marker)) {
2231
+ this.#emitter.emit("rollback", rejection.error);
2232
+ throw rejection.error;
2233
+ }
2234
+ throw this.#rollbackError(rejection.error, error);
2235
+ }
2236
+ throw error;
2237
+ }
2238
+ }
2239
+ const rollback = await this.#driver.snapshot();
2240
+ this.#emitter.emit("transaction");
2241
+ const outcome = await scope(this.#driver, new TransactionScope());
2242
+ if (outcome.success) {
2243
+ this.#emitter.emit("commit");
2244
+ return outcome.value;
2245
+ }
2246
+ try {
2247
+ await rollback();
2248
+ } catch (cause) {
2249
+ throw this.#rollbackError(outcome.error, cause);
2250
+ }
2251
+ this.#emitter.emit("rollback", outcome.error);
2252
+ throw outcome.error;
2253
+ } finally {
2254
+ if (this.#transaction === token) this.#transaction = void 0;
2255
+ }
2256
+ }
2257
+ async migrate(deployed, options) {
2258
+ checkAbort(options?.signal);
2259
+ this.#outside();
2260
+ if (this.#ready !== void 0 || this.#status !== "idle") throw new DatabaseError("CONFLICT", `Database '${this.#name}' cannot apply an explicit deployed schema after opening`, {
2261
+ name: this.#name,
2262
+ status: this.#status
2263
+ });
2264
+ if (this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, { name: this.#name });
2265
+ const plan = planMigration(deployed, this.#schema);
2266
+ const readiness = this.#transition(deployed, plan).catch((error) => {
2267
+ if (this.#ready === readiness) this.#ready = void 0;
2268
+ this.#failure = { error };
2269
+ throw error;
2270
+ });
2271
+ this.#failure = void 0;
2272
+ this.#ready = readiness;
2273
+ await readiness;
2274
+ return plan;
2275
+ }
2276
+ #admit() {
2277
+ if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
2278
+ if (this.#transaction !== void 0) throw new DatabaseError("CONFLICT", `Database '${this.#name}' has an active transaction`, { name: this.#name });
2279
+ if (this.#failure !== void 0) throw this.#failure.error;
2280
+ }
2281
+ #outside() {
2282
+ if (this.#transaction !== void 0) throw new DatabaseError("CONFLICT", `Database '${this.#name}' has an active transaction`, { name: this.#name });
2283
+ }
2284
+ #connect() {
2285
+ if (this.#ready !== void 0) return this.#ready;
2286
+ if (this.#failure !== void 0) throw this.#failure.error;
2287
+ if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
2288
+ const readiness = this.#driver.open(this.#schema).then(async () => {
2289
+ if (this.#status === "idle") {
2290
+ this.#status = "open";
2291
+ this.#emitter.emit("open");
2292
+ }
2293
+ await this.#reconcile();
2294
+ }).catch((error) => {
2295
+ if (this.#ready === readiness) this.#ready = void 0;
2296
+ throw error;
2297
+ });
2298
+ this.#ready = readiness;
2299
+ return readiness;
2300
+ }
2301
+ async #drain() {
2302
+ if (this.#operations.size === 0) return;
2303
+ await Promise.allSettled(this.#operations);
2304
+ }
2305
+ async #reconcile() {
2306
+ if (this.#version === void 0 || this.#driver.metadata === void 0 || this.#driver.stamp === void 0) return;
2307
+ const metadata = await this.#driver.metadata();
2308
+ if (metadata === void 0) {
2309
+ await this.#stamp();
2310
+ return;
2311
+ }
2312
+ if (metadata.version > this.#version) throw new DatabaseError("MIGRATION", `Database '${this.#name}' store version ${metadata.version} is newer than declared version ${this.#version}`, {
2313
+ name: this.#name,
2314
+ stored: metadata.version,
2315
+ declared: this.#version
2316
+ });
2317
+ if (metadata.version === this.#version) {
2318
+ if (!equalsValue(normalizeDriverSchema(metadata.schema), this.#schema)) throw new DatabaseError("MIGRATION", `Database '${this.#name}' stored schema differs at version ${this.#version}`, {
2319
+ name: this.#name,
2320
+ version: this.#version
2321
+ });
2322
+ return;
2323
+ }
2324
+ const plan = planMigration(metadata.schema, this.#schema, metadata.version, this.#version);
2325
+ if (plan.steps.length > 0 && this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, {
2326
+ name: this.#name,
2327
+ stored: metadata.version,
2328
+ declared: this.#version
2329
+ });
2330
+ await this.#apply(plan);
2331
+ }
2332
+ async #apply(plan) {
2333
+ if (this.#driver.transaction !== void 0) {
2334
+ await this.#driver.transaction(async (storage) => {
2335
+ if (plan.steps.length > 0 && storage.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' transaction does not support migration`, { name: this.#name });
2336
+ if (storage.migrate === void 0) await this.#stamp(storage);
2337
+ else await storage.migrate(this.#migration(plan));
2338
+ });
2339
+ this.#emitter.emit("migrate", plan);
2340
+ return;
2341
+ }
2342
+ if (this.#driver.migrate === void 0) await this.#stamp();
2343
+ else await this.#driver.migrate(this.#migration(plan));
2344
+ this.#emitter.emit("migrate", plan);
2345
+ }
2346
+ async #transition(deployed, plan) {
2347
+ await this.#driver.open(deployed);
2348
+ await this.#apply(plan);
2349
+ if (this.#status === "idle") {
2350
+ this.#status = "open";
2351
+ this.#emitter.emit("open");
2352
+ }
2353
+ }
2354
+ async #stamp(storage) {
2355
+ const target = storage ?? this.#driver;
2356
+ if (this.#version === void 0 || target.stamp === void 0) return;
2357
+ const metadata = {
2358
+ version: this.#version,
2359
+ schema: this.#schema
2360
+ };
2361
+ await target.stamp(metadata);
2362
+ }
2363
+ #migration(plan) {
2364
+ if (this.#version === void 0) return { plan };
2365
+ return {
2366
+ plan,
2367
+ metadata: {
2368
+ version: this.#version,
2369
+ schema: this.#schema
2370
+ }
2371
+ };
2372
+ }
2373
+ #rollbackError(transaction, cause) {
2374
+ return new DatabaseError("DRIVER", `Database '${this.#name}' rollback failed`, {
2375
+ cause,
2376
+ transaction
2377
+ });
2378
+ }
2379
+ };
2380
+ //#endregion
2381
+ //#region src/core/Cursor.ts
2382
+ /**
2383
+ * A forward row cursor for bulk in-place mutation.
2384
+ *
2385
+ * @remarks
2386
+ * Iterates a snapshot of the table's keys captured when the cursor was opened,
2387
+ * reading each row lazily through the owning table — so a mutation made during
2388
+ * iteration cannot corrupt the walk, and a key removed mid-iteration is simply
2389
+ * skipped. `update` and `remove` act on the row at the current position.
2390
+ */
2391
+ var Cursor = class {
2392
+ #keys;
2393
+ #read;
2394
+ #update;
2395
+ #remove;
2396
+ #track;
2397
+ #tail = Promise.resolve();
2398
+ #index = -1;
1499
2399
  #value;
1500
2400
  #closed = false;
1501
- constructor(table, keys) {
1502
- this.#table = table;
2401
+ constructor(keys, read, update, remove, track) {
1503
2402
  this.#keys = keys;
2403
+ this.#read = read;
2404
+ this.#update = update;
2405
+ this.#remove = remove;
2406
+ this.#track = track;
1504
2407
  }
1505
2408
  get value() {
1506
2409
  return this.#value;
@@ -1511,16 +2414,31 @@ var Cursor = class {
1511
2414
  get done() {
1512
2415
  return this.#closed || this.#index >= this.#keys.length;
1513
2416
  }
1514
- async next() {
2417
+ next() {
2418
+ return this.#track(() => this.#queue(() => this.#advance()));
2419
+ }
2420
+ update(changes) {
2421
+ return this.#track(() => this.#queue(() => this.#revise(changes)));
2422
+ }
2423
+ remove() {
2424
+ return this.#track(() => this.#queue(() => this.#delete()));
2425
+ }
2426
+ close() {
2427
+ this.#closed = true;
2428
+ this.#value = void 0;
2429
+ }
2430
+ async #advance() {
1515
2431
  if (this.#closed) return;
1516
2432
  this.#index += 1;
1517
2433
  while (this.#index < this.#keys.length) {
2434
+ if (this.#closed) return;
1518
2435
  const key = this.#keys[this.#index];
1519
2436
  if (key === void 0) {
1520
2437
  this.#index += 1;
1521
2438
  continue;
1522
2439
  }
1523
- const row = await this.#table.get(key);
2440
+ const row = await this.#read(key);
2441
+ if (this.#closed) return;
1524
2442
  if (row !== void 0) {
1525
2443
  this.#value = row;
1526
2444
  return;
@@ -1529,97 +2447,103 @@ var Cursor = class {
1529
2447
  }
1530
2448
  this.#value = void 0;
1531
2449
  }
1532
- async update(changes) {
2450
+ async #revise(changes) {
1533
2451
  if (this.#closed || this.#value === void 0) return;
1534
2452
  const key = this.#keys[this.#index];
1535
2453
  if (key === void 0) return;
1536
- await this.#table.update(key, changes);
1537
- this.#value = await this.#table.get(key);
2454
+ await this.#update(key, changes);
2455
+ if (this.#closed) return;
2456
+ const row = await this.#read(key);
2457
+ if (this.#closed) return;
2458
+ this.#value = row;
1538
2459
  }
1539
- async remove() {
2460
+ async #delete() {
1540
2461
  if (this.#closed || this.#value === void 0) return;
1541
2462
  const key = this.#keys[this.#index];
1542
2463
  if (key === void 0) return;
1543
- await this.#table.remove(key);
2464
+ await this.#remove(key);
2465
+ if (this.#closed) return;
1544
2466
  this.#value = void 0;
1545
2467
  }
1546
- close() {
1547
- this.#closed = true;
1548
- this.#value = void 0;
2468
+ #queue(operation) {
2469
+ const result = this.#tail.then(operation);
2470
+ this.#tail = result.then(() => void 0, () => void 0);
2471
+ return result;
1549
2472
  }
1550
2473
  };
1551
2474
  //#endregion
1552
- //#region src/core/Clause.ts
2475
+ //#region src/core/DatabaseIterator.ts
1553
2476
  /**
1554
- * A pending condition opened by a query's `where` / `and` / `or`.
2477
+ * The internal continuation admission boundary for a root database stream.
1555
2478
  *
1556
2479
  * @remarks
1557
- * Holds the column, the connector that will join this condition to the ones
1558
- * before it, and a recorder the owning query supplies. Each operator builds the
1559
- * {@link Condition}, hands it to the recorder, and returns the query — so the
1560
- * fluent chain flows straight back into the builder without exposing a mutator.
2480
+ * Each continuation enters the shared root operation ledger independently, so
2481
+ * an idle iterator never delays a transaction or close. A continuation rejected
2482
+ * after transaction or close admission closes attempts source cleanup exactly
2483
+ * once and leaves the iterator terminal.
1561
2484
  */
1562
- var Clause = class {
1563
- #record;
1564
- #column;
1565
- #connector;
1566
- constructor(record, column, connector) {
1567
- this.#record = record;
1568
- this.#column = column;
1569
- this.#connector = connector;
1570
- }
1571
- equals(value) {
1572
- return this.#apply("equals", [value]);
1573
- }
1574
- not(value) {
1575
- return this.#apply("not", [value]);
1576
- }
1577
- above(value) {
1578
- return this.#apply("above", [value]);
1579
- }
1580
- below(value) {
1581
- return this.#apply("below", [value]);
1582
- }
1583
- from(value) {
1584
- return this.#apply("from", [value]);
1585
- }
1586
- to(value) {
1587
- return this.#apply("to", [value]);
1588
- }
1589
- between(lower, upper) {
1590
- return this.#apply("between", [lower, upper]);
1591
- }
1592
- like(pattern) {
1593
- return this.#apply("like", [pattern]);
1594
- }
1595
- glob(pattern) {
1596
- return this.#apply("glob", [pattern]);
2485
+ var DatabaseIterator = class {
2486
+ #source;
2487
+ #context;
2488
+ #cleaned = false;
2489
+ constructor(source, context) {
2490
+ this.#source = source[Symbol.asyncIterator]();
2491
+ this.#context = context;
2492
+ }
2493
+ [Symbol.asyncIterator]() {
2494
+ return this;
1597
2495
  }
1598
- starts(prefix) {
1599
- return this.#apply("starts", [prefix]);
2496
+ next() {
2497
+ return this.#continue(() => this.#next());
1600
2498
  }
1601
- ends(suffix) {
1602
- return this.#apply("ends", [suffix]);
2499
+ return() {
2500
+ return this.#continue(() => this.#return());
1603
2501
  }
1604
- any(values) {
1605
- return this.#apply("any", values);
2502
+ throw(error) {
2503
+ return this.#continue(() => this.#throw(error));
1606
2504
  }
1607
- none(values) {
1608
- return this.#apply("none", values);
2505
+ async #next() {
2506
+ if (this.#cleaned) return {
2507
+ done: true,
2508
+ value: void 0
2509
+ };
2510
+ await this.#context.connect();
2511
+ const result = await this.#source.next();
2512
+ if (result.done === true) this.#cleaned = true;
2513
+ return result;
1609
2514
  }
1610
- absent() {
1611
- return this.#apply("absent", []);
2515
+ async #return() {
2516
+ if (this.#cleaned || this.#source.return === void 0) {
2517
+ this.#cleaned = true;
2518
+ return {
2519
+ done: true,
2520
+ value: void 0
2521
+ };
2522
+ }
2523
+ this.#cleaned = true;
2524
+ return this.#source.return();
2525
+ }
2526
+ async #throw(error) {
2527
+ if (this.#source.throw !== void 0) {
2528
+ const result = await this.#source.throw(error);
2529
+ if (result.done === true) this.#cleaned = true;
2530
+ return result;
2531
+ }
2532
+ try {
2533
+ await this.#return();
2534
+ } catch {}
2535
+ throw error;
1612
2536
  }
1613
- present() {
1614
- return this.#apply("present", []);
2537
+ #continue(operation) {
2538
+ if (!this.#context.accepting) this.#cleanup();
2539
+ return this.#context.track(operation);
1615
2540
  }
1616
- #apply(operator, values) {
1617
- return this.#record({
1618
- column: this.#column,
1619
- operator,
1620
- values,
1621
- connector: this.#connector
1622
- });
2541
+ #cleanup() {
2542
+ if (this.#cleaned) return;
2543
+ this.#cleaned = true;
2544
+ try {
2545
+ (this.#source.return?.())?.catch(() => {});
2546
+ } catch {}
1623
2547
  }
1624
2548
  };
1625
2549
  //#endregion
@@ -1628,12 +2552,9 @@ var Clause = class {
1628
2552
  * A fluent query builder bound to one table.
1629
2553
  *
1630
2554
  * @remarks
1631
- * Accumulates conditions, ordering, JS filters, and a page; each builder method
1632
- * mutates and returns the same instance, so a chain reads as one statement. The
1633
- * portable parts (conditions, order, page) compile into a {@link Criteria} the
1634
- * table resolves; a `filter` predicate is applied in memory after the read and
1635
- * before paging, so it composes with the rest without a backend ever seeing a
1636
- * JS callback.
2555
+ * Accumulates typed conditions, ordering, JS filters, and a page. Each builder
2556
+ * method mutates and returns the same instance. Portable inputs flow to the
2557
+ * table, while predicates remain an in-memory refinement.
1637
2558
  */
1638
2559
  var Query = class {
1639
2560
  #table;
@@ -1645,42 +2566,29 @@ var Query = class {
1645
2566
  constructor(table) {
1646
2567
  this.#table = table;
1647
2568
  }
1648
- where(column) {
1649
- return this.#clause(column, "and");
1650
- }
1651
- and(column) {
1652
- return this.#clause(column, "and");
1653
- }
1654
- or(column) {
1655
- return this.#clause(column, "or");
1656
- }
1657
- filter(predicate) {
1658
- this.#filters.push(predicate);
2569
+ condition(input) {
2570
+ this.#conditions.push(input);
1659
2571
  return this;
1660
2572
  }
1661
- ascending(column) {
1662
- this.#orders.push({
1663
- column,
1664
- direction: "ascending"
1665
- });
2573
+ order(input) {
2574
+ this.#orders.push(input);
1666
2575
  return this;
1667
2576
  }
1668
- descending(column) {
1669
- this.#orders.push({
1670
- column,
1671
- direction: "descending"
1672
- });
2577
+ filter(predicate) {
2578
+ this.#filters.push(predicate);
1673
2579
  return this;
1674
2580
  }
1675
2581
  limit(count) {
2582
+ validatePage({ limit: count });
1676
2583
  this.#limit = count;
1677
2584
  return this;
1678
2585
  }
1679
2586
  offset(count) {
2587
+ validatePage({ offset: count });
1680
2588
  this.#offset = count;
1681
2589
  return this;
1682
2590
  }
1683
- async all() {
2591
+ async collect() {
1684
2592
  if (this.#filters.length === 0) return this.#table.records({
1685
2593
  conditions: this.#conditions,
1686
2594
  order: this.#orders,
@@ -1693,8 +2601,8 @@ var Query = class {
1693
2601
  });
1694
2602
  return this.#page(this.#filtered(fetched));
1695
2603
  }
1696
- async first() {
1697
- return (await this.all())[0];
2604
+ async find() {
2605
+ return (await this.collect())[0];
1698
2606
  }
1699
2607
  async count() {
1700
2608
  if (this.#filters.length === 0) return this.#table.count({ conditions: this.#conditions });
@@ -1702,17 +2610,10 @@ var Query = class {
1702
2610
  return this.#filtered(fetched).length;
1703
2611
  }
1704
2612
  /**
1705
- * Lazy per-row evaluation of this query's conditions / filters / offset /
1706
- * limit.
1707
- *
1708
- * @remarks
1709
- * `order` and its comparators are IGNORED (streaming yields unsorted, as rows
1710
- * are evaluated one at a time). Same abort semantics as
1711
- * `TableInterface.scan`: the signal (if any) is checked before each yield,
1712
- * and breaking out early closes the underlying source.
2613
+ * Lazily evaluate conditions, filters, offset, and limit.
1713
2614
  *
1714
- * @param options - `signal` to cancel the iteration; checked before each yield
1715
- * @returns An async iterable of matching rows
2615
+ * @param options - Optional abort options
2616
+ * @returns Matching rows in storage order
1716
2617
  */
1717
2618
  async *stream(options) {
1718
2619
  if (this.#filters.length === 0) {
@@ -1747,24 +2648,6 @@ var Query = class {
1747
2648
  if (this.#filters.length === 0) return this.#table.aggregate(operation, column, { conditions: this.#conditions });
1748
2649
  return this.#table.records({ conditions: this.#conditions }).then((fetched) => computeAggregate(this.#filtered(fetched), operation, column));
1749
2650
  }
1750
- sum(column) {
1751
- return this.aggregate("sum", column);
1752
- }
1753
- average(column) {
1754
- return this.aggregate("average", column);
1755
- }
1756
- minimum(column) {
1757
- return this.aggregate("minimum", column);
1758
- }
1759
- maximum(column) {
1760
- return this.aggregate("maximum", column);
1761
- }
1762
- #clause(column, connector) {
1763
- return new Clause((condition) => {
1764
- this.#conditions.push(condition);
1765
- return this;
1766
- }, column, connector);
1767
- }
1768
2651
  #filtered(rows) {
1769
2652
  let result = rows;
1770
2653
  for (const predicate of this.#filters) result = result.filter(predicate);
@@ -1807,8 +2690,10 @@ var Table = class {
1807
2690
  #contract;
1808
2691
  #guard;
1809
2692
  #generate;
2693
+ #context;
2694
+ #scope;
1810
2695
  #emitter;
1811
- constructor(ready, driver, name, key, contract, generate, on, error) {
2696
+ constructor(ready, driver, name, key, contract, generate, error, context, scope) {
1812
2697
  this.#ready = ready;
1813
2698
  this.#driver = driver;
1814
2699
  this.#name = name;
@@ -1816,10 +2701,9 @@ var Table = class {
1816
2701
  this.#contract = contract;
1817
2702
  this.#guard = contract.is;
1818
2703
  this.#generate = generate;
1819
- this.#emitter = new Emitter({
1820
- ...on !== void 0 ? { on } : {},
1821
- ...error !== void 0 ? { error } : {}
1822
- });
2704
+ this.#context = context;
2705
+ this.#scope = scope;
2706
+ this.#emitter = new Emitter({ ...error !== void 0 ? { error } : {} });
1823
2707
  }
1824
2708
  get emitter() {
1825
2709
  return this.#emitter;
@@ -1833,58 +2717,77 @@ var Table = class {
1833
2717
  get contract() {
1834
2718
  return this.#contract;
1835
2719
  }
1836
- async get(keys) {
1837
- await this.#ready();
1838
- if (isArray(keys)) return this.#each(keys, (key) => this.#read(key));
1839
- return this.#read(keys);
2720
+ get(keys) {
2721
+ return this.#track(async () => {
2722
+ await this.#ready();
2723
+ if (isArray(keys)) return this.#each(keys, (key) => this.#read(key));
2724
+ return this.#read(keys);
2725
+ });
1840
2726
  }
1841
- async resolve(keys) {
1842
- await this.#ready();
1843
- if (isArray(keys)) return this.#each(keys, (key) => this.#resolveOne(key));
1844
- return this.#resolveOne(keys);
2727
+ resolve(keys) {
2728
+ return this.#track(async () => {
2729
+ await this.#ready();
2730
+ if (isArray(keys)) return this.#each(keys, (key) => this.#resolveOne(key));
2731
+ return this.#resolveOne(keys);
2732
+ });
1845
2733
  }
1846
- async has(keys) {
1847
- await this.#ready();
1848
- if (isArray(keys)) return this.#each(keys, async (key) => await this.#read(key) !== void 0);
1849
- return await this.#read(keys) !== void 0;
2734
+ has(keys) {
2735
+ return this.#track(async () => {
2736
+ await this.#ready();
2737
+ if (isArray(keys)) return this.#each(keys, async (key) => await this.#read(key) !== void 0);
2738
+ return await this.#read(keys) !== void 0;
2739
+ });
1850
2740
  }
1851
- async keys() {
1852
- await this.#ready();
1853
- return this.#driver.keys(this.#name);
2741
+ keys() {
2742
+ return this.#track(async () => {
2743
+ await this.#ready();
2744
+ return this.#driver.keys(this.#name);
2745
+ });
1854
2746
  }
1855
- async records(criteria, options) {
1856
- checkAbort(options?.signal);
1857
- await this.#ready();
1858
- const source = await this.#driver.records?.(this.#name, criteria ?? {}) ?? applyCriteria(await this.#collect(), criteria);
1859
- const rows = [];
1860
- for (const row of source) if (this.#guard(row)) rows.push(row);
1861
- return rows;
2747
+ async records(input, options) {
2748
+ validatePage(input);
2749
+ return this.#track(async () => {
2750
+ checkAbort(options?.signal);
2751
+ await this.#ready();
2752
+ const candidate = {
2753
+ ...input?.conditions === void 0 ? {} : { conditions: input.conditions },
2754
+ ...input?.order === void 0 ? {} : { order: input.order }
2755
+ };
2756
+ const source = await this.#driver.records?.(this.#name, candidate) ?? applyQuery(await this.#collect(), candidate);
2757
+ const rows = [];
2758
+ for (const row of source) if (this.#guard(row)) rows.push(row);
2759
+ const offset = input?.offset ?? 0;
2760
+ const limit = input?.limit;
2761
+ return rows.slice(offset, limit === void 0 ? void 0 : offset + limit);
2762
+ });
1862
2763
  }
1863
2764
  /**
1864
- * Count rows matching `criteria`'s conditions.
2765
+ * Count contract-valid rows matching `input`'s conditions.
1865
2766
  *
1866
2767
  * @remarks
1867
- * Unlike {@link records}, which narrows every row through the table's
1868
- * contract guard before returning it, `count` operates on STORED rows
1869
- * WITHOUT that guard (both the native `driver.count` hook and the
1870
- * `filterRows`-over-`#collect()` fallback count raw storage) — so it can
1871
- * exceed `(await records(criteria)).length` when storage holds rows that
1872
- * no longer conform to the table's contract (legacy or migrated data).
2768
+ * Paging is ignored. Candidate rows use the driver's native `records` hook
2769
+ * when present, then the table contract guard determines the count so legacy
2770
+ * invalid rows cannot make `count()` disagree with `records()`.
1873
2771
  *
1874
- * @param criteria - Optional conditions to filter by (paging is ignored)
2772
+ * @param input - Optional conditions to filter by (paging is ignored)
1875
2773
  * @param options - `{ signal }` to abort
1876
- * @returns The count of matching stored rows
2774
+ * @returns The count of matching contract-valid rows
1877
2775
  */
1878
- async count(criteria, options) {
1879
- checkAbort(options?.signal);
1880
- await this.#ready();
1881
- const conditions = criteria?.conditions;
1882
- const native = await this.#driver.count?.(this.#name, conditions ? { conditions } : {});
1883
- if (native !== void 0) return native;
1884
- return filterRows(await this.#collect(), criteria?.conditions ?? []).length;
2776
+ async count(input, options) {
2777
+ validatePage(input);
2778
+ return this.#track(async () => {
2779
+ checkAbort(options?.signal);
2780
+ await this.#ready();
2781
+ const conditions = input?.conditions;
2782
+ const candidate = conditions === void 0 ? {} : { conditions };
2783
+ const rows = await this.#driver.records?.(this.#name, candidate) ?? filterRows(await this.#collect(), conditions ?? []);
2784
+ let count = 0;
2785
+ for (const row of rows) if (this.#guard(row)) count += 1;
2786
+ return count;
2787
+ });
1885
2788
  }
1886
2789
  /**
1887
- * Compute an aggregate over `column` across rows matching `criteria`'s
2790
+ * Compute an aggregate over `column` across rows matching `input`'s
1888
2791
  * conditions.
1889
2792
  *
1890
2793
  * @remarks
@@ -1896,115 +2799,138 @@ var Table = class {
1896
2799
  *
1897
2800
  * @param operation - The aggregate to compute
1898
2801
  * @param column - The column to aggregate
1899
- * @param criteria - Optional conditions to filter by (paging is ignored)
2802
+ * @param input - Optional conditions to filter by (paging is ignored)
1900
2803
  * @param options - `{ signal }` to abort
1901
2804
  * @returns The aggregate value, or `undefined` when undefined for the inputs
1902
2805
  */
1903
- async aggregate(operation, column, criteria, options) {
1904
- checkAbort(options?.signal);
1905
- await this.#ready();
1906
- const conditions = criteria?.conditions;
1907
- const filter = conditions ? { conditions } : {};
1908
- const native = this.#driver.aggregate?.(this.#name, operation, column, filter);
1909
- if (native !== void 0) return native;
1910
- return computeAggregate(await this.#driver.records?.(this.#name, filter) ?? filterRows(await this.#collect(), criteria?.conditions ?? []), operation, column);
2806
+ async aggregate(operation, column, input, options) {
2807
+ validatePage(input);
2808
+ return this.#track(async () => {
2809
+ checkAbort(options?.signal);
2810
+ await this.#ready();
2811
+ const conditions = input?.conditions;
2812
+ const filter = conditions ? { conditions } : {};
2813
+ const native = this.#driver.aggregate?.(this.#name, operation, column, filter);
2814
+ if (native !== void 0) return native;
2815
+ return computeAggregate(await this.#driver.records?.(this.#name, filter) ?? filterRows(await this.#collect(), input?.conditions ?? []), operation, column);
2816
+ });
1911
2817
  }
1912
2818
  /**
1913
- * Stream the table's rows matching `criteria`, applying offset/limit paging.
2819
+ * Stream the table's rows matching `input`, applying offset/limit paging.
1914
2820
  *
1915
2821
  * @remarks
1916
- * `criteria.limit` counts rows that pass BOTH the criteria conditions AND the
1917
- * table's contract guard (a stored row that fails the guard is skipped and
1918
- * does not count toward `limit`) this can differ from {@link records}'s
1919
- * `limit`, which a driver's optional native `records` hook applies BEFORE
1920
- * the contract guard runs, when storage holds rows that no longer conform
1921
- * to the table's contract.
2822
+ * `input.limit` counts rows that pass both the input conditions and the
2823
+ * table's contract guard. Native streams receive only the conditions; this
2824
+ * table rechecks them, narrows each candidate, and applies offset/limit last,
2825
+ * matching {@link records} when storage contains legacy invalid rows.
1922
2826
  *
1923
- * @param criteria - Optional conditions plus offset/limit paging
2827
+ * @param input - Optional conditions plus offset/limit paging
1924
2828
  * @param options - `{ signal }` to abort mid-stream
1925
2829
  * @returns An async iterable of matching, guard-conforming rows
1926
2830
  */
1927
- async *scan(criteria, options) {
1928
- checkAbort(options?.signal);
1929
- await this.#ready();
1930
- if (this.#driver.stream !== void 0) {
1931
- for await (const row of this.#driver.stream(this.#name, criteria ?? {})) {
1932
- checkAbort(options?.signal);
1933
- const narrowed = this.#cast(row);
1934
- if (narrowed !== void 0) yield narrowed;
1935
- }
1936
- return;
1937
- }
1938
- const conditions = criteria?.conditions;
1939
- const offset = criteria?.offset ?? 0;
1940
- const limit = criteria?.limit;
1941
- let matched = 0;
1942
- let yielded = 0;
1943
- for await (const row of this.#driver.scan(this.#name)) {
1944
- checkAbort(options?.signal);
1945
- if (limit !== void 0 && yielded >= limit) break;
1946
- if (conditions !== void 0 && conditions.length > 0 && !matchesCriteria(row, conditions)) continue;
1947
- if (matched < offset) {
1948
- matched += 1;
1949
- continue;
1950
- }
1951
- matched += 1;
1952
- const narrowed = this.#cast(row);
1953
- if (narrowed !== void 0) {
1954
- yielded += 1;
1955
- yield narrowed;
1956
- }
1957
- }
1958
- }
1959
- async set(rows, options) {
1960
- checkAbort(options?.signal);
1961
- await this.#ready();
1962
- if (isArray(rows)) return this.#each(rows, (row) => this.#put(row, false), options?.signal);
1963
- return this.#put(rows, false);
2831
+ scan(input, options) {
2832
+ validatePage(input);
2833
+ const source = this.#scan(input, options);
2834
+ if (this.#context !== void 0) return new DatabaseIterator(source, this.#context);
2835
+ return this.#scope === void 0 ? source : this.#scope.stream(source);
2836
+ }
2837
+ set(rows, options) {
2838
+ return this.#track(async () => {
2839
+ await this.#wait(options?.signal);
2840
+ if (isArray(rows)) return this.#each(rows, (row) => this.#put(row, false, options), options?.signal);
2841
+ return this.#put(rows, false, options);
2842
+ });
1964
2843
  }
1965
- async add(rows, options) {
1966
- checkAbort(options?.signal);
1967
- await this.#ready();
1968
- if (isArray(rows)) return this.#each(rows, (row) => this.#put(row, true), options?.signal);
1969
- return this.#put(rows, true);
2844
+ add(rows, options) {
2845
+ return this.#track(async () => {
2846
+ await this.#wait(options?.signal);
2847
+ if (isArray(rows)) return this.#each(rows, (row) => this.#put(row, true, options), options?.signal);
2848
+ return this.#put(rows, true, options);
2849
+ });
1970
2850
  }
1971
- async update(keys, changes, options) {
1972
- checkAbort(options?.signal);
1973
- await this.#ready();
1974
- if (isArray(keys)) return this.#each(keys, (key) => this.#updateOne(key, changes), options?.signal);
1975
- return this.#updateOne(keys, changes);
2851
+ update(keys, changes, options) {
2852
+ return this.#track(async () => {
2853
+ await this.#wait(options?.signal);
2854
+ if (isArray(keys)) return this.#each(keys, (key) => this.#updateOne(key, changes, options), options?.signal);
2855
+ return this.#updateOne(keys, changes, options);
2856
+ });
1976
2857
  }
1977
- async remove(keys, options) {
1978
- checkAbort(options?.signal);
1979
- await this.#ready();
1980
- if (isArray(keys)) return this.#each(keys, (key) => this.#delete(key), options?.signal);
1981
- return this.#delete(keys);
2858
+ remove(keys, options) {
2859
+ return this.#track(async () => {
2860
+ await this.#wait(options?.signal);
2861
+ if (isArray(keys)) return this.#each(keys, (key) => this.#delete(key, options), options?.signal);
2862
+ return this.#delete(keys, options);
2863
+ });
1982
2864
  }
1983
- async clear() {
1984
- await this.#ready();
1985
- await this.#driver.clear(this.#name);
1986
- this.#emitter.emit("clear");
2865
+ clear() {
2866
+ return this.#track(async () => {
2867
+ await this.#ready();
2868
+ await this.#driver.clear(this.#name);
2869
+ this.#emitter.emit("clear");
2870
+ });
1987
2871
  }
1988
2872
  query() {
1989
2873
  return new Query(this);
1990
2874
  }
1991
- async cursor() {
2875
+ cursor() {
2876
+ return this.#track(async () => {
2877
+ await this.#ready();
2878
+ let initializing = true;
2879
+ const cursor = new Cursor(await this.#driver.keys(this.#name), (key) => this.#readCursor(key), (key, changes) => this.#updateCursor(key, changes), (key) => this.#deleteCursor(key), (operation) => initializing ? operation() : this.#track(operation));
2880
+ await cursor.next();
2881
+ initializing = false;
2882
+ return cursor;
2883
+ });
2884
+ }
2885
+ async *#scan(input, options) {
2886
+ checkAbort(options?.signal);
1992
2887
  await this.#ready();
1993
- const cursor = new Cursor(this, await this.#driver.keys(this.#name));
1994
- await cursor.next();
1995
- return cursor;
2888
+ const conditions = input?.conditions;
2889
+ const offset = input?.offset ?? 0;
2890
+ const limit = input?.limit;
2891
+ let matched = 0;
2892
+ let yielded = 0;
2893
+ const iterator = (this.#driver.stream === void 0 ? this.#driver.scan(this.#name) : this.#driver.stream(this.#name, conditions === void 0 ? {} : { conditions }))[Symbol.asyncIterator]();
2894
+ try {
2895
+ while (true) {
2896
+ await this.#ready();
2897
+ checkAbort(options?.signal);
2898
+ if (limit !== void 0 && yielded >= limit) return;
2899
+ const step = await iterator.next();
2900
+ await this.#ready();
2901
+ checkAbort(options?.signal);
2902
+ if (step.done === true) return;
2903
+ const row = step.value;
2904
+ if (conditions !== void 0 && conditions.length > 0 && !matchesQuery(row, conditions)) continue;
2905
+ const narrowed = this.#cast(row);
2906
+ if (narrowed === void 0) continue;
2907
+ if (matched < offset) {
2908
+ matched += 1;
2909
+ continue;
2910
+ }
2911
+ matched += 1;
2912
+ yielded += 1;
2913
+ yield narrowed;
2914
+ }
2915
+ } finally {
2916
+ await iterator.return?.();
2917
+ }
1996
2918
  }
1997
- async #each(items, operation, signal) {
2919
+ async #each(elements, operation, signal) {
1998
2920
  const results = [];
1999
- for (const item of items) {
2921
+ for (const element of elements) {
2000
2922
  checkAbort(signal);
2001
- results.push(await operation(item));
2923
+ results.push(await operation(element));
2002
2924
  }
2003
2925
  return results;
2004
2926
  }
2005
2927
  async #read(key) {
2006
2928
  return this.#cast(await this.#driver.read(this.#name, key));
2007
2929
  }
2930
+ async #readCursor(key) {
2931
+ await this.#ready();
2932
+ return this.#read(key);
2933
+ }
2008
2934
  async #resolveOne(key) {
2009
2935
  const row = await this.#read(key);
2010
2936
  if (row === void 0) throw new DatabaseError("NOT_FOUND", `No row '${key}' in table '${this.#name}'`, {
@@ -2013,29 +2939,79 @@ var Table = class {
2013
2939
  });
2014
2940
  return row;
2015
2941
  }
2016
- async #put(row, exclusive) {
2942
+ async #put(row, insert, options) {
2017
2943
  const validated = this.#validate(this.#prepare(row));
2018
2944
  const key = this.#resolveKey(validated);
2019
- if (exclusive && await this.#driver.read(this.#name, key) !== void 0) throw new DatabaseError("CONFLICT", `Row '${key}' already exists in table '${this.#name}'`, {
2020
- table: this.#name,
2021
- key
2022
- });
2023
- await this.#driver.write(this.#name, key, validated);
2945
+ if (insert) await this.#driver.insert(this.#name, key, validated, options);
2946
+ else await this.#driver.write(this.#name, key, validated, options);
2024
2947
  this.#emitter.emit("write", key);
2025
2948
  return key;
2026
2949
  }
2027
- async #updateOne(key, changes) {
2950
+ async #updateOne(key, changes, options) {
2028
2951
  const existing = await this.#driver.read(this.#name, key);
2029
- if (existing === void 0) return false;
2030
- await this.#driver.write(this.#name, key, this.#validate(Object.assign({}, existing, changes)));
2952
+ if (existing === void 0) {
2953
+ checkAbort(options?.signal);
2954
+ return false;
2955
+ }
2956
+ const input = changes;
2957
+ if (isRecord(input) && Object.hasOwn(input, this.#key) && !equalsValue(input[this.#key], key)) throw new DatabaseError("VALIDATION", `Update cannot change primary column '${this.#key}' on table '${this.#name}'`, {
2958
+ table: this.#name,
2959
+ column: this.#key,
2960
+ key
2961
+ });
2962
+ await this.#driver.write(this.#name, key, this.#validate(Object.assign({}, existing, changes)), options);
2031
2963
  this.#emitter.emit("write", key);
2032
2964
  return true;
2033
2965
  }
2034
- async #delete(key) {
2035
- const removed = await this.#driver.delete(this.#name, key);
2966
+ async #updateCursor(key, changes) {
2967
+ await this.#wait(void 0);
2968
+ return this.#updateOne(key, changes);
2969
+ }
2970
+ async #delete(key, options) {
2971
+ const removed = await this.#driver.delete(this.#name, key, options);
2036
2972
  if (removed) this.#emitter.emit("remove", key);
2037
2973
  return removed;
2038
2974
  }
2975
+ async #deleteCursor(key) {
2976
+ await this.#wait(void 0);
2977
+ return this.#delete(key);
2978
+ }
2979
+ async #wait(signal) {
2980
+ checkAbort(signal);
2981
+ const ready = this.#ready();
2982
+ if (signal === void 0) {
2983
+ await ready;
2984
+ return;
2985
+ }
2986
+ const cleanup = new AbortController();
2987
+ try {
2988
+ await new Promise((resolve, reject) => {
2989
+ signal.addEventListener("abort", () => {
2990
+ ready.catch(() => {});
2991
+ try {
2992
+ checkAbort(signal);
2993
+ } catch (error) {
2994
+ reject(error);
2995
+ }
2996
+ }, {
2997
+ once: true,
2998
+ signal: cleanup.signal
2999
+ });
3000
+ ready.then(resolve, reject);
3001
+ if (signal.aborted) {
3002
+ ready.catch(() => {});
3003
+ try {
3004
+ checkAbort(signal);
3005
+ } catch (error) {
3006
+ reject(error);
3007
+ }
3008
+ }
3009
+ });
3010
+ } finally {
3011
+ cleanup.abort();
3012
+ }
3013
+ checkAbort(signal);
3014
+ }
2039
3015
  async #collect() {
2040
3016
  const rows = [];
2041
3017
  for await (const row of this.#driver.scan(this.#name)) rows.push(row);
@@ -2044,21 +3020,38 @@ var Table = class {
2044
3020
  #prepare(row) {
2045
3021
  if (!isRecord(row)) throw new DatabaseError("VALIDATION", `Row for table '${this.#name}' is not a record`, { table: this.#name });
2046
3022
  const prepared = { ...row };
2047
- if (prepared[this.#key] === void 0) {
2048
- if (this.#generate === void 0) throw new DatabaseError("VALIDATION", `Row for table '${this.#name}' is missing its key column '${this.#key}' and no key factory was provided`, {
3023
+ if (prepared[this.#key] === void 0) if (this.#generate !== void 0) try {
3024
+ prepared[this.#key] = this.#generate();
3025
+ } catch (cause) {
3026
+ throw new DatabaseError("VALIDATION", `Failed to generate primary column '${this.#key}' for table '${this.#name}'`, {
2049
3027
  table: this.#name,
2050
- column: this.#key
3028
+ column: this.#key,
3029
+ cause
3030
+ });
3031
+ }
3032
+ else try {
3033
+ prepared[this.#key] = crypto.randomUUID();
3034
+ } catch (cause) {
3035
+ throw new DatabaseError("DRIVER", `Host failed to generate primary column '${this.#key}' for table '${this.#name}'`, {
3036
+ table: this.#name,
3037
+ column: this.#key,
3038
+ cause
2051
3039
  });
2052
- prepared[this.#key] = this.#generate();
2053
3040
  }
2054
3041
  return prepared;
2055
3042
  }
2056
3043
  #validate(row) {
2057
3044
  const parsed = this.#contract.parse(row);
2058
- if (parsed === void 0 || !isRecord(parsed)) throw new DatabaseError("VALIDATION", `Row failed the '${this.#name}' contract`, {
2059
- table: this.#name,
2060
- row
2061
- });
3045
+ if (parsed === void 0 || !isRecord(parsed)) {
3046
+ const [fault] = this.#contract.explain(row);
3047
+ throw new DatabaseError("VALIDATION", `Row failed the '${this.#name}' contract`, {
3048
+ table: this.#name,
3049
+ ...fault === void 0 ? {} : {
3050
+ field: fault.path,
3051
+ reason: fault.reason
3052
+ }
3053
+ });
3054
+ }
2062
3055
  return parsed;
2063
3056
  }
2064
3057
  #resolveKey(row) {
@@ -2072,80 +3065,99 @@ var Table = class {
2072
3065
  #cast(row) {
2073
3066
  return row !== void 0 && this.#guard(row) ? row : void 0;
2074
3067
  }
3068
+ #track(operation) {
3069
+ if (this.#context !== void 0) return this.#context.track(operation);
3070
+ return this.#scope === void 0 ? operation() : this.#scope.track(operation);
3071
+ }
2075
3072
  };
2076
3073
  //#endregion
2077
- //#region src/core/Database.ts
3074
+ //#region src/core/DatabaseTransaction.ts
2078
3075
  /**
2079
- * A database the ergonomic entry point over a {@link DriverInterface}.
3076
+ * A table-only database view bound to one driver transaction scope.
3077
+ *
3078
+ * @typeParam T - The declared table shape map
2080
3079
  *
2081
3080
  * @remarks
2082
- * Owns the driver and a `tables` shape map, connecting the driver lazily on first
2083
- * use so a freshly created database is immediately usable. `table(name)` returns
2084
- * a table typed by that table's shape `Infer`. `import` registers more tables and
2085
- * returns a database re-typed with them over the **same** driver and storage;
2086
- * `export` emits a portable {@link TableExport} per table. `transaction` snapshots
2087
- * the driver, runs the scope, and rolls every table back if it throws — an
2088
- * optimistic model that works uniformly across backends rather than reconciling
2089
- * SQL's and IndexedDB's incompatible native transactions.
3081
+ * The view gives transaction work the same typed tables as its owning database
3082
+ * while enforcing a materially narrower contract and lifetime. `table` and
3083
+ * every table operation call the owning scope check, so a captured capability
3084
+ * cannot escape its transaction.
3085
+ */
3086
+ var DatabaseTransaction = class {
3087
+ #driver;
3088
+ #tables;
3089
+ #primary;
3090
+ #generate;
3091
+ #error;
3092
+ #scope;
3093
+ constructor(driver, tables, primary, generate, error, scope) {
3094
+ this.#driver = driver;
3095
+ this.#tables = tables;
3096
+ this.#primary = primary;
3097
+ this.#generate = generate;
3098
+ this.#error = error;
3099
+ this.#scope = scope;
3100
+ }
3101
+ table(name) {
3102
+ this.#scope.check();
3103
+ const columns = this.#columns(name);
3104
+ return this.#build(name, this.#key(name), createContract(objectShape(columns)));
3105
+ }
3106
+ #build(name, key, contract) {
3107
+ return new Table(() => Promise.resolve(), this.#driver, name, key, contract, this.#generate, this.#error, void 0, this.#scope);
3108
+ }
3109
+ #key(name) {
3110
+ return this.#primary[name] ?? "id";
3111
+ }
3112
+ #columns(name) {
3113
+ const columns = this.#tables[name];
3114
+ if (columns === void 0) throw new DatabaseError("NOT_FOUND", `Table '${name}' is not declared`, { table: name });
3115
+ return columns;
3116
+ }
3117
+ };
3118
+ //#endregion
3119
+ //#region src/core/Database.ts
3120
+ /**
3121
+ * A typed database view over one shared internal lifecycle and storage context.
2090
3122
  *
2091
3123
  * @remarks
2092
- * - **Versioned (optional).** When {@link DatabaseOptions.version} is set and the driver
2093
- * implements both {@link DriverInterface.meta} and {@link DriverInterface.stamp},
2094
- * `open()` reconciles the driver's persisted {@link DriverMeta} against the declared
2095
- * version INSIDE the same lazy-connect chain, AFTER the `open` event fires — see
2096
- * {@link DatabaseOptions.version} for the full reconciliation contract.
2097
- * - **Observable (§13).** The owned {@link emitter} ({@link DatabaseEventMap}) carries the
2098
- * connection + transaction lifecycle — `open` / `close` / `transaction` / `commit` /
2099
- * `rollback` — for fire-and-forget observers, ALONGSIDE each table's per-row events. Every
2100
- * event is emitted directly, strictly AFTER the relevant transition: `commit` only after
2101
- * the scope succeeds, `rollback` only after every table is restored. The `rollback` emit
2102
- * OBSERVES the propagated error — it never swallows it (the original throw propagates
2103
- * exactly as before). The emitter isolates a listener throw and routes it to its `error`
2104
- * handler (the `error` option), so observation can never reorder, throw into, or corrupt
2105
- * the snapshot / commit / rollback flow.
3124
+ * Each view owns only its table contracts, primary columns, indexes, and key
3125
+ * generator. Imported views register their physical schemas with the same
3126
+ * internal context before opening begins, so every view observes one driver,
3127
+ * merged schema, emitter, status, transaction boundary, and terminal close.
2106
3128
  */
2107
3129
  var Database = class Database {
2108
- #driver;
3130
+ #context;
2109
3131
  #tables;
2110
- #keys;
3132
+ #primary;
2111
3133
  #indexes;
2112
- #name;
2113
3134
  #generate;
2114
- #version;
2115
- #emitter;
2116
- #status = "idle";
2117
- #ready;
2118
3135
  constructor(options) {
2119
- this.#driver = options.driver;
2120
3136
  this.#tables = options.tables;
2121
- this.#keys = options.keys ?? {};
3137
+ this.#primary = options.primary ?? {};
2122
3138
  this.#indexes = options.indexes ?? {};
2123
- this.#name = options.name ?? "database";
2124
- this.#generate = options.key;
2125
- this.#version = options.version;
2126
- this.#emitter = new Emitter({
2127
- ...options.on !== void 0 ? { on: options.on } : {},
2128
- ...options.error !== void 0 ? { error: options.error } : {}
2129
- });
3139
+ this.#generate = options.generator;
3140
+ this.#context = new DatabaseContext(options);
3141
+ this.#context.register(this.#schema());
2130
3142
  }
2131
3143
  get emitter() {
2132
- return this.#emitter;
3144
+ return this.#context.emitter;
2133
3145
  }
2134
3146
  get name() {
2135
- return this.#name;
3147
+ return this.#context.name;
2136
3148
  }
2137
3149
  get status() {
2138
- return this.#status;
3150
+ return this.#context.status;
2139
3151
  }
2140
3152
  table(name) {
2141
- if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
3153
+ if (this.#context.status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#context.name}' is closed`, { name: this.#context.name });
2142
3154
  const columns = this.#columns(name);
2143
3155
  return this.#build(name, this.#key(name), createContract(objectShape(columns)));
2144
3156
  }
2145
- import(tables, keys) {
3157
+ import(tables, primary) {
2146
3158
  return this.#spawn(tables, {
2147
- ...this.#keys,
2148
- ...keys
3159
+ ...this.#primary,
3160
+ ...primary
2149
3161
  });
2150
3162
  }
2151
3163
  export() {
@@ -2153,109 +3165,44 @@ var Database = class Database {
2153
3165
  for (const name of Object.keys(this.#tables)) {
2154
3166
  const columns = this.#columns(name);
2155
3167
  result[name] = {
2156
- key: this.#key(name),
3168
+ primary: this.#key(name),
2157
3169
  columns,
2158
3170
  schema: compileSchema(objectShape(columns))
2159
3171
  };
2160
3172
  }
2161
3173
  return result;
2162
3174
  }
2163
- async open() {
2164
- await this.#connect();
3175
+ open() {
3176
+ return this.#context.open();
2165
3177
  }
2166
- async close() {
2167
- this.#status = "closed";
2168
- this.#ready = void 0;
2169
- await this.#driver.close();
2170
- this.#emitter.emit("close");
3178
+ close() {
3179
+ return this.#context.close();
2171
3180
  }
2172
- /**
2173
- * Run `scope` transactionally: commit its writes on success, roll every table
2174
- * back if it throws.
2175
- *
2176
- * @remarks
2177
- * When the driver implements the optional native {@link DriverInterface.transaction}
2178
- * hook, that native `commit` / `rollback` handle drives the transaction; otherwise
2179
- * the universal snapshot floor (`driver.snapshot()`) runs unchanged. Either path
2180
- * emits the same `transaction` / `commit` / `rollback` lifecycle (AGENTS §13).
2181
- * `options.signal` is checked ONCE at entry, before connecting or starting any
2182
- * transactional work — an already-aborted signal throws `ABORTED` and neither the
2183
- * native hook nor the snapshot floor is invoked. Nesting is unguarded and
2184
- * unsupported exactly as before: this is a single-writer model, not reentrant.
2185
- * On the native path, a `scope` throw rolls back via the native handle; a
2186
- * native `commit` failure propagates as-is with no rollback attempt — the
2187
- * engine owns transaction state after a failed COMMIT.
2188
- *
2189
- * @param scope - The transactional work to run
2190
- * @param options - `{ signal }` to abort before the transaction starts
2191
- * @returns The scope's resolved value
2192
- * @throws An `ABORTED` {@link DatabaseError} when `options.signal` has already fired
2193
- */
2194
- async transaction(scope, options) {
2195
- checkAbort(options?.signal);
2196
- await this.#connect();
2197
- const native = await this.#driver.transaction?.();
2198
- if (native !== void 0) {
2199
- this.#emitter.emit("transaction");
2200
- let value;
2201
- try {
2202
- value = await scope();
2203
- } catch (error) {
2204
- await native.rollback();
2205
- this.#emitter.emit("rollback", error);
2206
- throw error;
2207
- }
2208
- await native.commit();
2209
- this.#emitter.emit("commit");
2210
- return value;
2211
- }
2212
- const rollback = await this.#driver.snapshot();
2213
- this.#emitter.emit("transaction");
2214
- try {
2215
- const value = await scope();
2216
- this.#emitter.emit("commit");
2217
- return value;
2218
- } catch (error) {
2219
- await rollback();
2220
- this.#emitter.emit("rollback", error);
2221
- throw error;
2222
- }
3181
+ transaction(scope, options) {
3182
+ return this.#context.transaction(async (storage, lifetime) => {
3183
+ const transaction = new DatabaseTransaction(storage, this.#tables, this.#primary, this.#generate, this.#context.error, lifetime);
3184
+ return this.#settle(scope, transaction, lifetime);
3185
+ }, options);
2223
3186
  }
2224
- /**
2225
- * Diff `deployed` against this database's declared schema and apply the
2226
- * resulting plan through the driver's optional `migrate` hook.
2227
- *
2228
- * @param deployed - The schema currently deployed, as {@link TableSchema}s
2229
- * @param options - `{ signal }` to abort before the migration starts
2230
- * @returns The applied {@link Migration} plan
2231
- * @throws A `MIGRATION` {@link DatabaseError} when the driver does not
2232
- * implement `migrate`, or when a step references an unknown table
2233
- * (propagated from the driver)
2234
- * @throws An `ABORTED` {@link DatabaseError} when `options.signal` has
2235
- * already fired at entry
2236
- */
2237
- async migrate(deployed, options) {
2238
- checkAbort(options?.signal);
2239
- await this.#connect();
2240
- const plan = planMigration(deployed, this.#schema());
2241
- if (this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, { name: this.#name });
2242
- await this.#apply(plan);
2243
- return plan;
3187
+ migrate(deployed, options) {
3188
+ return this.#context.migrate(deployed, options);
2244
3189
  }
2245
3190
  #build(name, key, contract) {
2246
- return new Table(() => this.#connect(), this.#driver, name, key, contract, this.#generate);
3191
+ return new Table(() => this.#context.connect(), this.#context.driver, name, key, contract, this.#generate, this.#context.error, this.#context);
2247
3192
  }
2248
- #spawn(tables, keys) {
2249
- return new Database({
2250
- driver: this.#driver,
3193
+ #spawn(tables, primary) {
3194
+ return Database.#attach({
3195
+ driver: this.#context.driver,
2251
3196
  tables,
2252
- keys,
2253
- name: this.#name,
2254
- ...this.#generate === void 0 ? {} : { key: this.#generate }
2255
- });
3197
+ primary,
3198
+ name: this.#context.name,
3199
+ ...this.#context.error === void 0 ? {} : { error: this.#context.error },
3200
+ ...this.#generate === void 0 ? {} : { generator: this.#generate },
3201
+ ...this.#context.version === void 0 ? {} : { version: this.#context.version }
3202
+ }, this.#context);
2256
3203
  }
2257
3204
  #key(name) {
2258
- return this.#keys[name] ?? "id";
3205
+ return this.#primary[name] ?? "id";
2259
3206
  }
2260
3207
  #columns(name) {
2261
3208
  const columns = this.#tables[name];
@@ -2268,74 +3215,36 @@ var Database = class Database {
2268
3215
  return {
2269
3216
  name,
2270
3217
  primary: this.#key(name),
2271
- columns: Object.entries(columns).map(([column, shape]) => {
2272
- return {
2273
- name: column,
2274
- type: shapeToColumnType(shape),
2275
- nullable: shape.type === "optional" || shape.type === "nullable"
2276
- };
2277
- }),
3218
+ columns: Object.entries(columns).map(([column, shape]) => shapeToColumnSchema(column, shape)),
2278
3219
  indexes: this.#indexes[name] ?? []
2279
3220
  };
2280
3221
  });
2281
3222
  }
2282
- #connect() {
2283
- if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
2284
- if (this.#ready === void 0) this.#ready = this.#driver.open(this.#schema()).then(async () => {
2285
- if (this.#status === "idle") this.#status = "open";
2286
- this.#emitter.emit("open");
2287
- await this.#reconcile();
2288
- });
2289
- return this.#ready;
2290
- }
2291
- async #reconcile() {
2292
- if (this.#version === void 0 || this.#driver.meta === void 0) return;
2293
- const declared = this.#schema();
2294
- const meta = await this.#driver.meta();
2295
- if (meta === void 0) {
2296
- await this.#stamp();
2297
- return;
2298
- }
2299
- if (meta.version > this.#version) throw new DatabaseError("MIGRATION", `Database '${this.#name}' store version ${meta.version} is newer than declared version ${this.#version}`, {
2300
- name: this.#name,
2301
- stored: meta.version,
2302
- declared: this.#version
2303
- });
2304
- if (meta.version < this.#version) {
2305
- const plan = planMigration(meta.schema, declared, meta.version, this.#version);
2306
- if (plan.steps.length > 0 && this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, {
2307
- name: this.#name,
2308
- stored: meta.version,
2309
- declared: this.#version
2310
- });
2311
- await this.#apply(plan);
2312
- }
2313
- }
2314
- async #apply(plan) {
2315
- const native = await this.#driver.transaction?.();
2316
- if (native !== void 0) {
2317
- try {
2318
- await this.#driver.migrate?.(plan);
2319
- await this.#stamp();
2320
- } catch (error) {
2321
- await native.rollback();
2322
- throw error;
2323
- }
2324
- await native.commit();
2325
- this.#emitter.emit("migrate", plan);
2326
- return;
2327
- }
2328
- await this.#driver.migrate?.(plan);
2329
- await this.#stamp();
2330
- this.#emitter.emit("migrate", plan);
2331
- }
2332
- async #stamp() {
2333
- if (this.#version === void 0 || this.#driver.stamp === void 0) return;
2334
- const meta = {
2335
- version: this.#version,
2336
- schema: this.#schema()
2337
- };
2338
- await this.#driver.stamp(meta);
3223
+ async #settle(scope, transaction, lifetime) {
3224
+ const outcome = await Promise.resolve().then(() => scope(transaction)).then((value) => ({
3225
+ success: true,
3226
+ value
3227
+ }), (error) => ({
3228
+ success: false,
3229
+ error
3230
+ }));
3231
+ lifetime.stop();
3232
+ const drained = await lifetime.drain().then(() => ({
3233
+ success: true,
3234
+ value: void 0
3235
+ }), (error) => ({
3236
+ success: false,
3237
+ error
3238
+ }));
3239
+ if (!outcome.success) return outcome;
3240
+ if (!drained.success) return drained;
3241
+ return outcome;
3242
+ }
3243
+ static #attach(options, context) {
3244
+ const database = new Database(options);
3245
+ database.#context = context;
3246
+ context.register(database.#schema());
3247
+ return database;
2339
3248
  }
2340
3249
  };
2341
3250
  //#endregion
@@ -2351,29 +3260,54 @@ var Database = class Database {
2351
3260
  * snapshot capture and restore — so a caller mutating a nested field of an input
2352
3261
  * row, a returned row, or a row mutated in place between snapshot and rollback
2353
3262
  * can never perturb stored state (AGENTS §11); a shallow `{ ...row }` spread
2354
- * would still share nested object/array references. `snapshot`
3263
+ * would still share nested object/array references. Metadata instead routes
3264
+ * through `cloneDriverMetadata`: `stamp` and migration snapshot exact JSON at
3265
+ * ingress, and `metadata` returns a distinct deeply frozen owned copy. `snapshot`
2355
3266
  * clones every table to give transactions an exact rollback point. `scan` and
2356
3267
  * `keys` yield in key order — sorted by the core {@link compareValues} total
2357
3268
  * order, the same contract the SQLite (`ORDER BY`) and IndexedDB (key-ordered
2358
3269
  * reads) backends honor, so an unordered read agrees across every backend rather
2359
3270
  * than leaking Map insertion order. A persistent backend (IndexedDB, SQLite)
2360
- * implements the same nine methods over real storage.
3271
+ * implements the same required methods over real storage.
2361
3272
  */
2362
3273
  var MemoryDriver = class {
2363
3274
  #tables = /* @__PURE__ */ new Map();
2364
- #meta;
3275
+ #identities = /* @__PURE__ */ new Map();
3276
+ #schema = [];
3277
+ #metadata;
2365
3278
  async open(schema) {
2366
- for (const table of schema) if (!this.#tables.has(table.name)) this.#tables.set(table.name, /* @__PURE__ */ new Map());
3279
+ const owned = normalizeDriverSchema(schema);
3280
+ const deployed = normalizeDriverSchema(this.#metadata?.schema ?? owned);
3281
+ const names = new Set(deployed.map((table) => table.name));
3282
+ for (const name of this.#identities.keys()) if (!names.has(name)) this.#identities.delete(name);
3283
+ for (const table of deployed) {
3284
+ if (!this.#tables.has(table.name)) this.#tables.set(table.name, /* @__PURE__ */ new Map());
3285
+ if (!this.#identities.has(table.name)) this.#identities.set(table.name, {});
3286
+ }
3287
+ this.#schema = deployed;
2367
3288
  }
2368
3289
  async close() {}
2369
3290
  async read(table, key) {
2370
3291
  const row = this.#store(table).get(key);
2371
3292
  return row === void 0 ? void 0 : structuredClone(row);
2372
3293
  }
2373
- async write(table, key, row) {
2374
- this.#store(table).set(key, structuredClone(row));
3294
+ async write(table, key, row, options) {
3295
+ checkAbort(options?.signal);
3296
+ const primary = this.#table(table).primary;
3297
+ this.#store(table).set(key, structuredClone(bindRowKey(row, primary, key)));
3298
+ }
3299
+ async insert(table, key, row, options) {
3300
+ checkAbort(options?.signal);
3301
+ const store = this.#store(table);
3302
+ if (store.has(key)) throw new DatabaseError("CONFLICT", `Row '${key}' already exists in table '${table}'`, {
3303
+ table,
3304
+ key
3305
+ });
3306
+ const primary = this.#table(table).primary;
3307
+ store.set(key, structuredClone(bindRowKey(row, primary, key)));
2375
3308
  }
2376
- async delete(table, key) {
3309
+ async delete(table, key, options) {
3310
+ checkAbort(options?.signal);
2377
3311
  return this.#store(table).delete(key);
2378
3312
  }
2379
3313
  async keys(table) {
@@ -2392,17 +3326,17 @@ var MemoryDriver = class {
2392
3326
  * @remarks
2393
3327
  * Iterates the table's keys in the same key order `scan` and `keys` yield
2394
3328
  * (sorted by {@link compareValues}), testing each row against
2395
- * `criteria.conditions` (via {@link matchesCriteria}) before counting it
3329
+ * `input.conditions` (via {@link matchesQuery}) before counting it
2396
3330
  * toward `offset` / `limit`. Both are applied lazily as matches are found —
2397
3331
  * `offset` matches are skipped without being yielded, and iteration stops the
2398
3332
  * instant `limit` yields have been produced, so a large table is never fully
2399
- * walked for a small page. `criteria.order` is IGNORED (the same contract as
3333
+ * walked for a small page. `input.order` is IGNORED (the same contract as
2400
3334
  * `TableInterface.scan` and `QueryInterface.stream`): streaming yields key
2401
3335
  * order, sorted output is `records()`'s job. Rows yield copy-out (AGENTS
2402
3336
  * §11), and an unknown table mirrors `scan`'s empty-yield behavior.
2403
3337
  *
2404
3338
  * @param table - The table to stream
2405
- * @param criteria - The filter / offset / limit to apply lazily
3339
+ * @param input - The filter / offset / limit to apply lazily
2406
3340
  *
2407
3341
  * @example
2408
3342
  * ```ts
@@ -2411,18 +3345,22 @@ var MemoryDriver = class {
2411
3345
  * }
2412
3346
  * ```
2413
3347
  */
2414
- async *stream(table, criteria) {
3348
+ stream(table, input) {
3349
+ validatePage(input);
3350
+ return this.#stream(table, input);
3351
+ }
3352
+ async *#stream(table, input) {
2415
3353
  const store = this.#store(table);
2416
- const conditions = criteria.conditions;
2417
- const offset = criteria.offset ?? 0;
2418
- const limit = criteria.limit;
3354
+ const conditions = input.conditions;
3355
+ const offset = input.offset ?? 0;
3356
+ const limit = input.limit;
2419
3357
  let skipped = 0;
2420
3358
  let yielded = 0;
2421
3359
  for (const key of this.#ordered(table)) {
2422
3360
  if (limit !== void 0 && yielded >= limit) return;
2423
3361
  const row = store.get(key);
2424
3362
  if (row === void 0) continue;
2425
- if (conditions !== void 0 && conditions.length > 0 && !matchesCriteria(row, conditions)) continue;
3363
+ if (conditions !== void 0 && conditions.length > 0 && !matchesQuery(row, conditions)) continue;
2426
3364
  if (skipped < offset) {
2427
3365
  skipped += 1;
2428
3366
  continue;
@@ -2438,90 +3376,149 @@ var MemoryDriver = class {
2438
3376
  * Capture the current state and return a thunk that rolls back to it.
2439
3377
  *
2440
3378
  * @remarks
2441
- * `tables` omitted clones and restores the WHOLE store, byte-identical to the
2442
- * prior whole-store behavior. `tables` provided clones ONLY the named tables,
2443
- * and the returned thunk restores ONLY those every other table keeps
2444
- * whatever it was mutated to after the snapshot was taken.
3379
+ * Capture owns rows, schema, and one session-local table identity. Replay
3380
+ * adapts rows to each surviving same-identity table's current schema before
3381
+ * changing storage. Removed or replaced tables are skipped; uncaptured and
3382
+ * later-added tables retain their current rows. Schema and metadata are never
3383
+ * restored.
2445
3384
  *
2446
3385
  * @param tables - The table names to scope the snapshot to; omitted captures every table
2447
3386
  * @returns A thunk that restores the captured tables
2448
3387
  */
2449
3388
  async snapshot(tables) {
2450
- if (tables === void 0) {
2451
- const copy = /* @__PURE__ */ new Map();
2452
- for (const [name, store] of this.#tables) {
2453
- const cloned = /* @__PURE__ */ new Map();
2454
- for (const [key, row] of store) cloned.set(key, structuredClone(row));
2455
- copy.set(name, cloned);
2456
- }
2457
- return async () => {
2458
- this.#tables.clear();
2459
- for (const [name, store] of copy) {
2460
- const restored = /* @__PURE__ */ new Map();
2461
- for (const [key, row] of store) restored.set(key, structuredClone(row));
2462
- this.#tables.set(name, restored);
2463
- }
2464
- };
2465
- }
2466
- const copy = /* @__PURE__ */ new Map();
2467
- for (const name of tables) {
3389
+ const names = tables === void 0 ? this.#schema.map((table) => table.name) : [...new Set(tables)].filter((name) => this.#schema.some((table) => table.name === name));
3390
+ const captured = /* @__PURE__ */ new Map();
3391
+ for (const name of names) {
3392
+ const schema = this.#schema.find((table) => table.name === name);
2468
3393
  const store = this.#tables.get(name);
2469
- if (store === void 0) continue;
2470
- const cloned = /* @__PURE__ */ new Map();
2471
- for (const [key, row] of store) cloned.set(key, structuredClone(row));
2472
- copy.set(name, cloned);
3394
+ const identity = this.#identities.get(name);
3395
+ if (schema === void 0 || store === void 0 || identity === void 0) continue;
3396
+ const rows = /* @__PURE__ */ new Map();
3397
+ for (const [key, row] of store) rows.set(key, structuredClone(row));
3398
+ captured.set(name, {
3399
+ identity,
3400
+ rows,
3401
+ schema
3402
+ });
2473
3403
  }
2474
3404
  return async () => {
2475
- for (const [name, store] of copy) {
2476
- const restored = /* @__PURE__ */ new Map();
2477
- for (const [key, row] of store) restored.set(key, structuredClone(row));
2478
- this.#tables.set(name, restored);
3405
+ const replacements = /* @__PURE__ */ new Map();
3406
+ for (const [name, capture] of captured) {
3407
+ const schema = this.#schema.find((table) => table.name === name);
3408
+ const store = this.#tables.get(name);
3409
+ if (schema === void 0 || store === void 0 || this.#identities.get(name) !== capture.identity) continue;
3410
+ const plan = planMigration([capture.schema], [schema]);
3411
+ const entries = [...capture.rows.entries()];
3412
+ const migrated = migrateRows(entries.map(([, row]) => row), plan.steps);
3413
+ if (migrated.length !== entries.length) throw new DatabaseError("MIGRATION", "Snapshot row count changed during migration", { table: name });
3414
+ const rows = /* @__PURE__ */ new Map();
3415
+ for (const [index, [key]] of entries.entries()) {
3416
+ const row = migrated[index];
3417
+ if (!isKey(key) || row === void 0) throw new DatabaseError("MIGRATION", "Snapshot row has no usable primary key", {
3418
+ table: name,
3419
+ column: schema.primary,
3420
+ index
3421
+ });
3422
+ rows.set(key, structuredClone(bindRowKey(row, schema.primary, key)));
3423
+ }
3424
+ replacements.set(store, rows);
3425
+ }
3426
+ for (const [store, rows] of replacements) {
3427
+ store.clear();
3428
+ for (const [key, row] of rows) store.set(key, row);
2479
3429
  }
2480
3430
  };
2481
3431
  }
2482
3432
  /**
2483
- * Return the persisted {@link DriverMeta}, or `undefined` when the store has
3433
+ * Return the persisted {@link DriverMetadata}, or `undefined` when the store has
2484
3434
  * never been stamped.
2485
3435
  *
2486
3436
  * @remarks
2487
3437
  * In-process only — the metadata lives in this instance's memory, exactly
2488
- * like the rest of this driver's storage. A driver-conformance-valid
2489
- * implementation of the optional `meta` / `stamp` pair.
3438
+ * like the rest of this driver's storage. The returned value is a distinct
3439
+ * deeply frozen owned snapshot. A driver-conformance-valid implementation of
3440
+ * the optional `metadata` / `stamp` pair.
2490
3441
  *
2491
- * @returns The last-stamped {@link DriverMeta}, or `undefined`
3442
+ * @returns The last-stamped {@link DriverMetadata}, or `undefined`
2492
3443
  */
2493
- async meta() {
2494
- return this.#meta;
3444
+ async metadata() {
3445
+ return this.#metadata === void 0 ? void 0 : cloneDriverMetadata(this.#metadata);
2495
3446
  }
2496
3447
  /**
2497
- * Persist `meta` verbatim for a later `meta()` to return.
3448
+ * Persist an owned snapshot for a later `metadata()` to return.
2498
3449
  *
2499
- * @param meta - The {@link DriverMeta} to persist
3450
+ * @param metadata - The {@link DriverMetadata} to persist
2500
3451
  */
2501
- async stamp(meta) {
2502
- this.#meta = meta;
3452
+ async stamp(metadata) {
3453
+ this.#metadata = cloneDriverMetadata(metadata);
2503
3454
  }
2504
3455
  /**
2505
3456
  * Apply a {@link Migration} plan's steps against the in-memory store.
2506
3457
  *
2507
3458
  * @remarks
2508
- * A multi-step plan applies its steps sequentially and is NOT atomic — a
2509
- * failure partway through a plan leaves the earlier steps already applied.
3459
+ * Steps apply against an isolated candidate. Rows, schema changes, and
3460
+ * optional metadata publish together only after the whole request succeeds.
2510
3461
  *
2511
- * @param plan - The migration plan to apply
3462
+ * @param input - The migration plan and optional metadata to settle atomically
2512
3463
  */
2513
- async migrate(plan) {
2514
- for (const step of plan.steps) switch (step.operation) {
3464
+ async migrate(input) {
3465
+ const owned = cloneMigrationInput(input);
3466
+ const schema = projectMigrationSchema(this.#schema, owned.plan.steps);
3467
+ if (owned.metadata !== void 0 && !equalsValue(normalizeDriverSchema(owned.metadata.schema), schema)) throw new DatabaseError("MIGRATION", "Migration metadata schema does not match the plan", {
3468
+ projected: schema,
3469
+ metadata: owned.metadata.schema
3470
+ });
3471
+ const candidate = this.#copy(this.#tables);
3472
+ const identities = this.#projectIdentities(this.#identities, owned.plan.steps);
3473
+ for (const step of owned.plan.steps) this.#migrate(candidate, step);
3474
+ this.#tables.clear();
3475
+ for (const [name, store] of candidate) this.#tables.set(name, store);
3476
+ this.#identities = identities;
3477
+ this.#schema = schema;
3478
+ if (owned.metadata !== void 0) this.#metadata = owned.metadata;
3479
+ }
3480
+ #ordered(table) {
3481
+ return [...this.#store(table).keys()].sort(compareValues);
3482
+ }
3483
+ #require(tables, table) {
3484
+ const store = tables.get(table);
3485
+ if (store === void 0) throw new DatabaseError("MIGRATION", `migrate: unknown table '${table}'`, { table });
3486
+ return store;
3487
+ }
3488
+ #copy(tables) {
3489
+ const copy = /* @__PURE__ */ new Map();
3490
+ for (const [name, store] of tables) {
3491
+ const cloned = /* @__PURE__ */ new Map();
3492
+ for (const [key, row] of store) cloned.set(key, structuredClone(row));
3493
+ copy.set(name, cloned);
3494
+ }
3495
+ return copy;
3496
+ }
3497
+ #projectIdentities(identities, steps) {
3498
+ const projected = new Map(identities);
3499
+ for (const step of steps) {
3500
+ if (step.operation === "table.add") projected.set(step.table.name, {});
3501
+ if (step.operation === "table.remove") projected.delete(step.table);
3502
+ }
3503
+ return projected;
3504
+ }
3505
+ #table(name) {
3506
+ const schema = this.#schema.find((table) => table.name === name);
3507
+ if (schema === void 0) throw new DatabaseError("NOT_FOUND", `Unknown table '${name}'`, { table: name });
3508
+ return schema;
3509
+ }
3510
+ #migrate(tables, step) {
3511
+ switch (step.operation) {
2515
3512
  case "table.add":
2516
- if (!this.#tables.has(step.table.name)) this.#tables.set(step.table.name, /* @__PURE__ */ new Map());
3513
+ if (!tables.has(step.table.name)) tables.set(step.table.name, /* @__PURE__ */ new Map());
2517
3514
  break;
2518
3515
  case "table.remove":
2519
- this.#require(step.table);
2520
- this.#tables.delete(step.table);
3516
+ this.#require(tables, step.table);
3517
+ tables.delete(step.table);
2521
3518
  break;
2522
3519
  case "column.add":
2523
3520
  case "column.remove": {
2524
- const store = this.#require(step.table);
3521
+ const store = this.#require(tables, step.table);
2525
3522
  const rows = [...store.entries()];
2526
3523
  const migrated = migrateRows(rows.map(([, row]) => row), [step]);
2527
3524
  for (const [index, [key]] of rows.entries()) {
@@ -2535,25 +3532,13 @@ var MemoryDriver = class {
2535
3532
  break;
2536
3533
  }
2537
3534
  case "index.add":
2538
- case "index.remove":
2539
- this.#require(step.table);
2540
- break;
3535
+ case "index.remove": this.#require(tables, step.table);
2541
3536
  }
2542
3537
  }
2543
- #ordered(table) {
2544
- return [...this.#store(table).keys()].sort(compareValues);
2545
- }
2546
- #require(table) {
2547
- const store = this.#tables.get(table);
2548
- if (store === void 0) throw new DatabaseError("MIGRATION", `migrate: unknown table '${table}'`, { table });
2549
- return store;
2550
- }
2551
3538
  #store(table) {
2552
- let store = this.#tables.get(table);
2553
- if (store === void 0) {
2554
- store = /* @__PURE__ */ new Map();
2555
- this.#tables.set(table, store);
2556
- }
3539
+ this.#table(table);
3540
+ const store = this.#tables.get(table);
3541
+ if (store === void 0) throw new DatabaseError("NOT_FOUND", `Table '${table}' has no backing store`, { table });
2557
3542
  return store;
2558
3543
  }
2559
3544
  };
@@ -2568,10 +3553,10 @@ var MemoryDriver = class {
2568
3553
  * level. The `const` type parameter captures the literal names and columns, so
2569
3554
  * `db.table('users')` is checked against the schema and typed by `Infer` of its
2570
3555
  * columns — no annotations. Name a non-`id` primary-key column per table via the
2571
- * optional `keys` map.
3556
+ * optional `primary` and `indexes` maps.
2572
3557
  *
2573
- * @param options - The driver, the `tables` column map, optional `keys`, and an
2574
- * optional `name`
3558
+ * @param options - The driver, `tables`, and optional `primary`, `indexes`,
3559
+ * `name`, `generator`, `version`, and emitter hooks
2575
3560
  * @returns A typed {@link DatabaseInterface}
2576
3561
  *
2577
3562
  * @example
@@ -2585,7 +3570,7 @@ var MemoryDriver = class {
2585
3570
  * users: { id: stringShape(), age: integerShape() },
2586
3571
  * posts: { slug: stringShape(), title: stringShape() },
2587
3572
  * },
2588
- * keys: { posts: 'slug' },
3573
+ * primary: { posts: 'slug' },
2589
3574
  * })
2590
3575
  * await db.table('users').set({ id: 'u1', age: 36 }) // typed; coerced + validated
2591
3576
  * ```
@@ -2606,6 +3591,6 @@ function createMemoryDriver() {
2606
3591
  return new MemoryDriver();
2607
3592
  }
2608
3593
  //#endregion
2609
- export { Clause, Cursor, DEFAULT_PRIMARY, Database, DatabaseError, MAX_PATTERN_LENGTH, MemoryDriver, Query, Table, UUID_BYTE_COUNT, UUID_BYTE_RANGE, applyCriteria, auditDriver, checkAbort, compareValues, computeAggregate, conformDriver, createDatabase, createMemoryDriver, deepEqual, driverFindings, extractKey, filterRows, generateUUID, globMatch, isDatabaseError, isDriverMeta, likeMatch, matchesCondition, matchesCriteria, migrateRows, planMigration, shapeToColumnType, sortRows, wildcardMatch };
3594
+ export { DEFAULT_PRIMARY, Database, DatabaseError, MAX_PATTERN_LENGTH, MemoryDriver, applyQuery, auditDriver, bindRowKey, checkAbort, cloneDriverMetadata, cloneDriverSchema, cloneMigrationInput, compareValues, computeAggregate, conformDriver, createDatabase, createMemoryDriver, driverFindings, equalsValue, extractKey, filterRows, isColumnSchema, isDatabaseError, isDriverMetadata, isDriverSchema, isKey, isMigration, isMigrationInput, isMigrationStep, isTableSchema, matchesCondition, matchesGlobPattern, matchesLikePattern, matchesQuery, matchesWildcardPattern, migrateRows, normalizeDriverSchema, planMigration, projectMigrationSchema, shapeToColumnSchema, shapeToColumnStorage, sortRows, validatePage };
2610
3595
 
2611
3596
  //# sourceMappingURL=index.js.map