@orkestrel/database 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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
@@ -52,7 +48,7 @@ var DatabaseError = class extends Error {
52
48
  super(message);
53
49
  this.name = "DatabaseError";
54
50
  this.code = code;
55
- this.context = context;
51
+ if (context !== void 0) this.context = context;
56
52
  }
57
53
  };
58
54
  /**
@@ -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
@@ -90,16 +299,7 @@ function isDatabaseError(value) {
90
299
  * @returns `-1`, `0`, or `1`
91
300
  */
92
301
  function compareValues(left, right) {
93
- const rankOf = (value) => {
94
- if (value === void 0) return 0;
95
- if (value === null) return 1;
96
- if (typeof value === "boolean") return 2;
97
- if (typeof value === "number") return 3;
98
- if (typeof value === "string") return 4;
99
- return 5;
100
- };
101
- const leftRank = rankOf(left);
102
- const rightRank = rankOf(right);
302
+ const [leftRank = 5, rightRank = 5] = [left, right].map((value) => value === void 0 ? 0 : value === null ? 1 : typeof value === "boolean" ? 2 : typeof value === "number" ? 3 : typeof value === "string" ? 4 : 5);
103
303
  if (leftRank !== rightRank) return leftRank < rightRank ? -1 : 1;
104
304
  if (typeof left === "number" && typeof right === "number") {
105
305
  if (Number.isNaN(left) || Number.isNaN(right)) return Number.isNaN(left) ? Number.isNaN(right) ? 0 : 1 : -1;
@@ -115,14 +315,15 @@ function compareValues(left, right) {
115
315
  *
116
316
  * @remarks
117
317
  * Primitives compare by SameValueZero (`NaN` equals itself; `+0` equals `-0`).
118
- * Arrays compare by index (same length, every element `deepEqual`). Plain
318
+ * Arrays compare by index (same length, every element `equalsValue`). Plain
119
319
  * records (via `isRecord`) compare by their OWN enumerable keys: same key
120
320
  * COUNT and, for every key in `left`, `right` has that key (`Object.hasOwn`)
121
- * 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
122
322
  * equal to that key being absent (both differ in `Object.keys` membership).
123
323
  * Anything else (functions, class instances, mismatched shapes) falls through
124
- * to `false`. There is no cycle detection a cyclic input recurses forever;
125
- * 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.
126
327
  *
127
328
  * @param left - The left value
128
329
  * @param right - The right value
@@ -130,32 +331,67 @@ function compareValues(left, right) {
130
331
  *
131
332
  * @example
132
333
  * ```ts
133
- * deepEqual(Number.NaN, Number.NaN) // true
134
- * deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }) // true
135
- * 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
136
337
  * ```
137
338
  */
138
- function deepEqual(left, right) {
139
- if (typeof left === "number" && typeof right === "number") return Number.isNaN(left) && Number.isNaN(right) || left === right;
140
- if (left === right) return true;
141
- if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((item, index) => deepEqual(item, right[index]));
142
- if (isRecord(left) && isRecord(right)) {
143
- const leftKeys = Object.keys(left);
144
- const rightKeys = Object.keys(right);
145
- if (leftKeys.length !== rightKeys.length) return false;
146
- return leftKeys.every((key) => Object.hasOwn(right, key) && deepEqual(left[key], right[key]));
147
- }
148
- 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
+ }
149
385
  }
150
386
  /**
151
387
  * Match a value against a wildcard pattern in LINEAR time — the shared, ReDoS-SAFE
152
- * engine behind {@link likeMatch} and {@link globMatch}.
388
+ * engine behind {@link matchesLikePattern} and {@link matchesGlobPattern}.
153
389
  *
154
390
  * @remarks
155
391
  * A backtracking RegExp (`a%b%c` → `^a.*b.*c$`) is CATASTROPHIC on a hostile pattern:
156
392
  * `.*` segments separated by literals, matched against a long non-matching input, blow
157
393
  * up super-linearly — and JS has no atomic groups / possessive quantifiers to bound it
158
- * (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
159
395
  * wire). So this builds NO regex. It runs the classic GREEDY TWO-POINTER wildcard match:
160
396
  * the `any` wildcard records its position and, on a later mismatch, backtracks ONLY to
161
397
  * that last `any` (letting it absorb one more char) — so the work is O(value × pattern),
@@ -178,7 +414,7 @@ function deepEqual(left, right) {
178
414
  * @returns Whether `value` matches `pattern`
179
415
  * @throws A `VALIDATION` {@link DatabaseError} when `pattern` exceeds {@link MAX_PATTERN_LENGTH}
180
416
  */
181
- function wildcardMatch(value, pattern, any, single, fold) {
417
+ function matchesWildcardPattern(value, pattern, any, single, fold) {
182
418
  if (pattern.length > 1024) throw new DatabaseError("VALIDATION", `Pattern exceeds the maximum length of ${MAX_PATTERN_LENGTH}`, {
183
419
  length: pattern.length,
184
420
  limit: MAX_PATTERN_LENGTH
@@ -207,11 +443,11 @@ function wildcardMatch(value, pattern, any, single, fold) {
207
443
  while (pi < needle.length && needle[pi] === any) pi += 1;
208
444
  return pi === needle.length;
209
445
  }
210
- function likeMatch(value, pattern) {
211
- return wildcardMatch(value, pattern, "%", "_", true);
446
+ function matchesLikePattern(value, pattern) {
447
+ return matchesWildcardPattern(value, pattern, "%", "_", true);
212
448
  }
213
- function globMatch(value, pattern) {
214
- return wildcardMatch(value, pattern, "*", "?", false);
449
+ function matchesGlobPattern(value, pattern) {
450
+ return matchesWildcardPattern(value, pattern, "*", "?", false);
215
451
  }
216
452
  /**
217
453
  * Evaluate one {@link Condition} against a row — the per-operator predicate.
@@ -221,10 +457,10 @@ function globMatch(value, pattern) {
221
457
  * string is one column; an array descends a nested value) — and applies the
222
458
  * operator. Range operators (`above` / `below` / `from` / `to` / `between`) use
223
459
  * {@link compareValues}, the total order; the equality family (`equals` / `not`
224
- * / `any` / `none`) uses {@link deepEqual} — STRUCTURAL equality, not the total
460
+ * / `any` / `none`) uses {@link equalsValue} — STRUCTURAL equality, not the total
225
461
  * order's rank-5-collapses-all-objects behavior, so `equals` on an object/array
226
462
  * operand only matches a structurally-equal value, never every row holding any
227
- * object. This is a semantics change from ranking: `deepEqual` is SameValueZero
463
+ * object. This is a semantics change from ranking: `equalsValue` is SameValueZero
228
464
  * on leaves, so `NaN` now equals `NaN` under `equals` / `any` (it never matched
229
465
  * anything under the old rank-based comparison). `like` / `glob` / `starts` /
230
466
  * `ends` match only strings; `absent` / `present` test nullishness. Total — a
@@ -239,19 +475,19 @@ function matchesCondition(row, condition) {
239
475
  const first = condition.values[0];
240
476
  const second = condition.values[1];
241
477
  switch (condition.operator) {
242
- case "equals": return deepEqual(value, first);
243
- case "not": return !deepEqual(value, first);
478
+ case "equals": return equalsValue(value, first);
479
+ case "not": return !equalsValue(value, first);
244
480
  case "above": return compareValues(value, first) > 0;
245
481
  case "below": return compareValues(value, first) < 0;
246
482
  case "from": return compareValues(value, first) >= 0;
247
483
  case "to": return compareValues(value, first) <= 0;
248
484
  case "between": return compareValues(value, first) >= 0 && compareValues(value, second) <= 0;
249
- case "like": return isString(value) && isString(first) && likeMatch(value, first);
250
- 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);
251
487
  case "starts": return isString(value) && isString(first) && value.startsWith(first);
252
488
  case "ends": return isString(value) && isString(first) && value.endsWith(first);
253
- case "any": return condition.values.some((candidate) => deepEqual(value, candidate));
254
- 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));
255
491
  case "absent": return value === void 0 || value === null;
256
492
  case "present": return value !== void 0 && value !== null;
257
493
  }
@@ -269,7 +505,7 @@ function matchesCondition(row, condition) {
269
505
  * @param conditions - The conditions to fold
270
506
  * @returns Whether the row satisfies the combined conditions
271
507
  */
272
- function matchesCriteria(row, conditions) {
508
+ function matchesQuery(row, conditions) {
273
509
  let result = true;
274
510
  let seeded = false;
275
511
  for (const condition of conditions) {
@@ -283,11 +519,11 @@ function matchesCriteria(row, conditions) {
283
519
  }
284
520
  /**
285
521
  * Filter rows by a list of conditions — the shared basis for a table's count
286
- * and aggregate paths (no sort/page, unlike {@link applyCriteria}).
522
+ * and aggregate paths (no sort/page, unlike {@link applyQuery}).
287
523
  *
288
524
  * @remarks
289
525
  * An empty condition list matches every row (returned as-is, no copy). Folds
290
- * each row through {@link matchesCriteria}.
526
+ * each row through {@link matchesQuery}.
291
527
  *
292
528
  * @param rows - The rows to filter
293
529
  * @param conditions - The conditions to apply (empty matches everything)
@@ -303,7 +539,7 @@ function matchesCriteria(row, conditions) {
303
539
  */
304
540
  function filterRows(rows, conditions) {
305
541
  if (conditions.length === 0) return rows;
306
- return rows.filter((row) => matchesCriteria(row, conditions));
542
+ return rows.filter((row) => matchesQuery(row, conditions));
307
543
  }
308
544
  /**
309
545
  * Sort rows by an ordering specification, leaving the input untouched.
@@ -328,26 +564,27 @@ function sortRows(rows, order) {
328
564
  return sorted;
329
565
  }
330
566
  /**
331
- * Apply a {@link Criteria} to rows — filter, then sort, then page.
567
+ * Apply a {@link QueryInput} to rows — filter, then sort, then page.
332
568
  *
333
569
  * @remarks
334
570
  * The whole portable read pipeline in one place: conditions filter, `order`
335
571
  * sorts, and `offset` / `limit` window the result. Each step is skipped when its
336
- * part of the criteria is absent. The reference {@link DriverInterface} backends
572
+ * part of the input is absent. The reference {@link DriverInterface} backends
337
573
  * lean on this rather than each re-deriving it.
338
574
  *
339
575
  * @param rows - The rows to process (typically a table's full `scan`)
340
- * @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
341
577
  * @returns The filtered, sorted, paged rows
342
578
  */
343
- function applyCriteria(rows, criteria) {
579
+ function applyQuery(rows, input) {
580
+ validatePage(input);
344
581
  let result = rows;
345
- const conditions = criteria?.conditions;
346
- if (conditions !== void 0 && conditions.length > 0) result = result.filter((row) => matchesCriteria(row, conditions));
347
- 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;
348
585
  if (order !== void 0 && order.length > 0) result = sortRows(result, order);
349
- const offset = criteria?.offset ?? 0;
350
- const limit = criteria?.limit;
586
+ const offset = input?.offset ?? 0;
587
+ const limit = input?.limit;
351
588
  if (offset > 0 || limit !== void 0) result = result.slice(offset, limit !== void 0 ? offset + limit : void 0);
352
589
  return result;
353
590
  }
@@ -389,11 +626,24 @@ function computeAggregate(rows, operation, column) {
389
626
  */
390
627
  function extractKey(row, column) {
391
628
  const value = row[column];
392
- if (isString(value)) return value;
393
- 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
+ };
394
644
  }
395
645
  /**
396
- * 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
397
647
  * value a `TableSchema` carries so a native backend can declare a real column.
398
648
  *
399
649
  * @remarks
@@ -410,13 +660,13 @@ function extractKey(row, column) {
410
660
  *
411
661
  * @example
412
662
  * ```ts
413
- * shapeToColumnType(stringShape()) // 'text'
414
- * shapeToColumnType(integerShape()) // 'integer'
415
- * shapeToColumnType(optionalShape(integerShape())) // 'integer'
416
- * shapeToColumnType(objectShape({ a: stringShape() })) // 'json'
663
+ * shapeToColumnStorage(stringShape()) // 'text'
664
+ * shapeToColumnStorage(integerShape()) // 'integer'
665
+ * shapeToColumnStorage(optionalShape(integerShape())) // 'integer'
666
+ * shapeToColumnStorage(objectShape({ a: stringShape() })) // 'json'
417
667
  * ```
418
668
  */
419
- function shapeToColumnType(shape) {
669
+ function shapeToColumnStorage(shape) {
420
670
  switch (shape.type) {
421
671
  case "string": return "text";
422
672
  case "number": return shape.integer === true ? "integer" : "real";
@@ -426,7 +676,7 @@ function shapeToColumnType(shape) {
426
676
  if (shape.values.every((value) => typeof value === "number")) return shape.values.every((value) => Number.isInteger(value)) ? "integer" : "real";
427
677
  return "text";
428
678
  case "optional":
429
- case "nullable": return shapeToColumnType(shape.inner);
679
+ case "nullable": return shapeToColumnStorage(shape.inner);
430
680
  case "null":
431
681
  case "object":
432
682
  case "array":
@@ -436,53 +686,31 @@ function shapeToColumnType(shape) {
436
686
  }
437
687
  }
438
688
  /**
439
- * Whether a value is a well-formed {@link DriverMeta} — the boundary guard a
440
- * versioning driver's `meta()` narrows a stored (structured-clone or
441
- * `JSON.parse`d) value through before trusting it, replacing the per-driver
442
- * duplicated narrowing every backend used to hand-roll (AGENTS §14: never `as`).
689
+ * Project one contract shape into a portable column schema.
443
690
  *
444
- * @remarks
445
- * Total and total-recursive over the whole shape: a finite `version`, and a
446
- * `schema` array of well-formed {@link TableSchema} entries each a `name` /
447
- * `primary` string pair, a `columns` array of well-formed {@link ColumnSchema}
448
- * entries (a `name` string, a {@link ColumnType} literal, a `nullable`
449
- * boolean), and an `indexes` array of string arrays. Anything off-shape
450
- * (including a non-record) returns `false` rather than throwing.
451
- *
452
- * @param value - The value to test
453
- * @returns `true` when `value` is a well-formed `DriverMeta`
454
- *
455
- * @example
456
- * ```ts
457
- * isDriverMeta({ version: 1, schema: [] }) // true
458
- * isDriverMeta({ version: 1, schema: [{ name: 'users' }] }) // false
459
- * ```
691
+ * @param name - The column name
692
+ * @param shape - The column contract shape
693
+ * @returns The portable storage and independent absence/null acceptance
460
694
  */
461
- function isDriverMeta(value) {
462
- const COLUMN_TYPES = [
463
- "text",
464
- "integer",
465
- "real",
466
- "boolean",
467
- "json",
468
- "blob"
469
- ];
470
- const isColumnType = (candidate) => isString(candidate) && COLUMN_TYPES.some((type) => type === candidate);
471
- const isColumnSchema = (candidate) => isRecord(candidate) && isString(candidate.name) && isColumnType(candidate.type) && isBoolean(candidate.nullable);
472
- const isIndexGroup = (candidate) => isArray(candidate) && candidate.every((entry) => isString(entry));
473
- const isTableSchema = (candidate) => isRecord(candidate) && isString(candidate.name) && isString(candidate.primary) && isArray(candidate.columns) && candidate.columns.every(isColumnSchema) && isArray(candidate.indexes) && candidate.indexes.every(isIndexGroup);
474
- return isRecord(value) && isFiniteNumber(value.version) && isArray(value.schema) && value.schema.every(isTableSchema);
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
+ };
475
703
  }
476
704
  /**
477
- * Throw when an {@link ReadOptions.signal | AbortSignal} has fired — the shared
478
- * 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.
479
707
  *
480
708
  * @remarks
481
709
  * A no-op for `undefined` or a live signal, so callers thread `options?.signal`
482
710
  * straight through. When the signal has aborted, throws an `ABORTED`
483
711
  * {@link DatabaseError} carrying the signal's `reason` in its context — callers
484
- * mint signals with whatever tool they like (`AbortSignal.timeout(ms)`,
485
- * `new AbortController()`, `@orkestrel/abort`).
712
+ * mint signals with native APIs such as `AbortSignal.timeout(ms)` or
713
+ * `new AbortController()`.
486
714
  *
487
715
  * @param signal - The signal to check, if any
488
716
  * @returns Nothing — returns normally while the signal is live
@@ -514,11 +742,12 @@ function checkAbort(signal) {
514
742
  * `column.remove` / `index.add` / `index.remove` steps. Step order is
515
743
  * deterministic: every `table.remove`, then every `table.add`, then each
516
744
  * shared table's column/index changes in `declared` order. `from` / `to` are
517
- * plan labels only version tracking itself is deferred to persistent
518
- * backends.
745
+ * plan labels only; versioning drivers persist and reconcile them through
746
+ * {@link DriverMetadata}.
519
747
  *
520
748
  * A column present in BOTH schemas under the same name but with a different
521
- * `type` or `nullable` throws a `MIGRATION` {@link DatabaseError} naming the
749
+ * `storage`, `optional`, or `nullable` value throws a `MIGRATION`
750
+ * {@link DatabaseError} naming the
522
751
  * table, the column, and the from→to difference — a name-only diff would
523
752
  * otherwise silently produce NO step for the drift, and versioned
524
753
  * reconciliation would stamp over it. There is no automatic in-place
@@ -531,42 +760,66 @@ function checkAbort(signal) {
531
760
  * @param from - The plan's source version label (defaults to `0`)
532
761
  * @param to - The plan's target version label (defaults to `1`)
533
762
  * @returns The migration plan moving `deployed` toward `declared`
534
- * @throws A `MIGRATION` {@link DatabaseError} when a shared column's `type` or
535
- * `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
536
767
  *
537
768
  * @example
538
769
  * ```ts
539
770
  * const plan = planMigration(
540
- * [{ name: 'users', primary: 'id', columns: [], indexes: [] }],
541
- * [{ 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: [] }],
542
773
  * )
543
774
  * // plan.steps === [{ operation: 'column.add', table: 'users', column: { name: 'age', ... } }]
544
775
  * ```
545
776
  */
546
777
  function planMigration(deployed, declared, from = 0, to = 1) {
547
- const deployedByName = new Map(deployed.map((table) => [table.name, table]));
548
- 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]));
549
792
  const steps = [];
550
- 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({
551
794
  operation: "table.remove",
552
795
  table: table.name
553
796
  });
554
- 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({
555
798
  operation: "table.add",
556
799
  table
557
800
  });
558
- for (const table of declared) {
801
+ for (const table of targetSchema) {
559
802
  const before = deployedByName.get(table.name);
560
803
  if (before === void 0) continue;
561
- const beforeColumns = new Map(before.columns.map((column) => [column.name, column]));
562
- const afterColumns = new Map(table.columns.map((column) => [column.name, column]));
563
- 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({
564
817
  operation: "column.remove",
565
818
  table: table.name,
566
819
  column: column.name
567
820
  });
568
821
  for (const column of table.columns) {
569
- const previous = beforeColumns.get(column.name);
822
+ const previous = beforeColumnMap.get(column.name);
570
823
  if (previous === void 0) {
571
824
  steps.push({
572
825
  operation: "column.add",
@@ -575,36 +828,158 @@ function planMigration(deployed, declared, from = 0, to = 1) {
575
828
  });
576
829
  continue;
577
830
  }
578
- 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`, {
579
832
  table: table.name,
580
833
  column: column.name,
581
834
  from: {
582
- type: previous.type,
835
+ storage: previous.storage,
836
+ optional: previous.optional,
583
837
  nullable: previous.nullable
584
838
  },
585
839
  to: {
586
- type: column.type,
840
+ storage: column.storage,
841
+ optional: column.optional,
587
842
  nullable: column.nullable
588
843
  }
589
844
  });
590
845
  }
591
- const sameIndex = (left, right) => left.length === right.length && left.every((column, position) => column === right[position]);
592
- for (const index of before.indexes) if (!table.indexes.some((candidate) => sameIndex(candidate, index))) steps.push({
593
- operation: "index.remove",
594
- table: table.name,
595
- index
596
- });
597
- for (const index of table.indexes) if (!before.indexes.some((candidate) => sameIndex(candidate, index))) steps.push({
846
+ for (const index of table.indexes) if (!before.indexes.some((candidate) => equalsValue(candidate, index))) steps.push({
598
847
  operation: "index.add",
599
848
  table: table.name,
600
849
  index
601
850
  });
602
851
  }
603
- 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: {
604
858
  from,
605
859
  to,
606
860
  steps
607
- };
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);
608
983
  }
609
984
  /**
610
985
  * Apply one table's {@link MigrationStep}s to its rows — a pure row transform.
@@ -652,21 +1027,24 @@ function migrateRows(rows, steps) {
652
1027
  * `read` of a missing key returns `undefined`; `write`/`read` round-trip with
653
1028
  * DEEP copy-in/copy-out isolation (mutating the caller's row — including a
654
1029
  * NESTED field — after `write`, or a row `read` returns, never perturbs
655
- * 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`;
656
1034
  * `keys`/`scan` yield in ascending key order; `clear` empties only its target
657
1035
  * table; `snapshot`'s rollback thunk restores pre-snapshot state, including a
658
1036
  * NESTED field mutated in place on a read-back row between capture and
659
1037
  * restore; a scoped `snapshot(['users'])` rolls back only the named table,
660
1038
  * leaving a concurrent mutation to another table intact; a
661
1039
  * non-`id` primary key (`posts.slug`) round-trips; a nested-object row
662
- * round-trips structurally (via {@link deepEqual}). The optional surface is
1040
+ * round-trips structurally (via {@link equalsValue}). The optional surface is
663
1041
  * presence-gated: when `migrate` exists, a `column.remove` plan strips the
664
1042
  * column from stored rows and a plan referencing an unknown table throws
665
1043
  * `DatabaseError` `MIGRATION`; when `stream` exists, it yields only
666
1044
  * condition-matching rows and honors `offset`/`limit`; when `transaction`
667
- * exists, `commit` persists and `rollback` restores; when both `meta` and
668
- * `stamp` exist, a fresh store's `meta()` is `undefined`, and after
669
- * `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.
670
1048
  *
671
1049
  * Each phase runs within a `try`/`catch`: an EXPECTED mismatch yields a
672
1050
  * finding built from the assertion, while an UNEXPECTED throw (a driver
@@ -698,23 +1076,27 @@ async function* driverFindings(factory) {
698
1076
  columns: [
699
1077
  {
700
1078
  name: "id",
701
- type: "text",
1079
+ storage: "text",
1080
+ optional: false,
702
1081
  nullable: false
703
1082
  },
704
1083
  {
705
1084
  name: "name",
706
- type: "text",
1085
+ storage: "text",
1086
+ optional: false,
707
1087
  nullable: false
708
1088
  },
709
1089
  {
710
1090
  name: "age",
711
- type: "integer",
712
- nullable: true
1091
+ storage: "integer",
1092
+ optional: true,
1093
+ nullable: false
713
1094
  },
714
1095
  {
715
1096
  name: "meta",
716
- type: "json",
717
- nullable: true
1097
+ storage: "json",
1098
+ optional: true,
1099
+ nullable: false
718
1100
  }
719
1101
  ],
720
1102
  indexes: []
@@ -724,539 +1106,801 @@ async function* driverFindings(factory) {
724
1106
  primary: "slug",
725
1107
  columns: [{
726
1108
  name: "slug",
727
- type: "text",
1109
+ storage: "text",
1110
+ optional: false,
728
1111
  nullable: false
729
1112
  }, {
730
1113
  name: "title",
731
- type: "text",
1114
+ storage: "text",
1115
+ optional: false,
732
1116
  nullable: false
733
1117
  }],
734
1118
  indexes: []
735
1119
  };
736
1120
  const CONFORMANCE_SCHEMA = [CONFORMANCE_USERS_SCHEMA, CONFORMANCE_POSTS_SCHEMA];
737
- const findingOf = (check, message, context) => ({
738
- check,
739
- message,
740
- context
741
- });
742
- const phases = [
743
- {
1121
+ try {
1122
+ const driver = factory();
1123
+ await driver.open(CONFORMANCE_SCHEMA);
1124
+ await driver.close();
1125
+ } catch (error) {
1126
+ yield {
744
1127
  check: "open-close",
745
- run: async () => {
746
- const driver = factory();
747
- await driver.open(CONFORMANCE_SCHEMA);
748
- await driver.close();
1128
+ message: error instanceof Error ? error.message : String(error),
1129
+ context: { error }
1130
+ };
1131
+ }
1132
+ try {
1133
+ const driver = factory();
1134
+ await driver.open(CONFORMANCE_SCHEMA);
1135
+ const missing = await driver.read("users", "nope");
1136
+ await driver.close();
1137
+ if (missing !== void 0) yield {
1138
+ check: "read-missing",
1139
+ message: "read of a missing key must return undefined",
1140
+ context: {
1141
+ table: "users",
1142
+ expected: void 0,
1143
+ actual: missing
749
1144
  }
750
- },
751
- {
1145
+ };
1146
+ } catch (error) {
1147
+ yield {
752
1148
  check: "read-missing",
753
- run: async () => {
754
- const driver = factory();
755
- await driver.open(CONFORMANCE_SCHEMA);
756
- const missing = await driver.read("users", "nope");
757
- await driver.close();
758
- if (missing !== void 0) return findingOf("read-missing", "read of a missing key must return undefined", {
1149
+ message: error instanceof Error ? error.message : String(error),
1150
+ context: { error }
1151
+ };
1152
+ }
1153
+ writeRead: try {
1154
+ const driver = factory();
1155
+ await driver.open(CONFORMANCE_SCHEMA);
1156
+ const input = {
1157
+ id: "caller",
1158
+ name: "Ada",
1159
+ age: 30,
1160
+ meta: { tags: ["a"] }
1161
+ };
1162
+ await driver.write("users", "u1", input);
1163
+ input.name = "Mutated after write";
1164
+ if (isRecord(input.meta) && Array.isArray(input.meta.tags)) input.meta.tags.push("mutated");
1165
+ const stored = await driver.read("users", "u1");
1166
+ const original = {
1167
+ id: "u1",
1168
+ name: "Ada",
1169
+ age: 30,
1170
+ meta: { tags: ["a"] }
1171
+ };
1172
+ if (stored === void 0 || !equalsValue(stored, original)) {
1173
+ await driver.close();
1174
+ yield {
1175
+ check: "copy-in",
1176
+ message: "write must deep-copy the input row (including nested fields) rather than store it by reference",
1177
+ context: {
759
1178
  table: "users",
760
- expected: void 0,
761
- actual: missing
762
- });
1179
+ expected: original,
1180
+ actual: stored
1181
+ }
1182
+ };
1183
+ break writeRead;
1184
+ }
1185
+ stored.name = "Mutated after read";
1186
+ if (isRecord(stored.meta) && Array.isArray(stored.meta.tags)) stored.meta.tags.push("mutated");
1187
+ const reread = await driver.read("users", "u1");
1188
+ if (reread === void 0 || !equalsValue(reread, original)) {
1189
+ await driver.close();
1190
+ yield {
1191
+ check: "copy-out",
1192
+ message: "read must deep-copy the stored row (including nested fields) rather than return it by reference",
1193
+ context: {
1194
+ table: "users",
1195
+ expected: original,
1196
+ actual: reread
1197
+ }
1198
+ };
1199
+ break writeRead;
1200
+ }
1201
+ const overwrite = {
1202
+ id: "caller",
1203
+ name: "Ada Overwritten",
1204
+ age: 31
1205
+ };
1206
+ await driver.write("users", "u1", overwrite);
1207
+ const overwritten = await driver.read("users", "u1");
1208
+ await driver.close();
1209
+ const expectedOverwrite = {
1210
+ ...overwrite,
1211
+ id: "u1"
1212
+ };
1213
+ if (overwritten === void 0 || !equalsValue(overwritten, expectedOverwrite)) yield {
1214
+ check: "upsert",
1215
+ message: "write must upsert-overwrite an existing key",
1216
+ context: {
1217
+ table: "users",
1218
+ expected: expectedOverwrite,
1219
+ actual: overwritten
763
1220
  }
764
- },
765
- {
1221
+ };
1222
+ } catch (error) {
1223
+ yield {
766
1224
  check: "write-read",
767
- run: async () => {
768
- const driver = factory();
769
- await driver.open(CONFORMANCE_SCHEMA);
770
- const input = {
771
- id: "u1",
772
- name: "Ada",
773
- age: 30,
774
- meta: { tags: ["a"] }
775
- };
776
- await driver.write("users", "u1", input);
777
- input.name = "Mutated after write";
778
- if (isRecord(input.meta) && Array.isArray(input.meta.tags)) input.meta.tags.push("mutated");
779
- const stored = await driver.read("users", "u1");
780
- const original = {
781
- id: "u1",
782
- name: "Ada",
783
- age: 30,
784
- meta: { tags: ["a"] }
785
- };
786
- if (stored === void 0 || !deepEqual(stored, original)) {
787
- await driver.close();
788
- return findingOf("copy-in", "write must deep-copy the input row (including nested fields) rather than store it by reference", {
789
- table: "users",
790
- expected: original,
791
- actual: stored
792
- });
793
- }
794
- stored.name = "Mutated after read";
795
- if (isRecord(stored.meta) && Array.isArray(stored.meta.tags)) stored.meta.tags.push("mutated");
796
- const reread = await driver.read("users", "u1");
797
- if (reread === void 0 || !deepEqual(reread, original)) {
798
- await driver.close();
799
- return findingOf("copy-out", "read must deep-copy the stored row (including nested fields) rather than return it by reference", {
800
- table: "users",
801
- expected: original,
802
- actual: reread
803
- });
1225
+ message: error instanceof Error ? error.message : String(error),
1226
+ context: { error }
1227
+ };
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
804
1260
  }
805
- const overwrite = {
806
- id: "u1",
807
- name: "Ada Overwritten",
808
- age: 31
809
- };
810
- await driver.write("users", "u1", overwrite);
811
- const overwritten = await driver.read("users", "u1");
812
- await driver.close();
813
- if (overwritten === void 0 || !deepEqual(overwritten, overwrite)) return findingOf("upsert", "write must upsert-overwrite an existing key", {
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
+ }
1270
+ deletePhase: try {
1271
+ const driver = factory();
1272
+ await driver.open(CONFORMANCE_SCHEMA);
1273
+ await driver.write("users", "u1", {
1274
+ id: "u1",
1275
+ name: "Ada",
1276
+ age: 30
1277
+ });
1278
+ const first = await driver.delete("users", "u1");
1279
+ if (first !== true) {
1280
+ await driver.close();
1281
+ yield {
1282
+ check: "delete-true",
1283
+ message: "delete of an existing key must return true",
1284
+ context: {
814
1285
  table: "users",
815
- expected: overwrite,
816
- actual: overwritten
817
- });
1286
+ expected: true,
1287
+ actual: first
1288
+ }
1289
+ };
1290
+ break deletePhase;
1291
+ }
1292
+ const second = await driver.delete("users", "u1");
1293
+ await driver.close();
1294
+ if (second !== false) yield {
1295
+ check: "delete-false",
1296
+ message: "delete of an already-removed key must return false",
1297
+ context: {
1298
+ table: "users",
1299
+ expected: false,
1300
+ actual: second
818
1301
  }
819
- },
820
- {
1302
+ };
1303
+ } catch (error) {
1304
+ yield {
821
1305
  check: "delete",
822
- run: async () => {
823
- const driver = factory();
824
- await driver.open(CONFORMANCE_SCHEMA);
825
- await driver.write("users", "u1", {
826
- id: "u1",
827
- name: "Ada",
828
- age: 30
829
- });
830
- const first = await driver.delete("users", "u1");
831
- if (first !== true) {
832
- await driver.close();
833
- return findingOf("delete-true", "delete of an existing key must return true", {
834
- table: "users",
835
- expected: true,
836
- actual: first
837
- });
1306
+ message: error instanceof Error ? error.message : String(error),
1307
+ context: { error }
1308
+ };
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
838
1363
  }
839
- const second = await driver.delete("users", "u1");
840
- await driver.close();
841
- if (second !== false) return findingOf("delete-false", "delete of an already-removed key must return false", {
842
- table: "users",
843
- expected: false,
844
- actual: second
845
- });
846
1364
  }
847
- },
848
- {
849
- check: "order",
850
- run: async () => {
851
- const driver = factory();
852
- await driver.open(CONFORMANCE_SCHEMA);
853
- for (const row of [
854
- {
855
- id: "c",
856
- name: "C",
857
- age: 3
858
- },
859
- {
860
- id: "a",
861
- name: "A",
862
- age: 1
863
- },
864
- {
865
- id: "b",
866
- name: "B",
867
- age: 2
868
- }
869
- ]) await driver.write("users", row.id, row);
870
- const expected = [
871
- "a",
872
- "b",
873
- "c"
874
- ];
875
- const keys = [...await driver.keys("users")];
876
- if (!deepEqual(keys, expected)) {
877
- await driver.close();
878
- return findingOf("keys-order", "keys must be returned in ascending key order", {
879
- table: "users",
880
- expected,
881
- actual: keys
882
- });
883
- }
884
- const scanned = [];
885
- for await (const row of driver.scan("users")) scanned.push(row);
886
- const scannedIds = scanned.map((row) => row.id);
887
- await driver.close();
888
- if (!deepEqual(scannedIds, expected)) return findingOf("scan-order", "scan must yield rows in ascending key order", {
1365
+ };
1366
+ } catch (error) {
1367
+ yield {
1368
+ check: "mutation-abort",
1369
+ message: error instanceof Error ? error.message : String(error),
1370
+ context: { error }
1371
+ };
1372
+ }
1373
+ orderPhase: try {
1374
+ const driver = factory();
1375
+ await driver.open(CONFORMANCE_SCHEMA);
1376
+ for (const row of [
1377
+ {
1378
+ id: "c",
1379
+ name: "C",
1380
+ age: 3
1381
+ },
1382
+ {
1383
+ id: "a",
1384
+ name: "A",
1385
+ age: 1
1386
+ },
1387
+ {
1388
+ id: "b",
1389
+ name: "B",
1390
+ age: 2
1391
+ }
1392
+ ]) await driver.write("users", row.id, row);
1393
+ const expected = [
1394
+ "a",
1395
+ "b",
1396
+ "c"
1397
+ ];
1398
+ const keys = [...await driver.keys("users")];
1399
+ if (!equalsValue(keys, expected)) {
1400
+ await driver.close();
1401
+ yield {
1402
+ check: "keys-order",
1403
+ message: "keys must be returned in ascending key order",
1404
+ context: {
889
1405
  table: "users",
890
1406
  expected,
891
- actual: scannedIds
892
- });
1407
+ actual: keys
1408
+ }
1409
+ };
1410
+ break orderPhase;
1411
+ }
1412
+ const scanned = [];
1413
+ for await (const row of driver.scan("users")) scanned.push(row);
1414
+ const scannedIds = scanned.map((row) => row.id);
1415
+ await driver.close();
1416
+ if (!equalsValue(scannedIds, expected)) yield {
1417
+ check: "scan-order",
1418
+ message: "scan must yield rows in ascending key order",
1419
+ context: {
1420
+ table: "users",
1421
+ expected,
1422
+ actual: scannedIds
893
1423
  }
894
- },
895
- {
896
- check: "clear",
897
- run: async () => {
898
- const driver = factory();
899
- await driver.open(CONFORMANCE_SCHEMA);
900
- await driver.write("users", "u1", {
901
- id: "u1",
902
- name: "Ada",
903
- age: 30
904
- });
905
- await driver.write("posts", "p1", {
906
- slug: "p1",
907
- title: "Post"
908
- });
909
- await driver.clear("users");
910
- const usersKeys = await driver.keys("users");
911
- const postsKeys = await driver.keys("posts");
912
- await driver.close();
913
- if (usersKeys.length !== 0) return findingOf("clear-target", "clear must empty the targeted table", {
1424
+ };
1425
+ } catch (error) {
1426
+ yield {
1427
+ check: "order",
1428
+ message: error instanceof Error ? error.message : String(error),
1429
+ context: { error }
1430
+ };
1431
+ }
1432
+ clearPhase: try {
1433
+ const driver = factory();
1434
+ await driver.open(CONFORMANCE_SCHEMA);
1435
+ await driver.write("users", "u1", {
1436
+ id: "u1",
1437
+ name: "Ada",
1438
+ age: 30
1439
+ });
1440
+ await driver.write("posts", "p1", {
1441
+ slug: "p1",
1442
+ title: "Post"
1443
+ });
1444
+ await driver.clear("users");
1445
+ const usersKeys = await driver.keys("users");
1446
+ const postsKeys = await driver.keys("posts");
1447
+ await driver.close();
1448
+ if (usersKeys.length !== 0) {
1449
+ yield {
1450
+ check: "clear-target",
1451
+ message: "clear must empty the targeted table",
1452
+ context: {
914
1453
  table: "users",
915
1454
  expected: [],
916
1455
  actual: usersKeys
917
- });
918
- if (postsKeys.length !== 1) return findingOf("clear-other", "clear must not affect other tables", {
919
- table: "posts",
920
- expected: 1,
921
- actual: postsKeys.length
922
- });
923
- }
924
- },
925
- {
926
- check: "snapshot",
927
- run: async () => {
928
- const driver = factory();
929
- await driver.open(CONFORMANCE_SCHEMA);
930
- const original = {
931
- id: "u1",
932
- name: "Ada",
933
- age: 30
934
- };
935
- await driver.write("users", "u1", original);
936
- const rollback = await driver.snapshot();
937
- await driver.write("users", "u2", {
938
- id: "u2",
939
- name: "Grace",
940
- age: 40
941
- });
942
- await driver.delete("users", "u1");
943
- await rollback();
944
- const keys = [...await driver.keys("users")];
945
- if (!deepEqual(keys, ["u1"])) {
946
- await driver.close();
947
- return findingOf("snapshot-rollback", "snapshot rollback must restore the pre-snapshot key set", {
948
- table: "users",
949
- expected: ["u1"],
950
- actual: keys
951
- });
952
1456
  }
953
- const restored = await driver.read("users", "u1");
954
- await driver.close();
955
- if (restored === void 0 || !deepEqual(restored, original)) return findingOf("snapshot-rollback-value", "snapshot rollback must restore pre-snapshot row values", {
956
- table: "users",
957
- expected: original,
958
- actual: restored
959
- });
1457
+ };
1458
+ break clearPhase;
1459
+ }
1460
+ if (postsKeys.length !== 1) yield {
1461
+ check: "clear-other",
1462
+ message: "clear must not affect other tables",
1463
+ context: {
1464
+ table: "posts",
1465
+ expected: 1,
1466
+ actual: postsKeys.length
960
1467
  }
961
- },
962
- {
963
- check: "snapshot-nested",
964
- run: async () => {
965
- const driver = factory();
966
- await driver.open(CONFORMANCE_SCHEMA);
967
- const original = {
968
- id: "u3",
969
- name: "Nested",
970
- age: 20,
971
- meta: { tags: ["a"] }
972
- };
973
- await driver.write("users", "u3", original);
974
- const rollback = await driver.snapshot();
975
- const before = await driver.read("users", "u3");
976
- if (isRecord(before) && isRecord(before.meta) && Array.isArray(before.meta.tags)) before.meta.tags.push("mutated-before-restore");
977
- await driver.write("users", "u3", {
978
- id: "u3",
979
- name: "Nested",
980
- age: 20,
981
- meta: { tags: ["a", "mutated-after-write"] }
982
- });
983
- await rollback();
984
- const restored = await driver.read("users", "u3");
985
- await driver.close();
986
- if (restored === void 0 || !deepEqual(restored, original)) return findingOf("snapshot-nested", "snapshot rollback must restore pre-snapshot nested field values, unaffected by a later in-place mutation of a read-back row", {
1468
+ };
1469
+ } catch (error) {
1470
+ yield {
1471
+ check: "clear",
1472
+ message: error instanceof Error ? error.message : String(error),
1473
+ context: { error }
1474
+ };
1475
+ }
1476
+ snapshotPhase: try {
1477
+ const driver = factory();
1478
+ await driver.open(CONFORMANCE_SCHEMA);
1479
+ const original = {
1480
+ id: "u1",
1481
+ name: "Ada",
1482
+ age: 30
1483
+ };
1484
+ await driver.write("users", "u1", original);
1485
+ const rollback = await driver.snapshot();
1486
+ await driver.write("users", "u2", {
1487
+ id: "u2",
1488
+ name: "Grace",
1489
+ age: 40
1490
+ });
1491
+ await driver.delete("users", "u1");
1492
+ await rollback();
1493
+ const keys = [...await driver.keys("users")];
1494
+ if (!equalsValue(keys, ["u1"])) {
1495
+ await driver.close();
1496
+ yield {
1497
+ check: "snapshot-rollback",
1498
+ message: "snapshot rollback must restore the pre-snapshot key set",
1499
+ context: {
987
1500
  table: "users",
988
- expected: original,
989
- actual: restored
990
- });
1501
+ expected: ["u1"],
1502
+ actual: keys
1503
+ }
1504
+ };
1505
+ break snapshotPhase;
1506
+ }
1507
+ const restored = await driver.read("users", "u1");
1508
+ await driver.close();
1509
+ if (restored === void 0 || !equalsValue(restored, original)) yield {
1510
+ check: "snapshot-rollback-value",
1511
+ message: "snapshot rollback must restore pre-snapshot row values",
1512
+ context: {
1513
+ table: "users",
1514
+ expected: original,
1515
+ actual: restored
991
1516
  }
992
- },
993
- {
1517
+ };
1518
+ } catch (error) {
1519
+ yield {
1520
+ check: "snapshot",
1521
+ message: error instanceof Error ? error.message : String(error),
1522
+ context: { error }
1523
+ };
1524
+ }
1525
+ try {
1526
+ const driver = factory();
1527
+ await driver.open(CONFORMANCE_SCHEMA);
1528
+ const original = {
1529
+ id: "u3",
1530
+ name: "Nested",
1531
+ age: 20,
1532
+ meta: { tags: ["a"] }
1533
+ };
1534
+ await driver.write("users", "u3", original);
1535
+ const rollback = await driver.snapshot();
1536
+ const before = await driver.read("users", "u3");
1537
+ if (isRecord(before) && isRecord(before.meta) && Array.isArray(before.meta.tags)) before.meta.tags.push("mutated-before-restore");
1538
+ await driver.write("users", "u3", {
1539
+ id: "u3",
1540
+ name: "Nested",
1541
+ age: 20,
1542
+ meta: { tags: ["a", "mutated-after-write"] }
1543
+ });
1544
+ await rollback();
1545
+ const restored = await driver.read("users", "u3");
1546
+ await driver.close();
1547
+ if (restored === void 0 || !equalsValue(restored, original)) yield {
1548
+ check: "snapshot-nested",
1549
+ message: "snapshot rollback must restore pre-snapshot nested field values, unaffected by a later in-place mutation of a read-back row",
1550
+ context: {
1551
+ table: "users",
1552
+ expected: original,
1553
+ actual: restored
1554
+ }
1555
+ };
1556
+ } catch (error) {
1557
+ yield {
1558
+ check: "snapshot-nested",
1559
+ message: error instanceof Error ? error.message : String(error),
1560
+ context: { error }
1561
+ };
1562
+ }
1563
+ try {
1564
+ const driver = factory();
1565
+ await driver.open(CONFORMANCE_SCHEMA);
1566
+ await driver.write("posts", "hello-world", {
1567
+ slug: "caller",
1568
+ title: "Hello"
1569
+ });
1570
+ const post = await driver.read("posts", "hello-world");
1571
+ const key = post === void 0 ? void 0 : extractKey(post, "slug");
1572
+ await driver.close();
1573
+ if (key !== "hello-world") yield {
994
1574
  check: "non-id-primary",
995
- run: async () => {
996
- const driver = factory();
997
- await driver.open(CONFORMANCE_SCHEMA);
998
- await driver.write("posts", "hello-world", {
999
- slug: "hello-world",
1000
- title: "Hello"
1001
- });
1002
- const post = await driver.read("posts", "hello-world");
1003
- const key = post === void 0 ? void 0 : extractKey(post, "slug");
1004
- await driver.close();
1005
- if (key !== "hello-world") return findingOf("non-id-primary", "a non-id primary key column must round-trip through the store", {
1006
- table: "posts",
1007
- expected: "hello-world",
1008
- actual: key
1009
- });
1575
+ message: "a non-id primary key column must round-trip through the store",
1576
+ context: {
1577
+ table: "posts",
1578
+ expected: "hello-world",
1579
+ actual: key
1010
1580
  }
1011
- },
1012
- {
1581
+ };
1582
+ } catch (error) {
1583
+ yield {
1584
+ check: "non-id-primary",
1585
+ message: error instanceof Error ? error.message : String(error),
1586
+ context: { error }
1587
+ };
1588
+ }
1589
+ try {
1590
+ const driver = factory();
1591
+ await driver.open(CONFORMANCE_SCHEMA);
1592
+ const nested = {
1593
+ id: "u3",
1594
+ name: "Nested",
1595
+ age: 20,
1596
+ meta: {
1597
+ tags: ["a", "b"],
1598
+ deep: { flag: true }
1599
+ }
1600
+ };
1601
+ await driver.write("users", "u3", nested);
1602
+ const readBack = await driver.read("users", "u3");
1603
+ await driver.close();
1604
+ if (readBack === void 0 || !equalsValue(readBack, nested)) yield {
1013
1605
  check: "nested-roundtrip",
1014
- run: async () => {
1015
- const driver = factory();
1016
- await driver.open(CONFORMANCE_SCHEMA);
1017
- const nested = {
1018
- id: "u3",
1019
- name: "Nested",
1020
- age: 20,
1021
- meta: {
1022
- tags: ["a", "b"],
1023
- deep: { flag: true }
1024
- }
1025
- };
1026
- await driver.write("users", "u3", nested);
1027
- const readBack = await driver.read("users", "u3");
1028
- await driver.close();
1029
- if (readBack === void 0 || !deepEqual(readBack, nested)) return findingOf("nested-roundtrip", "a nested-object row must round-trip structurally", {
1606
+ message: "a nested-object row must round-trip structurally",
1607
+ context: {
1608
+ table: "users",
1609
+ expected: nested,
1610
+ actual: readBack
1611
+ }
1612
+ };
1613
+ } catch (error) {
1614
+ yield {
1615
+ check: "nested-roundtrip",
1616
+ message: error instanceof Error ? error.message : String(error),
1617
+ context: { error }
1618
+ };
1619
+ }
1620
+ migratePhase: try {
1621
+ const driver = factory();
1622
+ if (driver.migrate === void 0) break migratePhase;
1623
+ const deployedUsers = {
1624
+ ...CONFORMANCE_USERS_SCHEMA,
1625
+ columns: [...CONFORMANCE_USERS_SCHEMA.columns, {
1626
+ name: "legacy",
1627
+ storage: "boolean",
1628
+ optional: true,
1629
+ nullable: false
1630
+ }]
1631
+ };
1632
+ await driver.open([deployedUsers, CONFORMANCE_POSTS_SCHEMA]);
1633
+ await driver.write("users", "u1", {
1634
+ id: "u1",
1635
+ name: "Ada",
1636
+ age: 30,
1637
+ legacy: true
1638
+ });
1639
+ const removePlan = planMigration([deployedUsers], [CONFORMANCE_USERS_SCHEMA]);
1640
+ await driver.migrate({ plan: removePlan });
1641
+ const migrated = await driver.read("users", "u1");
1642
+ if (migrated === void 0 || "legacy" in migrated) {
1643
+ await driver.close();
1644
+ yield {
1645
+ check: "migrate-column-remove",
1646
+ message: "a column.remove migration must strip the column from stored rows",
1647
+ context: {
1030
1648
  table: "users",
1031
- expected: nested,
1032
- actual: readBack
1033
- });
1649
+ expected: void 0,
1650
+ actual: migrated === void 0 ? void 0 : migrated.legacy
1651
+ }
1652
+ };
1653
+ break migratePhase;
1654
+ }
1655
+ let caught;
1656
+ try {
1657
+ await driver.migrate({ plan: {
1658
+ from: 0,
1659
+ to: 1,
1660
+ steps: [{
1661
+ operation: "table.remove",
1662
+ table: "ghost"
1663
+ }]
1664
+ } });
1665
+ } catch (error) {
1666
+ caught = error;
1667
+ }
1668
+ await driver.close();
1669
+ if (!isDatabaseError(caught) || caught.code !== "MIGRATION") yield {
1670
+ check: "migrate-unknown-table",
1671
+ message: "a migration step referencing an unknown table must throw a MIGRATION DatabaseError",
1672
+ context: {
1673
+ table: "ghost",
1674
+ expected: "MIGRATION",
1675
+ actual: isDatabaseError(caught) ? caught.code : caught
1034
1676
  }
1035
- },
1036
- {
1677
+ };
1678
+ } catch (error) {
1679
+ yield {
1037
1680
  check: "migrate",
1038
- run: async () => {
1039
- const driver = factory();
1040
- if (driver.migrate === void 0) return void 0;
1041
- const deployedUsers = {
1042
- ...CONFORMANCE_USERS_SCHEMA,
1043
- columns: [...CONFORMANCE_USERS_SCHEMA.columns, {
1044
- name: "legacy",
1045
- type: "boolean",
1046
- nullable: true
1047
- }]
1048
- };
1049
- await driver.open([deployedUsers, CONFORMANCE_POSTS_SCHEMA]);
1050
- await driver.write("users", "u1", {
1051
- id: "u1",
1052
- name: "Ada",
1053
- age: 30,
1054
- legacy: true
1055
- });
1056
- const removePlan = planMigration([deployedUsers], [CONFORMANCE_USERS_SCHEMA]);
1057
- await driver.migrate(removePlan);
1058
- const migrated = await driver.read("users", "u1");
1059
- if (migrated === void 0 || "legacy" in migrated) {
1060
- await driver.close();
1061
- return findingOf("migrate-column-remove", "a column.remove migration must strip the column from stored rows", {
1062
- table: "users",
1063
- expected: void 0,
1064
- actual: migrated === void 0 ? void 0 : migrated.legacy
1065
- });
1066
- }
1067
- let caught;
1068
- try {
1069
- await driver.migrate({
1070
- from: 0,
1071
- to: 1,
1072
- steps: [{
1073
- operation: "table.remove",
1074
- table: "ghost"
1075
- }]
1076
- });
1077
- } catch (error) {
1078
- caught = error;
1681
+ message: error instanceof Error ? error.message : String(error),
1682
+ context: { error }
1683
+ };
1684
+ }
1685
+ streamPhase: try {
1686
+ const driver = factory();
1687
+ if (driver.stream === void 0) break streamPhase;
1688
+ await driver.open(CONFORMANCE_SCHEMA);
1689
+ for (const row of [
1690
+ {
1691
+ id: "a",
1692
+ name: "A",
1693
+ age: 10
1694
+ },
1695
+ {
1696
+ id: "b",
1697
+ name: "B",
1698
+ age: 20
1699
+ },
1700
+ {
1701
+ id: "c",
1702
+ name: "C",
1703
+ age: 30
1704
+ }
1705
+ ]) await driver.write("users", row.id, row);
1706
+ const input = { conditions: [{
1707
+ column: "age",
1708
+ operator: "above",
1709
+ values: [10],
1710
+ connector: "and"
1711
+ }] };
1712
+ const matched = [];
1713
+ for await (const row of driver.stream("users", input)) matched.push(row);
1714
+ const matchedIds = matched.map((row) => row.id).sort();
1715
+ if (!equalsValue(matchedIds, ["b", "c"])) {
1716
+ await driver.close();
1717
+ yield {
1718
+ check: "stream-match",
1719
+ message: "stream must yield only condition-matching rows",
1720
+ context: {
1721
+ table: "users",
1722
+ expected: ["b", "c"],
1723
+ actual: matchedIds
1079
1724
  }
1080
- await driver.close();
1081
- if (!isDatabaseError(caught) || caught.code !== "MIGRATION") return findingOf("migrate-unknown-table", "a migration step referencing an unknown table must throw a MIGRATION DatabaseError", {
1082
- table: "ghost",
1083
- expected: "MIGRATION",
1084
- actual: isDatabaseError(caught) ? caught.code : caught
1085
- });
1725
+ };
1726
+ break streamPhase;
1727
+ }
1728
+ const paged = [];
1729
+ for await (const row of driver.stream("users", {
1730
+ offset: 1,
1731
+ limit: 1
1732
+ })) paged.push(row);
1733
+ await driver.close();
1734
+ if (paged.length !== 1) yield {
1735
+ check: "stream-page",
1736
+ message: "stream must honor offset and limit",
1737
+ context: {
1738
+ table: "users",
1739
+ expected: 1,
1740
+ actual: paged.length
1086
1741
  }
1087
- },
1088
- {
1742
+ };
1743
+ } catch (error) {
1744
+ yield {
1089
1745
  check: "stream",
1090
- run: async () => {
1091
- const driver = factory();
1092
- if (driver.stream === void 0) return void 0;
1093
- await driver.open(CONFORMANCE_SCHEMA);
1094
- for (const row of [
1095
- {
1096
- id: "a",
1097
- name: "A",
1098
- age: 10
1099
- },
1100
- {
1101
- id: "b",
1102
- name: "B",
1103
- age: 20
1104
- },
1105
- {
1106
- id: "c",
1107
- name: "C",
1108
- age: 30
1109
- }
1110
- ]) await driver.write("users", row.id, row);
1111
- const criteria = { conditions: [{
1112
- column: "age",
1113
- operator: "above",
1114
- values: [10],
1115
- connector: "and"
1116
- }] };
1117
- const matched = [];
1118
- for await (const row of driver.stream("users", criteria)) matched.push(row);
1119
- const matchedIds = matched.map((row) => row.id).sort();
1120
- if (!deepEqual(matchedIds, ["b", "c"])) {
1121
- await driver.close();
1122
- return findingOf("stream-match", "stream must yield only condition-matching rows", {
1123
- table: "users",
1124
- expected: ["b", "c"],
1125
- actual: matchedIds
1126
- });
1127
- }
1128
- const paged = [];
1129
- for await (const row of driver.stream("users", {
1130
- offset: 1,
1131
- limit: 1
1132
- })) paged.push(row);
1133
- await driver.close();
1134
- if (paged.length !== 1) return findingOf("stream-page", "stream must honor offset and limit", {
1746
+ message: error instanceof Error ? error.message : String(error),
1747
+ context: { error }
1748
+ };
1749
+ }
1750
+ transactionPhase: try {
1751
+ const driver = factory();
1752
+ if (driver.transaction === void 0) break transactionPhase;
1753
+ await driver.open(CONFORMANCE_SCHEMA);
1754
+ await driver.write("users", "u1", {
1755
+ id: "u1",
1756
+ name: "Ada",
1757
+ age: 30
1758
+ });
1759
+ await driver.transaction(async (transaction) => {
1760
+ await transaction.write("users", "u2", {
1761
+ id: "u2",
1762
+ name: "Grace",
1763
+ age: 40
1764
+ });
1765
+ });
1766
+ const afterCommit = [...await driver.keys("users")].sort();
1767
+ if (!equalsValue(afterCommit, ["u1", "u2"])) {
1768
+ await driver.close();
1769
+ yield {
1770
+ check: "transaction-commit",
1771
+ message: "transaction commit must persist writes made during the scope",
1772
+ context: {
1135
1773
  table: "users",
1136
- expected: 1,
1137
- actual: paged.length
1138
- });
1139
- }
1140
- },
1141
- {
1142
- check: "transaction",
1143
- run: async () => {
1144
- const driver = factory();
1145
- if (driver.transaction === void 0) return void 0;
1146
- await driver.open(CONFORMANCE_SCHEMA);
1147
- await driver.write("users", "u1", {
1148
- id: "u1",
1149
- name: "Ada",
1150
- age: 30
1151
- });
1152
- const committing = await driver.transaction();
1153
- await driver.write("users", "u2", {
1154
- id: "u2",
1155
- name: "Grace",
1156
- age: 40
1157
- });
1158
- await committing.commit();
1159
- const afterCommit = [...await driver.keys("users")].sort();
1160
- if (!deepEqual(afterCommit, ["u1", "u2"])) {
1161
- await driver.close();
1162
- return findingOf("transaction-commit", "transaction commit must persist writes made during the scope", {
1163
- table: "users",
1164
- expected: ["u1", "u2"],
1165
- actual: afterCommit
1166
- });
1774
+ expected: ["u1", "u2"],
1775
+ actual: afterCommit
1167
1776
  }
1168
- const rollingBack = await driver.transaction();
1169
- await driver.write("users", "u3", {
1777
+ };
1778
+ break transactionPhase;
1779
+ }
1780
+ const reason = {};
1781
+ try {
1782
+ await driver.transaction(async (transaction) => {
1783
+ await transaction.write("users", "u3", {
1170
1784
  id: "u3",
1171
1785
  name: "Marie",
1172
1786
  age: 50
1173
1787
  });
1174
- await rollingBack.rollback();
1175
- const afterRollback = [...await driver.keys("users")].sort();
1176
- await driver.close();
1177
- if (!deepEqual(afterRollback, ["u1", "u2"])) return findingOf("transaction-rollback", "transaction rollback must restore pre-transaction state", {
1178
- table: "users",
1179
- expected: ["u1", "u2"],
1180
- actual: afterRollback
1181
- });
1788
+ throw reason;
1789
+ });
1790
+ } catch (error) {
1791
+ if (error !== reason) throw error;
1792
+ }
1793
+ const afterRollback = [...await driver.keys("users")].sort();
1794
+ await driver.close();
1795
+ if (!equalsValue(afterRollback, ["u1", "u2"])) yield {
1796
+ check: "transaction-rollback",
1797
+ message: "transaction rollback must restore pre-transaction state",
1798
+ context: {
1799
+ table: "users",
1800
+ expected: ["u1", "u2"],
1801
+ actual: afterRollback
1182
1802
  }
1183
- },
1184
- {
1185
- check: "meta-stamp",
1186
- run: async () => {
1187
- const driver = factory();
1188
- if (driver.meta === void 0 || driver.stamp === void 0) return void 0;
1189
- await driver.open(CONFORMANCE_SCHEMA);
1190
- const fresh = await driver.meta();
1191
- if (fresh !== void 0) {
1192
- await driver.close();
1193
- return findingOf("meta-fresh", "a fresh store must report undefined meta", {
1194
- expected: void 0,
1195
- actual: fresh
1196
- });
1803
+ };
1804
+ } catch (error) {
1805
+ yield {
1806
+ check: "transaction",
1807
+ message: error instanceof Error ? error.message : String(error),
1808
+ context: { error }
1809
+ };
1810
+ }
1811
+ metadataPhase: try {
1812
+ const driver = factory();
1813
+ if (driver.metadata === void 0 || driver.stamp === void 0) break metadataPhase;
1814
+ await driver.open(CONFORMANCE_SCHEMA);
1815
+ const fresh = await driver.metadata();
1816
+ if (fresh !== void 0) {
1817
+ await driver.close();
1818
+ yield {
1819
+ check: "metadata-fresh",
1820
+ message: "a fresh store must report undefined metadata",
1821
+ context: {
1822
+ expected: void 0,
1823
+ actual: fresh
1197
1824
  }
1198
- const stamped = {
1199
- version: 1,
1200
- schema: CONFORMANCE_SCHEMA
1201
- };
1202
- await driver.stamp(stamped);
1203
- const read = await driver.meta();
1204
- await driver.close();
1205
- if (read === void 0 || !deepEqual(read, stamped)) return findingOf("meta-stamp", "meta() must return exactly the last-stamped value", {
1206
- expected: stamped,
1207
- actual: read
1208
- });
1825
+ };
1826
+ break metadataPhase;
1827
+ }
1828
+ const stamped = {
1829
+ version: 1,
1830
+ schema: CONFORMANCE_SCHEMA
1831
+ };
1832
+ await driver.stamp(stamped);
1833
+ const read = await driver.metadata();
1834
+ await driver.close();
1835
+ if (read === void 0 || !equalsValue(read, stamped)) yield {
1836
+ check: "metadata-stamp",
1837
+ message: "metadata() must return exactly the last-stamped value",
1838
+ context: {
1839
+ expected: stamped,
1840
+ actual: read
1209
1841
  }
1210
- },
1211
- {
1212
- check: "snapshot-scoped",
1213
- run: async () => {
1214
- const driver = factory();
1215
- await driver.open(CONFORMANCE_SCHEMA);
1216
- await driver.write("users", "u1", {
1217
- id: "u1",
1218
- name: "Ada",
1219
- age: 30
1220
- });
1221
- await driver.write("posts", "p1", {
1222
- slug: "p1",
1223
- title: "Post"
1224
- });
1225
- const rollback = await driver.snapshot(["users"]);
1226
- await driver.write("users", "u2", {
1227
- id: "u2",
1228
- name: "Grace",
1229
- age: 40
1230
- });
1231
- await driver.write("posts", "p2", {
1232
- slug: "p2",
1233
- title: "Another post"
1234
- });
1235
- await rollback();
1236
- const usersKeys = [...await driver.keys("users")];
1237
- if (!deepEqual(usersKeys, ["u1"])) {
1238
- await driver.close();
1239
- return findingOf("snapshot-scoped-users", "a scoped snapshot must roll back only the named table", {
1240
- table: "users",
1241
- expected: ["u1"],
1242
- actual: usersKeys
1243
- });
1842
+ };
1843
+ } catch (error) {
1844
+ yield {
1845
+ check: "metadata-stamp",
1846
+ message: error instanceof Error ? error.message : String(error),
1847
+ context: { error }
1848
+ };
1849
+ }
1850
+ scopedPhase: try {
1851
+ const driver = factory();
1852
+ await driver.open(CONFORMANCE_SCHEMA);
1853
+ await driver.write("users", "u1", {
1854
+ id: "u1",
1855
+ name: "Ada",
1856
+ age: 30
1857
+ });
1858
+ await driver.write("posts", "p1", {
1859
+ slug: "p1",
1860
+ title: "Post"
1861
+ });
1862
+ const rollback = await driver.snapshot(["users"]);
1863
+ await driver.write("users", "u2", {
1864
+ id: "u2",
1865
+ name: "Grace",
1866
+ age: 40
1867
+ });
1868
+ await driver.write("posts", "p2", {
1869
+ slug: "p2",
1870
+ title: "Another post"
1871
+ });
1872
+ await rollback();
1873
+ const usersKeys = [...await driver.keys("users")];
1874
+ if (!equalsValue(usersKeys, ["u1"])) {
1875
+ await driver.close();
1876
+ yield {
1877
+ check: "snapshot-scoped-users",
1878
+ message: "a scoped snapshot must roll back only the named table",
1879
+ context: {
1880
+ table: "users",
1881
+ expected: ["u1"],
1882
+ actual: usersKeys
1244
1883
  }
1245
- const postsKeys = [...await driver.keys("posts")].sort();
1246
- await driver.close();
1247
- if (!deepEqual(postsKeys, ["p1", "p2"])) return findingOf("snapshot-scoped-posts", "a scoped snapshot must leave an unnamed table's mutations intact", {
1248
- table: "posts",
1249
- expected: ["p1", "p2"],
1250
- actual: postsKeys
1251
- });
1252
- }
1884
+ };
1885
+ break scopedPhase;
1253
1886
  }
1254
- ];
1255
- for (const phase of phases) try {
1256
- const finding = await phase.run();
1257
- if (finding !== void 0) yield finding;
1887
+ const postsKeys = [...await driver.keys("posts")].sort();
1888
+ await driver.close();
1889
+ if (!equalsValue(postsKeys, ["p1", "p2"])) yield {
1890
+ check: "snapshot-scoped-posts",
1891
+ message: "a scoped snapshot must leave an unnamed table's mutations intact",
1892
+ context: {
1893
+ table: "posts",
1894
+ expected: ["p1", "p2"],
1895
+ actual: postsKeys
1896
+ }
1897
+ };
1258
1898
  } catch (error) {
1259
- yield findingOf(phase.check, error instanceof Error ? error.message : String(error), { error });
1899
+ yield {
1900
+ check: "snapshot-scoped",
1901
+ message: error instanceof Error ? error.message : String(error),
1902
+ context: { error }
1903
+ };
1260
1904
  }
1261
1905
  }
1262
1906
  /**
@@ -1315,40 +1959,424 @@ async function auditDriver(factory) {
1315
1959
  for await (const finding of driverFindings(factory)) findings.push(finding);
1316
1960
  return findings;
1317
1961
  }
1962
+ //#endregion
1963
+ //#region src/core/TransactionIterator.ts
1318
1964
  /**
1319
- * Generate an RFC 4122 version 4 UUID from a number source — no host crypto global.
1965
+ * The internal continuation boundary for one transaction-scoped async iterable.
1320
1966
  *
1321
1967
  * @remarks
1322
- * Draws exactly {@link UUID_BYTE_COUNT} values from `random`, one per byte, then
1323
- * forces the version (`4`) and variant (`10xx`) bits. The default source is
1324
- * `Math.random` a pure-ECMAScript intrinsic, so generation works on every host;
1325
- * pass a seeded source (`seededRandom` from `@orkestrel/contract`) and reuse it
1326
- * across calls for reproducible sequences in tests and fixtures — production
1327
- * identifiers should keep the default source, whose engine entropy is far larger
1328
- * than a 32-bit seed. Each byte is floored and masked, so a source straying
1329
- * outside `[0, 1)` (negative, `>= 1`, `NaN`, `Infinity`) can never yield a
1330
- * malformed UUID. Suitable as a collision-resistant record identifier — not a
1331
- * cryptographic token; never use one as a secret.
1332
- *
1333
- * @param random - A number source returning values in the half-open range `[0, 1)` (defaults to `Math.random`)
1334
- * @returns A lowercase RFC 4122 version 4 UUID
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.
1971
+ */
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.
1335
2039
  *
1336
- * @example
1337
- * ```ts
1338
- * import { generateUUID } from '@orkestrel/database'
1339
- * import { seededRandom } from '@orkestrel/contract'
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.
1340
2096
  *
1341
- * generateUUID() // e.g. '9b2f7c1e-3d4a-4f6b-8e2d-5a1c0b9f8e7d'
1342
- * generateUUID(seededRandom(42)) // the same UUID on every run
1343
- * ```
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.
1344
2101
  */
1345
- function generateUUID(random = Math.random) {
1346
- const bytes = Array.from({ length: 16 }, () => Math.floor(random() * 256) & 255);
1347
- bytes[6] = bytes[6] & 15 | 64;
1348
- bytes[8] = bytes[8] & 63 | 128;
1349
- const hex = bytes.map((byte) => byte.toString(16).padStart(2, "0"));
1350
- 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("")}`;
1351
- }
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
+ };
1352
2380
  //#endregion
1353
2381
  //#region src/core/Cursor.ts
1354
2382
  /**
@@ -1361,14 +2389,21 @@ function generateUUID(random = Math.random) {
1361
2389
  * skipped. `update` and `remove` act on the row at the current position.
1362
2390
  */
1363
2391
  var Cursor = class {
1364
- #table;
1365
2392
  #keys;
2393
+ #read;
2394
+ #update;
2395
+ #remove;
2396
+ #track;
2397
+ #tail = Promise.resolve();
1366
2398
  #index = -1;
1367
2399
  #value;
1368
2400
  #closed = false;
1369
- constructor(table, keys) {
1370
- this.#table = table;
2401
+ constructor(keys, read, update, remove, track) {
1371
2402
  this.#keys = keys;
2403
+ this.#read = read;
2404
+ this.#update = update;
2405
+ this.#remove = remove;
2406
+ this.#track = track;
1372
2407
  }
1373
2408
  get value() {
1374
2409
  return this.#value;
@@ -1379,11 +2414,31 @@ var Cursor = class {
1379
2414
  get done() {
1380
2415
  return this.#closed || this.#index >= this.#keys.length;
1381
2416
  }
1382
- 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() {
1383
2431
  if (this.#closed) return;
1384
2432
  this.#index += 1;
1385
2433
  while (this.#index < this.#keys.length) {
1386
- const row = await this.#table.get(this.#keys[this.#index]);
2434
+ if (this.#closed) return;
2435
+ const key = this.#keys[this.#index];
2436
+ if (key === void 0) {
2437
+ this.#index += 1;
2438
+ continue;
2439
+ }
2440
+ const row = await this.#read(key);
2441
+ if (this.#closed) return;
1387
2442
  if (row !== void 0) {
1388
2443
  this.#value = row;
1389
2444
  return;
@@ -1392,94 +2447,103 @@ var Cursor = class {
1392
2447
  }
1393
2448
  this.#value = void 0;
1394
2449
  }
1395
- async update(changes) {
2450
+ async #revise(changes) {
1396
2451
  if (this.#closed || this.#value === void 0) return;
1397
2452
  const key = this.#keys[this.#index];
1398
- await this.#table.update(key, changes);
1399
- this.#value = await this.#table.get(key);
2453
+ if (key === void 0) return;
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;
1400
2459
  }
1401
- async remove() {
2460
+ async #delete() {
1402
2461
  if (this.#closed || this.#value === void 0) return;
1403
- await this.#table.remove(this.#keys[this.#index]);
2462
+ const key = this.#keys[this.#index];
2463
+ if (key === void 0) return;
2464
+ await this.#remove(key);
2465
+ if (this.#closed) return;
1404
2466
  this.#value = void 0;
1405
2467
  }
1406
- close() {
1407
- this.#closed = true;
1408
- 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;
1409
2472
  }
1410
2473
  };
1411
2474
  //#endregion
1412
- //#region src/core/Clause.ts
2475
+ //#region src/core/DatabaseIterator.ts
1413
2476
  /**
1414
- * A pending condition opened by a query's `where` / `and` / `or`.
2477
+ * The internal continuation admission boundary for a root database stream.
1415
2478
  *
1416
2479
  * @remarks
1417
- * Holds the column, the connector that will join this condition to the ones
1418
- * before it, and a recorder the owning query supplies. Each operator builds the
1419
- * {@link Condition}, hands it to the recorder, and returns the query — so the
1420
- * 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.
1421
2484
  */
1422
- var Clause = class {
1423
- #record;
1424
- #column;
1425
- #connector;
1426
- constructor(record, column, connector) {
1427
- this.#record = record;
1428
- this.#column = column;
1429
- this.#connector = connector;
1430
- }
1431
- equals(value) {
1432
- return this.#apply("equals", [value]);
1433
- }
1434
- not(value) {
1435
- return this.#apply("not", [value]);
1436
- }
1437
- above(value) {
1438
- return this.#apply("above", [value]);
1439
- }
1440
- below(value) {
1441
- return this.#apply("below", [value]);
1442
- }
1443
- from(value) {
1444
- return this.#apply("from", [value]);
1445
- }
1446
- to(value) {
1447
- return this.#apply("to", [value]);
1448
- }
1449
- between(lower, upper) {
1450
- return this.#apply("between", [lower, upper]);
1451
- }
1452
- like(pattern) {
1453
- return this.#apply("like", [pattern]);
1454
- }
1455
- glob(pattern) {
1456
- 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;
1457
2495
  }
1458
- starts(prefix) {
1459
- return this.#apply("starts", [prefix]);
2496
+ next() {
2497
+ return this.#continue(() => this.#next());
1460
2498
  }
1461
- ends(suffix) {
1462
- return this.#apply("ends", [suffix]);
2499
+ return() {
2500
+ return this.#continue(() => this.#return());
1463
2501
  }
1464
- any(values) {
1465
- return this.#apply("any", values);
2502
+ throw(error) {
2503
+ return this.#continue(() => this.#throw(error));
1466
2504
  }
1467
- none(values) {
1468
- 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;
1469
2514
  }
1470
- absent() {
1471
- 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;
1472
2536
  }
1473
- present() {
1474
- return this.#apply("present", []);
2537
+ #continue(operation) {
2538
+ if (!this.#context.accepting) this.#cleanup();
2539
+ return this.#context.track(operation);
1475
2540
  }
1476
- #apply(operator, values) {
1477
- return this.#record({
1478
- column: this.#column,
1479
- operator,
1480
- values,
1481
- connector: this.#connector
1482
- });
2541
+ #cleanup() {
2542
+ if (this.#cleaned) return;
2543
+ this.#cleaned = true;
2544
+ try {
2545
+ (this.#source.return?.())?.catch(() => {});
2546
+ } catch {}
1483
2547
  }
1484
2548
  };
1485
2549
  //#endregion
@@ -1488,12 +2552,9 @@ var Clause = class {
1488
2552
  * A fluent query builder bound to one table.
1489
2553
  *
1490
2554
  * @remarks
1491
- * Accumulates conditions, ordering, JS filters, and a page; each builder method
1492
- * mutates and returns the same instance, so a chain reads as one statement. The
1493
- * portable parts (conditions, order, page) compile into a {@link Criteria} the
1494
- * table resolves; a `filter` predicate is applied in memory after the read and
1495
- * before paging, so it composes with the rest without a backend ever seeing a
1496
- * 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.
1497
2558
  */
1498
2559
  var Query = class {
1499
2560
  #table;
@@ -1505,47 +2566,34 @@ var Query = class {
1505
2566
  constructor(table) {
1506
2567
  this.#table = table;
1507
2568
  }
1508
- where(column) {
1509
- return this.#clause(column, "and");
1510
- }
1511
- and(column) {
1512
- return this.#clause(column, "and");
1513
- }
1514
- or(column) {
1515
- return this.#clause(column, "or");
1516
- }
1517
- filter(predicate) {
1518
- this.#filters.push(predicate);
2569
+ condition(input) {
2570
+ this.#conditions.push(input);
1519
2571
  return this;
1520
2572
  }
1521
- ascending(column) {
1522
- this.#orders.push({
1523
- column,
1524
- direction: "ascending"
1525
- });
2573
+ order(input) {
2574
+ this.#orders.push(input);
1526
2575
  return this;
1527
2576
  }
1528
- descending(column) {
1529
- this.#orders.push({
1530
- column,
1531
- direction: "descending"
1532
- });
2577
+ filter(predicate) {
2578
+ this.#filters.push(predicate);
1533
2579
  return this;
1534
2580
  }
1535
2581
  limit(count) {
2582
+ validatePage({ limit: count });
1536
2583
  this.#limit = count;
1537
2584
  return this;
1538
2585
  }
1539
2586
  offset(count) {
2587
+ validatePage({ offset: count });
1540
2588
  this.#offset = count;
1541
2589
  return this;
1542
2590
  }
1543
- async all() {
2591
+ async collect() {
1544
2592
  if (this.#filters.length === 0) return this.#table.records({
1545
2593
  conditions: this.#conditions,
1546
2594
  order: this.#orders,
1547
- limit: this.#limit,
1548
- offset: this.#offset
2595
+ ...this.#limit !== void 0 ? { limit: this.#limit } : {},
2596
+ ...this.#offset !== void 0 ? { offset: this.#offset } : {}
1549
2597
  });
1550
2598
  const fetched = await this.#table.records({
1551
2599
  conditions: this.#conditions,
@@ -1553,8 +2601,8 @@ var Query = class {
1553
2601
  });
1554
2602
  return this.#page(this.#filtered(fetched));
1555
2603
  }
1556
- async first() {
1557
- return (await this.all())[0];
2604
+ async find() {
2605
+ return (await this.collect())[0];
1558
2606
  }
1559
2607
  async count() {
1560
2608
  if (this.#filters.length === 0) return this.#table.count({ conditions: this.#conditions });
@@ -1562,24 +2610,17 @@ var Query = class {
1562
2610
  return this.#filtered(fetched).length;
1563
2611
  }
1564
2612
  /**
1565
- * Lazy per-row evaluation of this query's conditions / filters / offset /
1566
- * limit.
2613
+ * Lazily evaluate conditions, filters, offset, and limit.
1567
2614
  *
1568
- * @remarks
1569
- * `order` and its comparators are IGNORED (streaming yields unsorted, as rows
1570
- * are evaluated one at a time). Same abort semantics as
1571
- * `TableInterface.scan`: the signal (if any) is checked before each yield,
1572
- * and breaking out early closes the underlying source.
1573
- *
1574
- * @param options - `signal` to cancel the iteration; checked before each yield
1575
- * @returns An async iterable of matching rows
2615
+ * @param options - Optional abort options
2616
+ * @returns Matching rows in storage order
1576
2617
  */
1577
2618
  async *stream(options) {
1578
2619
  if (this.#filters.length === 0) {
1579
2620
  yield* this.#table.scan({
1580
2621
  conditions: this.#conditions,
1581
- limit: this.#limit,
1582
- offset: this.#offset
2622
+ ...this.#limit !== void 0 ? { limit: this.#limit } : {},
2623
+ ...this.#offset !== void 0 ? { offset: this.#offset } : {}
1583
2624
  }, options);
1584
2625
  return;
1585
2626
  }
@@ -1607,24 +2648,6 @@ var Query = class {
1607
2648
  if (this.#filters.length === 0) return this.#table.aggregate(operation, column, { conditions: this.#conditions });
1608
2649
  return this.#table.records({ conditions: this.#conditions }).then((fetched) => computeAggregate(this.#filtered(fetched), operation, column));
1609
2650
  }
1610
- sum(column) {
1611
- return this.aggregate("sum", column);
1612
- }
1613
- average(column) {
1614
- return this.aggregate("average", column);
1615
- }
1616
- minimum(column) {
1617
- return this.aggregate("minimum", column);
1618
- }
1619
- maximum(column) {
1620
- return this.aggregate("maximum", column);
1621
- }
1622
- #clause(column, connector) {
1623
- return new Clause((condition) => {
1624
- this.#conditions.push(condition);
1625
- return this;
1626
- }, column, connector);
1627
- }
1628
2651
  #filtered(rows) {
1629
2652
  let result = rows;
1630
2653
  for (const predicate of this.#filters) result = result.filter(predicate);
@@ -1667,19 +2690,20 @@ var Table = class {
1667
2690
  #contract;
1668
2691
  #guard;
1669
2692
  #generate;
2693
+ #context;
2694
+ #scope;
1670
2695
  #emitter;
1671
- constructor(ready, driver, name, key, contract, generate, on, error) {
2696
+ constructor(ready, driver, name, key, contract, generate, error, context, scope) {
1672
2697
  this.#ready = ready;
1673
2698
  this.#driver = driver;
1674
2699
  this.#name = name;
1675
2700
  this.#key = key;
1676
2701
  this.#contract = contract;
1677
- this.#guard = contract.is;
1678
- this.#generate = generate;
1679
- this.#emitter = new Emitter({
1680
- on,
1681
- error
1682
- });
2702
+ this.#guard = contract.is;
2703
+ this.#generate = generate;
2704
+ this.#context = context;
2705
+ this.#scope = scope;
2706
+ this.#emitter = new Emitter({ ...error !== void 0 ? { error } : {} });
1683
2707
  }
1684
2708
  get emitter() {
1685
2709
  return this.#emitter;
@@ -1693,58 +2717,77 @@ var Table = class {
1693
2717
  get contract() {
1694
2718
  return this.#contract;
1695
2719
  }
1696
- async get(keys) {
1697
- await this.#ready();
1698
- if (isArray(keys)) return this.#each(keys, (key) => this.#read(key));
1699
- 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
+ });
1700
2726
  }
1701
- async resolve(keys) {
1702
- await this.#ready();
1703
- if (isArray(keys)) return this.#each(keys, (key) => this.#resolveOne(key));
1704
- 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
+ });
1705
2733
  }
1706
- async has(keys) {
1707
- await this.#ready();
1708
- if (isArray(keys)) return this.#each(keys, async (key) => await this.#read(key) !== void 0);
1709
- 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
+ });
1710
2740
  }
1711
- async keys() {
1712
- await this.#ready();
1713
- 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
+ });
1714
2746
  }
1715
- async records(criteria, options) {
1716
- checkAbort(options?.signal);
1717
- await this.#ready();
1718
- const source = await this.#driver.records?.(this.#name, criteria ?? {}) ?? applyCriteria(await this.#collect(), criteria);
1719
- const rows = [];
1720
- for (const row of source) if (this.#guard(row)) rows.push(row);
1721
- 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
+ });
1722
2763
  }
1723
2764
  /**
1724
- * Count rows matching `criteria`'s conditions.
2765
+ * Count contract-valid rows matching `input`'s conditions.
1725
2766
  *
1726
2767
  * @remarks
1727
- * Unlike {@link records}, which narrows every row through the table's
1728
- * contract guard before returning it, `count` operates on STORED rows
1729
- * WITHOUT that guard (both the native `driver.count` hook and the
1730
- * `filterRows`-over-`#collect()` fallback count raw storage) — so it can
1731
- * exceed `(await records(criteria)).length` when storage holds rows that
1732
- * 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()`.
1733
2771
  *
1734
- * @param criteria - Optional conditions to filter by (paging is ignored)
2772
+ * @param input - Optional conditions to filter by (paging is ignored)
1735
2773
  * @param options - `{ signal }` to abort
1736
- * @returns The count of matching stored rows
2774
+ * @returns The count of matching contract-valid rows
1737
2775
  */
1738
- async count(criteria, options) {
1739
- checkAbort(options?.signal);
1740
- await this.#ready();
1741
- const conditions = criteria?.conditions;
1742
- const native = await this.#driver.count?.(this.#name, conditions ? { conditions } : {});
1743
- if (native !== void 0) return native;
1744
- 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
+ });
1745
2788
  }
1746
2789
  /**
1747
- * Compute an aggregate over `column` across rows matching `criteria`'s
2790
+ * Compute an aggregate over `column` across rows matching `input`'s
1748
2791
  * conditions.
1749
2792
  *
1750
2793
  * @remarks
@@ -1756,115 +2799,138 @@ var Table = class {
1756
2799
  *
1757
2800
  * @param operation - The aggregate to compute
1758
2801
  * @param column - The column to aggregate
1759
- * @param criteria - Optional conditions to filter by (paging is ignored)
2802
+ * @param input - Optional conditions to filter by (paging is ignored)
1760
2803
  * @param options - `{ signal }` to abort
1761
2804
  * @returns The aggregate value, or `undefined` when undefined for the inputs
1762
2805
  */
1763
- async aggregate(operation, column, criteria, options) {
1764
- checkAbort(options?.signal);
1765
- await this.#ready();
1766
- const conditions = criteria?.conditions;
1767
- const filter = conditions ? { conditions } : {};
1768
- const native = this.#driver.aggregate?.(this.#name, operation, column, filter);
1769
- if (native !== void 0) return native;
1770
- 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
+ });
1771
2817
  }
1772
2818
  /**
1773
- * Stream the table's rows matching `criteria`, applying offset/limit paging.
2819
+ * Stream the table's rows matching `input`, applying offset/limit paging.
1774
2820
  *
1775
2821
  * @remarks
1776
- * `criteria.limit` counts rows that pass BOTH the criteria conditions AND the
1777
- * table's contract guard (a stored row that fails the guard is skipped and
1778
- * does not count toward `limit`) this can differ from {@link records}'s
1779
- * `limit`, which a driver's optional native `records` hook applies BEFORE
1780
- * the contract guard runs, when storage holds rows that no longer conform
1781
- * 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.
1782
2826
  *
1783
- * @param criteria - Optional conditions plus offset/limit paging
2827
+ * @param input - Optional conditions plus offset/limit paging
1784
2828
  * @param options - `{ signal }` to abort mid-stream
1785
2829
  * @returns An async iterable of matching, guard-conforming rows
1786
2830
  */
1787
- async *scan(criteria, options) {
1788
- checkAbort(options?.signal);
1789
- await this.#ready();
1790
- if (this.#driver.stream !== void 0) {
1791
- for await (const row of this.#driver.stream(this.#name, criteria ?? {})) {
1792
- checkAbort(options?.signal);
1793
- const narrowed = this.#cast(row);
1794
- if (narrowed !== void 0) yield narrowed;
1795
- }
1796
- return;
1797
- }
1798
- const conditions = criteria?.conditions;
1799
- const offset = criteria?.offset ?? 0;
1800
- const limit = criteria?.limit;
1801
- let matched = 0;
1802
- let yielded = 0;
1803
- for await (const row of this.#driver.scan(this.#name)) {
1804
- checkAbort(options?.signal);
1805
- if (limit !== void 0 && yielded >= limit) break;
1806
- if (conditions !== void 0 && conditions.length > 0 && !matchesCriteria(row, conditions)) continue;
1807
- if (matched < offset) {
1808
- matched += 1;
1809
- continue;
1810
- }
1811
- matched += 1;
1812
- const narrowed = this.#cast(row);
1813
- if (narrowed !== void 0) {
1814
- yielded += 1;
1815
- yield narrowed;
1816
- }
1817
- }
1818
- }
1819
- async set(rows, options) {
1820
- checkAbort(options?.signal);
1821
- await this.#ready();
1822
- if (isArray(rows)) return this.#each(rows, (row) => this.#put(row, false), options?.signal);
1823
- 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
+ });
1824
2843
  }
1825
- async add(rows, options) {
1826
- checkAbort(options?.signal);
1827
- await this.#ready();
1828
- if (isArray(rows)) return this.#each(rows, (row) => this.#put(row, true), options?.signal);
1829
- 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
+ });
1830
2850
  }
1831
- async update(keys, changes, options) {
1832
- checkAbort(options?.signal);
1833
- await this.#ready();
1834
- if (isArray(keys)) return this.#each(keys, (key) => this.#updateOne(key, changes), options?.signal);
1835
- 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
+ });
1836
2857
  }
1837
- async remove(keys, options) {
1838
- checkAbort(options?.signal);
1839
- await this.#ready();
1840
- if (isArray(keys)) return this.#each(keys, (key) => this.#delete(key), options?.signal);
1841
- 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
+ });
1842
2864
  }
1843
- async clear() {
1844
- await this.#ready();
1845
- await this.#driver.clear(this.#name);
1846
- 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
+ });
1847
2871
  }
1848
2872
  query() {
1849
2873
  return new Query(this);
1850
2874
  }
1851
- 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);
1852
2887
  await this.#ready();
1853
- const cursor = new Cursor(this, await this.#driver.keys(this.#name));
1854
- await cursor.next();
1855
- 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
+ }
1856
2918
  }
1857
- async #each(items, operation, signal) {
2919
+ async #each(elements, operation, signal) {
1858
2920
  const results = [];
1859
- for (const item of items) {
2921
+ for (const element of elements) {
1860
2922
  checkAbort(signal);
1861
- results.push(await operation(item));
2923
+ results.push(await operation(element));
1862
2924
  }
1863
2925
  return results;
1864
2926
  }
1865
2927
  async #read(key) {
1866
2928
  return this.#cast(await this.#driver.read(this.#name, key));
1867
2929
  }
2930
+ async #readCursor(key) {
2931
+ await this.#ready();
2932
+ return this.#read(key);
2933
+ }
1868
2934
  async #resolveOne(key) {
1869
2935
  const row = await this.#read(key);
1870
2936
  if (row === void 0) throw new DatabaseError("NOT_FOUND", `No row '${key}' in table '${this.#name}'`, {
@@ -1873,29 +2939,79 @@ var Table = class {
1873
2939
  });
1874
2940
  return row;
1875
2941
  }
1876
- async #put(row, exclusive) {
2942
+ async #put(row, insert, options) {
1877
2943
  const validated = this.#validate(this.#prepare(row));
1878
2944
  const key = this.#resolveKey(validated);
1879
- if (exclusive && await this.#driver.read(this.#name, key) !== void 0) throw new DatabaseError("CONFLICT", `Row '${key}' already exists in table '${this.#name}'`, {
1880
- table: this.#name,
1881
- key
1882
- });
1883
- 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);
1884
2947
  this.#emitter.emit("write", key);
1885
2948
  return key;
1886
2949
  }
1887
- async #updateOne(key, changes) {
2950
+ async #updateOne(key, changes, options) {
1888
2951
  const existing = await this.#driver.read(this.#name, key);
1889
- if (existing === void 0) return false;
1890
- 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);
1891
2963
  this.#emitter.emit("write", key);
1892
2964
  return true;
1893
2965
  }
1894
- async #delete(key) {
1895
- 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);
1896
2972
  if (removed) this.#emitter.emit("remove", key);
1897
2973
  return removed;
1898
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
+ }
1899
3015
  async #collect() {
1900
3016
  const rows = [];
1901
3017
  for await (const row of this.#driver.scan(this.#name)) rows.push(row);
@@ -1904,21 +3020,38 @@ var Table = class {
1904
3020
  #prepare(row) {
1905
3021
  if (!isRecord(row)) throw new DatabaseError("VALIDATION", `Row for table '${this.#name}' is not a record`, { table: this.#name });
1906
3022
  const prepared = { ...row };
1907
- if (prepared[this.#key] === void 0) {
1908
- 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}'`, {
1909
3027
  table: this.#name,
1910
- 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
1911
3039
  });
1912
- prepared[this.#key] = this.#generate();
1913
3040
  }
1914
3041
  return prepared;
1915
3042
  }
1916
3043
  #validate(row) {
1917
3044
  const parsed = this.#contract.parse(row);
1918
- if (parsed === void 0 || !isRecord(parsed)) throw new DatabaseError("VALIDATION", `Row failed the '${this.#name}' contract`, {
1919
- table: this.#name,
1920
- row
1921
- });
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
+ }
1922
3055
  return parsed;
1923
3056
  }
1924
3057
  #resolveKey(row) {
@@ -1932,265 +3065,186 @@ var Table = class {
1932
3065
  #cast(row) {
1933
3066
  return row !== void 0 && this.#guard(row) ? row : void 0;
1934
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
+ }
1935
3072
  };
