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