1936
3073
  //#endregion
1937
- //#region src/core/Database.ts
3074
+ //#region src/core/DatabaseTransaction.ts
1938
3075
  /**
1939
- * 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
1940
3079
  *
1941
3080
  * @remarks
1942
- * Owns the driver and a `tables` shape map, connecting the driver lazily on first
1943
- * use so a freshly created database is immediately usable. `table(name)` returns
1944
- * a table typed by that table's shape `Infer`. `import` registers more tables and
1945
- * returns a database re-typed with them over the **same** driver and storage;
1946
- * `export` emits a portable {@link TableExport} per table. `transaction` snapshots
1947
- * the driver, runs the scope, and rolls every table back if it throws — an
1948
- * optimistic model that works uniformly across backends rather than reconciling
1949
- * 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.
1950
3122
  *
1951
3123
  * @remarks
1952
- * - **Versioned (optional).** When {@link DatabaseOptions.version} is set and the driver
1953
- * implements both {@link DriverInterface.meta} and {@link DriverInterface.stamp},
1954
- * `open()` reconciles the driver's persisted {@link DriverMeta} against the declared
1955
- * version INSIDE the same lazy-connect chain, AFTER the `open` event fires — see
1956
- * {@link DatabaseOptions.version} for the full reconciliation contract.
1957
- * - **Observable (§13).** The owned {@link emitter} ({@link DatabaseEventMap}) carries the
1958
- * connection + transaction lifecycle — `open` / `close` / `transaction` / `commit` /
1959
- * `rollback` — for fire-and-forget observers, ALONGSIDE each table's per-row events. Every
1960
- * event is emitted directly, strictly AFTER the relevant transition: `commit` only after
1961
- * the scope succeeds, `rollback` only after every table is restored. The `rollback` emit
1962
- * OBSERVES the propagated error — it never swallows it (the original throw propagates
1963
- * exactly as before). The emitter isolates a listener throw and routes it to its `error`
1964
- * handler (the `error` option), so observation can never reorder, throw into, or corrupt
1965
- * 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.
1966
3128
  */
1967
3129
  var Database = class Database {
1968
- #driver;
3130
+ #context;
1969
3131
  #tables;
1970
- #keys;
3132
+ #primary;
1971
3133
  #indexes;
1972
- #name;
1973
3134
  #generate;
1974
- #version;
1975
- #emitter;
1976
- #status = "idle";
1977
- #ready;
1978
3135
  constructor(options) {
1979
- this.#driver = options.driver;
1980
3136
  this.#tables = options.tables;
1981
- this.#keys = options.keys ?? {};
3137
+ this.#primary = options.primary ?? {};
1982
3138
  this.#indexes = options.indexes ?? {};
1983
- this.#name = options.name ?? "database";
1984
- this.#generate = options.key;
1985
- this.#version = options.version;
1986
- this.#emitter = new Emitter({
1987
- on: options.on,
1988
- error: options.error
1989
- });
3139
+ this.#generate = options.generator;
3140
+ this.#context = new DatabaseContext(options);
3141
+ this.#context.register(this.#schema());
1990
3142
  }
1991
3143
  get emitter() {
1992
- return this.#emitter;
3144
+ return this.#context.emitter;
1993
3145
  }
1994
3146
  get name() {
1995
- return this.#name;
3147
+ return this.#context.name;
1996
3148
  }
1997
3149
  get status() {
1998
- return this.#status;
3150
+ return this.#context.status;
1999
3151
  }
2000
3152
  table(name) {
2001
- if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
2002
- return this.#build(name, this.#key(name), createContract(objectShape(this.#tables[name])));
3153
+ if (this.#context.status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#context.name}' is closed`, { name: this.#context.name });
3154
+ const columns = this.#columns(name);
3155
+ return this.#build(name, this.#key(name), createContract(objectShape(columns)));
2003
3156
  }
2004
- import(tables, keys) {
3157
+ import(tables, primary) {
2005
3158
  return this.#spawn(tables, {
2006
- ...this.#keys,
2007
- ...keys
3159
+ ...this.#primary,
3160
+ ...primary
2008
3161
  });
2009
3162
  }
2010
3163
  export() {
2011
3164
  const result = {};
2012
3165
  for (const name of Object.keys(this.#tables)) {
2013
- const columns = this.#tables[name];
3166
+ const columns = this.#columns(name);
2014
3167
  result[name] = {
2015
- key: this.#key(name),
3168
+ primary: this.#key(name),
2016
3169
  columns,
2017
3170
  schema: compileSchema(objectShape(columns))
2018
3171
  };
2019
3172
  }
2020
3173
  return result;
2021
3174
  }
2022
- async open() {
2023
- await this.#connect();
3175
+ open() {
3176
+ return this.#context.open();
2024
3177
  }
2025
- async close() {
2026
- this.#status = "closed";
2027
- this.#ready = void 0;
2028
- await this.#driver.close();
2029
- this.#emitter.emit("close");
3178
+ close() {
3179
+ return this.#context.close();
2030
3180
  }
2031
- /**
2032
- * Run `scope` transactionally: commit its writes on success, roll every table
2033
- * back if it throws.
2034
- *
2035
- * @remarks
2036
- * When the driver implements the optional native {@link DriverInterface.transaction}
2037
- * hook, that native `commit` / `rollback` handle drives the transaction; otherwise
2038
- * the universal snapshot floor (`driver.snapshot()`) runs unchanged. Either path
2039
- * emits the same `transaction` / `commit` / `rollback` lifecycle (AGENTS §13).
2040
- * `options.signal` is checked ONCE at entry, before connecting or starting any
2041
- * transactional work — an already-aborted signal throws `ABORTED` and neither the
2042
- * native hook nor the snapshot floor is invoked. Nesting is unguarded and
2043
- * unsupported exactly as before: this is a single-writer model, not reentrant.
2044
- * On the native path, a `scope` throw rolls back via the native handle; a
2045
- * native `commit` failure propagates as-is with no rollback attempt — the
2046
- * engine owns transaction state after a failed COMMIT.
2047
- *
2048
- * @param scope - The transactional work to run
2049
- * @param options - `{ signal }` to abort before the transaction starts
2050
- * @returns The scope's resolved value
2051
- * @throws An `ABORTED` {@link DatabaseError} when `options.signal` has already fired
2052
- */
2053
- async transaction(scope, options) {
2054
- checkAbort(options?.signal);
2055
- await this.#connect();
2056
- const native = await this.#driver.transaction?.();
2057
- if (native !== void 0) {
2058
- this.#emitter.emit("transaction");
2059
- let value;
2060
- try {
2061
- value = await scope();
2062
- } catch (error) {
2063
- await native.rollback();
2064
- this.#emitter.emit("rollback", error);
2065
- throw error;
2066
- }
2067
- await native.commit();
2068
- this.#emitter.emit("commit");
2069
- return value;
2070
- }
2071
- const rollback = await this.#driver.snapshot();
2072
- this.#emitter.emit("transaction");
2073
- try {
2074
- const value = await scope();
2075
- this.#emitter.emit("commit");
2076
- return value;
2077
- } catch (error) {
2078
- await rollback();
2079
- this.#emitter.emit("rollback", error);
2080
- throw error;
2081
- }
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);
2082
3186
  }
2083
- /**
2084
- * Diff `deployed` against this database's declared schema and apply the
2085
- * resulting plan through the driver's optional `migrate` hook.
2086
- *
2087
- * @param deployed - The schema currently deployed, as {@link TableSchema}s
2088
- * @param options - `{ signal }` to abort before the migration starts
2089
- * @returns The applied {@link Migration} plan
2090
- * @throws A `MIGRATION` {@link DatabaseError} when the driver does not
2091
- * implement `migrate`, or when a step references an unknown table
2092
- * (propagated from the driver)
2093
- * @throws An `ABORTED` {@link DatabaseError} when `options.signal` has
2094
- * already fired at entry
2095
- */
2096
- async migrate(deployed, options) {
2097
- checkAbort(options?.signal);
2098
- await this.#connect();
2099
- const plan = planMigration(deployed, this.#schema());
2100
- if (this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, { name: this.#name });
2101
- await this.#apply(plan);
2102
- return plan;
3187
+ migrate(deployed, options) {
3188
+ return this.#context.migrate(deployed, options);
2103
3189
  }
2104
3190
  #build(name, key, contract) {
2105
- 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);
2106
3192
  }
2107
- #spawn(tables, keys) {
2108
- return new Database({
2109
- driver: this.#driver,
3193
+ #spawn(tables, primary) {
3194
+ return Database.#attach({
3195
+ driver: this.#context.driver,
2110
3196
  tables,
2111
- keys,
2112
- name: this.#name,
2113
- ...this.#generate === void 0 ? {} : { key: this.#generate }
2114
- });
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);
2115
3203
  }
2116
3204
  #key(name) {
2117
- return this.#keys[name] ?? "id";
3205
+ return this.#primary[name] ?? "id";
3206
+ }
3207
+ #columns(name) {
3208
+ const columns = this.#tables[name];
3209
+ if (columns === void 0) throw new DatabaseError("NOT_FOUND", `Table '${name}' is not declared`, { table: name });
3210
+ return columns;
2118
3211
  }
2119
3212
  #schema() {
2120
3213
  return Object.keys(this.#tables).map((name) => {
2121
- const columns = this.#tables[name];
3214
+ const columns = this.#columns(name);
2122
3215
  return {
2123
3216
  name,
2124
3217
  primary: this.#key(name),
2125
- columns: Object.keys(columns).map((column) => {
2126
- const shape = columns[column];
2127
- return {
2128
- name: column,
2129
- type: shapeToColumnType(shape),
2130
- nullable: shape.type === "optional" || shape.type === "nullable"
2131
- };
2132
- }),
3218
+ columns: Object.entries(columns).map(([column, shape]) => shapeToColumnSchema(column, shape)),
2133
3219
  indexes: this.#indexes[name] ?? []
2134
3220
  };
2135
3221
  });
2136
3222
  }
2137
- #connect() {
2138
- if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
2139
- if (this.#ready === void 0) this.#ready = this.#driver.open(this.#schema()).then(async () => {
2140
- if (this.#status === "idle") this.#status = "open";
2141
- this.#emitter.emit("open");
2142
- await this.#reconcile();
2143
- });
2144
- return this.#ready;
2145
- }
2146
- async #reconcile() {
2147
- if (this.#version === void 0 || this.#driver.meta === void 0) return;
2148
- const declared = this.#schema();
2149
- const meta = await this.#driver.meta();
2150
- if (meta === void 0) {
2151
- await this.#stamp();
2152
- return;
2153
- }
2154
- if (meta.version > this.#version) throw new DatabaseError("MIGRATION", `Database '${this.#name}' store version ${meta.version} is newer than declared version ${this.#version}`, {
2155
- name: this.#name,
2156
- stored: meta.version,
2157
- declared: this.#version
2158
- });
2159
- if (meta.version < this.#version) {
2160
- const plan = planMigration(meta.schema, declared, meta.version, this.#version);
2161
- if (plan.steps.length > 0 && this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, {
2162
- name: this.#name,
2163
- stored: meta.version,
2164
- declared: this.#version
2165
- });
2166
- await this.#apply(plan);
2167
- }
2168
- }
2169
- async #apply(plan) {
2170
- const native = await this.#driver.transaction?.();
2171
- if (native !== void 0) {
2172
- try {
2173
- await this.#driver.migrate?.(plan);
2174
- await this.#stamp();
2175
- } catch (error) {
2176
- await native.rollback();
2177
- throw error;
2178
- }
2179
- await native.commit();
2180
- this.#emitter.emit("migrate", plan);
2181
- return;
2182
- }
2183
- await this.#driver.migrate?.(plan);
2184
- await this.#stamp();
2185
- this.#emitter.emit("migrate", plan);
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;
2186
3242
  }
2187
- async #stamp() {
2188
- if (this.#version === void 0 || this.#driver.stamp === void 0) return;
2189
- const meta = {
2190
- version: this.#version,
2191
- schema: this.#schema()
2192
- };
2193
- await this.#driver.stamp(meta);
3243
+ static #attach(options, context) {
3244
+ const database = new Database(options);
3245
+ database.#context = context;
3246
+ context.register(database.#schema());
3247
+ return database;
2194
3248
  }
2195
3249
  };
2196
3250
  //#endregion
@@ -2206,29 +3260,54 @@ var Database = class Database {
2206
3260
  * snapshot capture and restore — so a caller mutating a nested field of an input
2207
3261
  * row, a returned row, or a row mutated in place between snapshot and rollback
2208
3262
  * can never perturb stored state (AGENTS §11); a shallow `{ ...row }` spread
2209
- * 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`
2210
3266
  * clones every table to give transactions an exact rollback point. `scan` and
2211
3267
  * `keys` yield in key order — sorted by the core {@link compareValues} total
2212
3268
  * order, the same contract the SQLite (`ORDER BY`) and IndexedDB (key-ordered
2213
3269
  * reads) backends honor, so an unordered read agrees across every backend rather
2214
3270
  * than leaking Map insertion order. A persistent backend (IndexedDB, SQLite)
2215
- * implements the same nine methods over real storage.
3271
+ * implements the same required methods over real storage.
2216
3272
  */
2217
3273
  var MemoryDriver = class {
2218
3274
  #tables = /* @__PURE__ */ new Map();
2219
- #meta;
3275
+ #identities = /* @__PURE__ */ new Map();
3276
+ #schema = [];
3277
+ #metadata;
2220
3278
  async open(schema) {
2221
- 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;
2222
3288
  }
2223
3289
  async close() {}
2224
3290
  async read(table, key) {
2225
3291
  const row = this.#store(table).get(key);
2226
3292
  return row === void 0 ? void 0 : structuredClone(row);
2227
3293
  }
2228
- async write(table, key, row) {
2229
- 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)));
2230
3308
  }
2231
- async delete(table, key) {
3309
+ async delete(table, key, options) {
3310
+ checkAbort(options?.signal);
2232
3311
  return this.#store(table).delete(key);
2233
3312
  }
2234
3313
  async keys(table) {
@@ -2247,17 +3326,17 @@ var MemoryDriver = class {
2247
3326
  * @remarks
2248
3327
  * Iterates the table's keys in the same key order `scan` and `keys` yield
2249
3328
  * (sorted by {@link compareValues}), testing each row against
2250
- * `criteria.conditions` (via {@link matchesCriteria}) before counting it
3329
+ * `input.conditions` (via {@link matchesQuery}) before counting it
2251
3330
  * toward `offset` / `limit`. Both are applied lazily as matches are found —
2252
3331
  * `offset` matches are skipped without being yielded, and iteration stops the
2253
3332
  * instant `limit` yields have been produced, so a large table is never fully
2254
- * 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
2255
3334
  * `TableInterface.scan` and `QueryInterface.stream`): streaming yields key
2256
3335
  * order, sorted output is `records()`'s job. Rows yield copy-out (AGENTS
2257
3336
  * §11), and an unknown table mirrors `scan`'s empty-yield behavior.
2258
3337
  *
2259
3338
  * @param table - The table to stream
2260
- * @param criteria - The filter / offset / limit to apply lazily
3339
+ * @param input - The filter / offset / limit to apply lazily
2261
3340
  *
2262
3341
  * @example
2263
3342
  * ```ts
@@ -2266,18 +3345,22 @@ var MemoryDriver = class {
2266
3345
  * }
2267
3346
  * ```
2268
3347
  */
2269
- async *stream(table, criteria) {
3348
+ stream(table, input) {
3349
+ validatePage(input);
3350
+ return this.#stream(table, input);
3351
+ }
3352
+ async *#stream(table, input) {
2270
3353
  const store = this.#store(table);
2271
- const conditions = criteria.conditions;
2272
- const offset = criteria.offset ?? 0;
2273
- const limit = criteria.limit;
3354
+ const conditions = input.conditions;
3355
+ const offset = input.offset ?? 0;
3356
+ const limit = input.limit;
2274
3357
  let skipped = 0;
2275
3358
  let yielded = 0;
2276
3359
  for (const key of this.#ordered(table)) {
2277
3360
  if (limit !== void 0 && yielded >= limit) return;
2278
3361
  const row = store.get(key);
2279
3362
  if (row === void 0) continue;
2280
- if (conditions !== void 0 && conditions.length > 0 && !matchesCriteria(row, conditions)) continue;
3363
+ if (conditions !== void 0 && conditions.length > 0 && !matchesQuery(row, conditions)) continue;
2281
3364
  if (skipped < offset) {
2282
3365
  skipped += 1;
2283
3366
  continue;
@@ -2293,115 +3376,169 @@ var MemoryDriver = class {
2293
3376
  * Capture the current state and return a thunk that rolls back to it.
2294
3377
  *
2295
3378
  * @remarks
2296
- * `tables` omitted clones and restores the WHOLE store, byte-identical to the
2297
- * prior whole-store behavior. `tables` provided clones ONLY the named tables,
2298
- * and the returned thunk restores ONLY those every other table keeps
2299
- * 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.
2300
3384
  *
2301
3385
  * @param tables - The table names to scope the snapshot to; omitted captures every table
2302
3386
  * @returns A thunk that restores the captured tables
2303
3387
  */
2304
3388
  async snapshot(tables) {
2305
- if (tables === void 0) {
2306
- const copy = /* @__PURE__ */ new Map();
2307
- for (const [name, store] of this.#tables) {
2308
- const cloned = /* @__PURE__ */ new Map();
2309
- for (const [key, row] of store) cloned.set(key, structuredClone(row));
2310
- copy.set(name, cloned);
2311
- }
2312
- return async () => {
2313
- this.#tables.clear();
2314
- for (const [name, store] of copy) {
2315
- const restored = /* @__PURE__ */ new Map();
2316
- for (const [key, row] of store) restored.set(key, structuredClone(row));
2317
- this.#tables.set(name, restored);
2318
- }
2319
- };
2320
- }
2321
- const copy = /* @__PURE__ */ new Map();
2322
- 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);
2323
3393
  const store = this.#tables.get(name);
2324
- if (store === void 0) continue;
2325
- const cloned = /* @__PURE__ */ new Map();
2326
- for (const [key, row] of store) cloned.set(key, structuredClone(row));
2327
- 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
+ });
2328
3403
  }
2329
3404
  return async () => {
2330
- for (const [name, store] of copy) {
2331
- const restored = /* @__PURE__ */ new Map();
2332
- for (const [key, row] of store) restored.set(key, structuredClone(row));
2333
- 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);
2334
3429
  }
2335
3430
  };
2336
3431
  }
2337
3432
  /**
2338
- * Return the persisted {@link DriverMeta}, or `undefined` when the store has
3433
+ * Return the persisted {@link DriverMetadata}, or `undefined` when the store has
2339
3434
  * never been stamped.
2340
3435
  *
2341
3436
  * @remarks
2342
3437
  * In-process only — the metadata lives in this instance's memory, exactly
2343
- * like the rest of this driver's storage. A driver-conformance-valid
2344
- * 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.
2345
3441
  *
2346
- * @returns The last-stamped {@link DriverMeta}, or `undefined`
3442
+ * @returns The last-stamped {@link DriverMetadata}, or `undefined`
2347
3443
  */
2348
- async meta() {
2349
- return this.#meta;
3444
+ async metadata() {
3445
+ return this.#metadata === void 0 ? void 0 : cloneDriverMetadata(this.#metadata);
2350
3446
  }
2351
3447
  /**
2352
- * Persist `meta` verbatim for a later `meta()` to return.
3448
+ * Persist an owned snapshot for a later `metadata()` to return.
2353
3449
  *
2354
- * @param meta - The {@link DriverMeta} to persist
3450
+ * @param metadata - The {@link DriverMetadata} to persist
2355
3451
  */
2356
- async stamp(meta) {
2357
- this.#meta = meta;
3452
+ async stamp(metadata) {
3453
+ this.#metadata = cloneDriverMetadata(metadata);
2358
3454
  }
2359
3455
  /**
2360
3456
  * Apply a {@link Migration} plan's steps against the in-memory store.
2361
3457
  *
2362
3458
  * @remarks
2363
- * A multi-step plan applies its steps sequentially and is NOT atomic — a
2364
- * 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.
2365
3461
  *
2366
- * @param plan - The migration plan to apply
3462
+ * @param input - The migration plan and optional metadata to settle atomically
2367
3463
  */
2368
- async migrate(plan) {
2369
- 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) {
2370
3512
  case "table.add":
2371
- 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());
2372
3514
  break;
2373
3515
  case "table.remove":
2374
- this.#require(step.table);
2375
- this.#tables.delete(step.table);
3516
+ this.#require(tables, step.table);
3517
+ tables.delete(step.table);
2376
3518
  break;
2377
3519
  case "column.add":
2378
3520
  case "column.remove": {
2379
- const store = this.#require(step.table);
3521
+ const store = this.#require(tables, step.table);
2380
3522
  const rows = [...store.entries()];
2381
3523
  const migrated = migrateRows(rows.map(([, row]) => row), [step]);
2382
- rows.forEach(([key], index) => store.set(key, migrated[index]));
3524
+ for (const [index, [key]] of rows.entries()) {
3525
+ const row = migrated[index];
3526
+ if (row === void 0) throw new DatabaseError("MIGRATION", "migrate: transformed row is missing", {
3527
+ table: step.table,
3528
+ index
3529
+ });
3530
+ store.set(key, row);
3531
+ }
2383
3532
  break;
2384
3533
  }
2385
3534
  case "index.add":
2386
- case "index.remove":
2387
- this.#require(step.table);
2388
- break;
3535
+ case "index.remove": this.#require(tables, step.table);
2389
3536
  }
2390
3537
  }
2391
- #ordered(table) {
2392
- return [...this.#store(table).keys()].sort(compareValues);
2393
- }
2394
- #require(table) {
2395
- const store = this.#tables.get(table);
2396
- if (store === void 0) throw new DatabaseError("MIGRATION", `migrate: unknown table '${table}'`, { table });
2397
- return store;
2398
- }
2399
3538
  #store(table) {
2400
- let store = this.#tables.get(table);
2401
- if (store === void 0) {
2402
- store = /* @__PURE__ */ new Map();
2403
- this.#tables.set(table, store);
2404
- }
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 });
2405
3542
  return store;
2406
3543
  }
2407
3544
  };
@@ -2416,10 +3553,10 @@ var MemoryDriver = class {
2416
3553
  * level. The `const` type parameter captures the literal names and columns, so
2417
3554
  * `db.table('users')` is checked against the schema and typed by `Infer` of its
2418
3555
  * columns — no annotations. Name a non-`id` primary-key column per table via the
2419
- * optional `keys` map.
3556
+ * optional `primary` and `indexes` maps.
2420
3557
  *
2421
- * @param options - The driver, the `tables` column map, optional `keys`, and an
2422
- * optional `name`
3558
+ * @param options - The driver, `tables`, and optional `primary`, `indexes`,
3559
+ * `name`, `generator`, `version`, and emitter hooks
2423
3560
  * @returns A typed {@link DatabaseInterface}
2424
3561
  *
2425
3562
  * @example
@@ -2433,7 +3570,7 @@ var MemoryDriver = class {
2433
3570
  * users: { id: stringShape(), age: integerShape() },
2434
3571
  * posts: { slug: stringShape(), title: stringShape() },
2435
3572
  * },
2436
- * keys: { posts: 'slug' },
3573
+ * primary: { posts: 'slug' },
2437
3574
  * })
2438
3575
  * await db.table('users').set({ id: 'u1', age: 36 }) // typed; coerced + validated
2439
3576
  * ```
@@ -2454,6 +3591,6 @@ function createMemoryDriver() {
2454
3591
  return new MemoryDriver();
2455
3592
  }
2456
3593
  //#endregion
2457
- 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 };
2458
3595
 
2459
3596
  //# sourceMappingURL=index.js.map