@orkestrel/database 0.0.1 → 0.0.2

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.
@@ -0,0 +1,2453 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _orkestrel_contract = require("@orkestrel/contract");
3
+ let _orkestrel_emitter = require("@orkestrel/emitter");
4
+ //#region src/core/constants.ts
5
+ /**
6
+ * The primary-key column assumed when {@link TableKeys} does not name one.
7
+ *
8
+ * @remarks
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`.
11
+ */
12
+ var DEFAULT_PRIMARY = "id";
13
+ /**
14
+ * The longest `LIKE` / `GLOB` pattern the wildcard matcher accepts before rejecting it.
15
+ *
16
+ * @remarks
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
19
+ * patterns. The matcher is the LINEAR greedy two-pointer wildcard match — never a
20
+ * backtracking regex (`.*`-segments-separated-by-literals against a long input is the
21
+ * catastrophic shape JS cannot bound without atomic groups), so it is O(value ×
22
+ * pattern). Capping the pattern length bounds that pattern factor, leaving a match
23
+ * linear in the value length whatever the pattern. A longer pattern throws a
24
+ * `VALIDATION` {@link DatabaseError}; the cap is generous for any legitimate search.
25
+ */
26
+ var MAX_PATTERN_LENGTH = 1024;
27
+ //#endregion
28
+ //#region src/core/errors.ts
29
+ /**
30
+ * An error thrown by the database layer.
31
+ *
32
+ * @remarks
33
+ * Carries a {@link DatabaseErrorCode} and an optional `context` bag naming the
34
+ * offending table / key. Thrown for: operating on a closed database (`CLOSED`), a
35
+ * `resolve` miss (`NOT_FOUND`), an `add` onto an existing key (`CONFLICT`), a
36
+ * row that fails its table's contract (`VALIDATION`), a cancelled operation whose
37
+ * {@link ReadOptions.signal} aborted (`ABORTED`, carrying `signal.reason` in
38
+ * `context`), an inapplicable {@link Migration} plan (`MIGRATION`), a
39
+ * driver that violates a {@link DriverInterface} invariant, thrown by the
40
+ * `conformDriver` helper (`CONFORMANCE`), and an unexpected infrastructure
41
+ * fault surfaced by a driver seam — e.g. a filesystem failure while
42
+ * persisting (`DRIVER`) — as opposed to expected domain conditions, which
43
+ * keep their specific codes.
44
+ */
45
+ var DatabaseError = class extends Error {
46
+ code;
47
+ context;
48
+ constructor(code, message, context) {
49
+ super(message);
50
+ this.name = "DatabaseError";
51
+ this.code = code;
52
+ this.context = context;
53
+ }
54
+ };
55
+ /**
56
+ * Narrow an unknown caught value to a {@link DatabaseError}.
57
+ *
58
+ * @param value - The value to test (typically a `catch` binding)
59
+ * @returns `true` when `value` is a {@link DatabaseError}
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * try {
64
+ * await users.add(row)
65
+ * } catch (error) {
66
+ * if (isDatabaseError(error) && error.code === 'CONFLICT') await users.set(row)
67
+ * }
68
+ * ```
69
+ */
70
+ function isDatabaseError(value) {
71
+ return value instanceof DatabaseError;
72
+ }
73
+ //#endregion
74
+ //#region src/core/helpers.ts
75
+ /**
76
+ * A total ordering over arbitrary values — the comparator behind sorting and the
77
+ * range operators.
78
+ *
79
+ * @remarks
80
+ * Values of different types order by a fixed type rank (`undefined` < `null` <
81
+ * boolean < number < string < other); same-typed values compare naturally.
82
+ * `NaN` sorts after every other number and equal to itself, so the comparator
83
+ * is total and never returns `NaN`.
84
+ *
85
+ * @param left - The left value
86
+ * @param right - The right value
87
+ * @returns `-1`, `0`, or `1`
88
+ */
89
+ function compareValues(left, right) {
90
+ const rankOf = (value) => {
91
+ if (value === void 0) return 0;
92
+ if (value === null) return 1;
93
+ if (typeof value === "boolean") return 2;
94
+ if (typeof value === "number") return 3;
95
+ if (typeof value === "string") return 4;
96
+ return 5;
97
+ };
98
+ const leftRank = rankOf(left);
99
+ const rightRank = rankOf(right);
100
+ if (leftRank !== rightRank) return leftRank < rightRank ? -1 : 1;
101
+ if (typeof left === "number" && typeof right === "number") {
102
+ if (Number.isNaN(left) || Number.isNaN(right)) return Number.isNaN(left) ? Number.isNaN(right) ? 0 : 1 : -1;
103
+ return left < right ? -1 : left > right ? 1 : 0;
104
+ }
105
+ if (typeof left === "string" && typeof right === "string") return left < right ? -1 : left > right ? 1 : 0;
106
+ if (typeof left === "boolean" && typeof right === "boolean") return left === right ? 0 : left ? 1 : -1;
107
+ return 0;
108
+ }
109
+ /**
110
+ * Structural equality by SameValueZero leaves — the comparator behind conformance
111
+ * checks and any test/fixture that needs "same data", not "same reference".
112
+ *
113
+ * @remarks
114
+ * Primitives compare by SameValueZero (`NaN` equals itself; `+0` equals `-0`).
115
+ * Arrays compare by index (same length, every element `deepEqual`). Plain
116
+ * records (via `isRecord`) compare by their OWN enumerable keys: same key
117
+ * COUNT and, for every key in `left`, `right` has that key (`Object.hasOwn`)
118
+ * with a `deepEqual` value — so a key present with value `undefined` is NOT
119
+ * equal to that key being absent (both differ in `Object.keys` membership).
120
+ * Anything else (functions, class instances, mismatched shapes) falls through
121
+ * to `false`. There is no cycle detection — a cyclic input recurses forever;
122
+ * callers pass acyclic data (rows, plans, config).
123
+ *
124
+ * @param left - The left value
125
+ * @param right - The right value
126
+ * @returns Whether `left` and `right` are structurally equal
127
+ *
128
+ * @example
129
+ * ```ts
130
+ * deepEqual(Number.NaN, Number.NaN) // true
131
+ * deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }) // true
132
+ * deepEqual({ a: undefined }, {}) // false — present-undefined ≠ absent
133
+ * ```
134
+ */
135
+ function deepEqual(left, right) {
136
+ if (typeof left === "number" && typeof right === "number") return Number.isNaN(left) && Number.isNaN(right) || left === right;
137
+ if (left === right) return true;
138
+ if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((item, index) => deepEqual(item, right[index]));
139
+ if ((0, _orkestrel_contract.isRecord)(left) && (0, _orkestrel_contract.isRecord)(right)) {
140
+ const leftKeys = Object.keys(left);
141
+ const rightKeys = Object.keys(right);
142
+ if (leftKeys.length !== rightKeys.length) return false;
143
+ return leftKeys.every((key) => Object.hasOwn(right, key) && deepEqual(left[key], right[key]));
144
+ }
145
+ return false;
146
+ }
147
+ /**
148
+ * Match a value against a wildcard pattern in LINEAR time — the shared, ReDoS-SAFE
149
+ * engine behind {@link likeMatch} and {@link globMatch}.
150
+ *
151
+ * @remarks
152
+ * A backtracking RegExp (`a%b%c` → `^a.*b.*c$`) is CATASTROPHIC on a hostile pattern:
153
+ * `.*` segments separated by literals, matched against a long non-matching input, blow
154
+ * up super-linearly — and JS has no atomic groups / possessive quantifiers to bound it
155
+ * (AGENTS §6.5, now that the authed server runs model-supplied `list` criteria over the
156
+ * wire). So this builds NO regex. It runs the classic GREEDY TWO-POINTER wildcard match:
157
+ * the `any` wildcard records its position and, on a later mismatch, backtracks ONLY to
158
+ * that last `any` (letting it absorb one more char) — so the work is O(value × pattern),
159
+ * never the exponential / polynomial backtracking a regex would do. The pattern length
160
+ * is capped at {@link MAX_PATTERN_LENGTH} (a `VALIDATION` {@link DatabaseError} over it),
161
+ * bounding the pattern factor so a match stays linear in the value length whatever the
162
+ * pattern.
163
+ *
164
+ * The `any` wildcard matches any run (including empty); `single` matches exactly one
165
+ * char; every other pattern char matches itself LITERALLY (a pattern `.` / `(` / `\` is
166
+ * a literal — the regex-metacharacter hazard is gone with the regex). `any` is tested
167
+ * BEFORE a literal match, so a value that literally contains the wildcard char never
168
+ * shadows the wildcard. Case folding is applied to BOTH sides when `fold` is set.
169
+ *
170
+ * @param value - The value to test
171
+ * @param pattern - The wildcard pattern
172
+ * @param any - The any-run wildcard char (`%` for `LIKE`, `*` for `GLOB`)
173
+ * @param single - The single-char wildcard char (`_` for `LIKE`, `?` for `GLOB`)
174
+ * @param fold - Whether to match case-INSENSITIVELY (`LIKE` folds; `GLOB` does not)
175
+ * @returns Whether `value` matches `pattern`
176
+ * @throws A `VALIDATION` {@link DatabaseError} when `pattern` exceeds {@link MAX_PATTERN_LENGTH}
177
+ */
178
+ function wildcardMatch(value, pattern, any, single, fold) {
179
+ if (pattern.length > 1024) throw new DatabaseError("VALIDATION", `Pattern exceeds the maximum length of ${MAX_PATTERN_LENGTH}`, {
180
+ length: pattern.length,
181
+ limit: MAX_PATTERN_LENGTH
182
+ });
183
+ const haystack = fold ? value.toLowerCase() : value;
184
+ const needle = fold ? pattern.toLowerCase() : pattern;
185
+ let vi = 0;
186
+ let pi = 0;
187
+ let star = -1;
188
+ let mark = 0;
189
+ while (vi < haystack.length) {
190
+ const pc = pi < needle.length ? needle[pi] : void 0;
191
+ if (pc === any) {
192
+ star = pi;
193
+ mark = vi;
194
+ pi += 1;
195
+ } else if (pc !== void 0 && (pc === single || pc === haystack[vi])) {
196
+ vi += 1;
197
+ pi += 1;
198
+ } else if (star !== -1) {
199
+ pi = star + 1;
200
+ mark += 1;
201
+ vi = mark;
202
+ } else return false;
203
+ }
204
+ while (pi < needle.length && needle[pi] === any) pi += 1;
205
+ return pi === needle.length;
206
+ }
207
+ function likeMatch(value, pattern) {
208
+ return wildcardMatch(value, pattern, "%", "_", true);
209
+ }
210
+ function globMatch(value, pattern) {
211
+ return wildcardMatch(value, pattern, "*", "?", false);
212
+ }
213
+ /**
214
+ * Evaluate one {@link Condition} against a row — the per-operator predicate.
215
+ *
216
+ * @remarks
217
+ * Reads the condition's column — a `FieldPath`, resolved with `resolveField` (a
218
+ * string is one column; an array descends a nested value) — and applies the
219
+ * operator. Range operators (`above` / `below` / `from` / `to` / `between`) use
220
+ * {@link compareValues}, the total order; the equality family (`equals` / `not`
221
+ * / `any` / `none`) uses {@link deepEqual} — STRUCTURAL equality, not the total
222
+ * order's rank-5-collapses-all-objects behavior, so `equals` on an object/array
223
+ * operand only matches a structurally-equal value, never every row holding any
224
+ * object. This is a semantics change from ranking: `deepEqual` is SameValueZero
225
+ * on leaves, so `NaN` now equals `NaN` under `equals` / `any` (it never matched
226
+ * anything under the old rank-based comparison). `like` / `glob` / `starts` /
227
+ * `ends` match only strings; `absent` / `present` test nullishness. Total — a
228
+ * type mismatch is simply a non-match.
229
+ *
230
+ * @param row - The row to test
231
+ * @param condition - The condition to apply
232
+ * @returns Whether the row satisfies the condition
233
+ */
234
+ function matchesCondition(row, condition) {
235
+ const value = (0, _orkestrel_contract.resolveField)(row, condition.column);
236
+ const first = condition.values[0];
237
+ const second = condition.values[1];
238
+ switch (condition.operator) {
239
+ case "equals": return deepEqual(value, first);
240
+ case "not": return !deepEqual(value, first);
241
+ case "above": return compareValues(value, first) > 0;
242
+ case "below": return compareValues(value, first) < 0;
243
+ case "from": return compareValues(value, first) >= 0;
244
+ case "to": return compareValues(value, first) <= 0;
245
+ case "between": return compareValues(value, first) >= 0 && compareValues(value, second) <= 0;
246
+ case "like": return (0, _orkestrel_contract.isString)(value) && (0, _orkestrel_contract.isString)(first) && likeMatch(value, first);
247
+ case "glob": return (0, _orkestrel_contract.isString)(value) && (0, _orkestrel_contract.isString)(first) && globMatch(value, first);
248
+ case "starts": return (0, _orkestrel_contract.isString)(value) && (0, _orkestrel_contract.isString)(first) && value.startsWith(first);
249
+ case "ends": return (0, _orkestrel_contract.isString)(value) && (0, _orkestrel_contract.isString)(first) && value.endsWith(first);
250
+ case "any": return condition.values.some((candidate) => deepEqual(value, candidate));
251
+ case "none": return !condition.values.some((candidate) => deepEqual(value, candidate));
252
+ case "absent": return value === void 0 || value === null;
253
+ case "present": return value !== void 0 && value !== null;
254
+ }
255
+ }
256
+ /**
257
+ * Fold a row through a list of conditions, joining each by its connector.
258
+ *
259
+ * @remarks
260
+ * Evaluated left-to-right: the first condition seeds the result, and each later
261
+ * condition combines with `&&` (`and`) or `||` (`or`). An empty list matches
262
+ * every row. There is no operator precedence — conditions combine in the order
263
+ * the query builder recorded them.
264
+ *
265
+ * @param row - The row to test
266
+ * @param conditions - The conditions to fold
267
+ * @returns Whether the row satisfies the combined conditions
268
+ */
269
+ function matchesCriteria(row, conditions) {
270
+ let result = true;
271
+ let seeded = false;
272
+ for (const condition of conditions) {
273
+ const match = matchesCondition(row, condition);
274
+ if (!seeded) {
275
+ result = match;
276
+ seeded = true;
277
+ } else result = condition.connector === "or" ? result || match : result && match;
278
+ }
279
+ return result;
280
+ }
281
+ /**
282
+ * Filter rows by a list of conditions — the shared basis for a table's count
283
+ * and aggregate paths (no sort/page, unlike {@link applyCriteria}).
284
+ *
285
+ * @remarks
286
+ * An empty condition list matches every row (returned as-is, no copy). Folds
287
+ * each row through {@link matchesCriteria}.
288
+ *
289
+ * @param rows - The rows to filter
290
+ * @param conditions - The conditions to apply (empty matches everything)
291
+ * @returns The matching rows
292
+ *
293
+ * @example
294
+ * ```ts
295
+ * filterRows(
296
+ * [{ age: 30 }, { age: 12 }],
297
+ * [{ column: 'age', operator: 'above', values: [18], connector: 'and' }],
298
+ * ) // => [{ age: 30 }]
299
+ * ```
300
+ */
301
+ function filterRows(rows, conditions) {
302
+ if (conditions.length === 0) return rows;
303
+ return rows.filter((row) => matchesCriteria(row, conditions));
304
+ }
305
+ /**
306
+ * Sort rows by an ordering specification, leaving the input untouched.
307
+ *
308
+ * @remarks
309
+ * Applies the terms in priority order — the first term that distinguishes two
310
+ * rows decides — using {@link compareValues}, reversing for `descending`.
311
+ *
312
+ * @param rows - The rows to sort
313
+ * @param order - The ordering terms in priority order
314
+ * @returns A new, sorted array
315
+ */
316
+ function sortRows(rows, order) {
317
+ const sorted = [...rows];
318
+ sorted.sort((left, right) => {
319
+ for (const term of order) {
320
+ const comparison = compareValues((0, _orkestrel_contract.resolveField)(left, term.column), (0, _orkestrel_contract.resolveField)(right, term.column));
321
+ if (comparison !== 0) return term.direction === "descending" ? -comparison : comparison;
322
+ }
323
+ return 0;
324
+ });
325
+ return sorted;
326
+ }
327
+ /**
328
+ * Apply a {@link Criteria} to rows — filter, then sort, then page.
329
+ *
330
+ * @remarks
331
+ * The whole portable read pipeline in one place: conditions filter, `order`
332
+ * sorts, and `offset` / `limit` window the result. Each step is skipped when its
333
+ * part of the criteria is absent. The reference {@link DriverInterface} backends
334
+ * lean on this rather than each re-deriving it.
335
+ *
336
+ * @param rows - The rows to process (typically a table's full `scan`)
337
+ * @param criteria - The read specification, or `undefined` for all rows as-is
338
+ * @returns The filtered, sorted, paged rows
339
+ */
340
+ function applyCriteria(rows, criteria) {
341
+ let result = rows;
342
+ const conditions = criteria?.conditions;
343
+ if (conditions !== void 0 && conditions.length > 0) result = result.filter((row) => matchesCriteria(row, conditions));
344
+ const order = criteria?.order;
345
+ if (order !== void 0 && order.length > 0) result = sortRows(result, order);
346
+ const offset = criteria?.offset ?? 0;
347
+ const limit = criteria?.limit;
348
+ if (offset > 0 || limit !== void 0) result = result.slice(offset, limit !== void 0 ? offset + limit : void 0);
349
+ return result;
350
+ }
351
+ /**
352
+ * Compute an aggregate over a column across rows.
353
+ *
354
+ * @remarks
355
+ * `count` returns the row count. The numeric aggregates coerce each cell with
356
+ * the contracts `parseNumber` (so `'42'` counts) and ignore non-numeric cells;
357
+ * over zero numeric values they return `undefined` — the SQL `NULL` of an empty
358
+ * aggregate.
359
+ *
360
+ * @param rows - The rows to aggregate (non-record entries are ignored)
361
+ * @param operation - The aggregate to compute
362
+ * @param column - The column to aggregate
363
+ * @returns The aggregate value, or `undefined` when undefined for the inputs
364
+ */
365
+ function computeAggregate(rows, operation, column) {
366
+ if (operation === "count") return rows.length;
367
+ const numbers = [];
368
+ for (const row of rows) {
369
+ if (!(0, _orkestrel_contract.isRecord)(row)) continue;
370
+ const value = (0, _orkestrel_contract.parseNumber)((0, _orkestrel_contract.resolveField)(row, column));
371
+ if (value !== void 0) numbers.push(value);
372
+ }
373
+ if (numbers.length === 0) return void 0;
374
+ if (operation === "sum" || operation === "average") {
375
+ const total = numbers.reduce((sum, value) => sum + value, 0);
376
+ return operation === "average" ? total / numbers.length : total;
377
+ }
378
+ return operation === "minimum" ? Math.min(...numbers) : Math.max(...numbers);
379
+ }
380
+ /**
381
+ * Read a row's primary key from a column, when it is a usable {@link Key}.
382
+ *
383
+ * @param row - The row to read
384
+ * @param column - The primary-key column name
385
+ * @returns The key (a string or finite number), or `undefined`
386
+ */
387
+ function extractKey(row, column) {
388
+ const value = row[column];
389
+ if ((0, _orkestrel_contract.isString)(value)) return value;
390
+ if ((0, _orkestrel_contract.isFiniteNumber)(value)) return value;
391
+ }
392
+ /**
393
+ * Map a column's {@link ContractShape} to its portable {@link ColumnType} — the
394
+ * value a `TableSchema` carries so a native backend can declare a real column.
395
+ *
396
+ * @remarks
397
+ * `string` → `text`; `number` → `integer` when the shape is integer-only, else
398
+ * `real`; `boolean` → `boolean`. A `literal` takes the type of its values
399
+ * (all-boolean → `boolean`, all-integer → `integer`, mixed/fractional numbers →
400
+ * `real`, anything else → `text`). `optional` / `nullable` unwrap to their inner
401
+ * type (nullability is tracked separately). `null` / `object` / `array` / `union` /
402
+ * `json` / `raw` → `json`: a backend stores them as JSON text and can `json_extract`
403
+ * for nested `FieldPath` queries. A scan-only backend ignores the result.
404
+ *
405
+ * @param shape - The column's contract shape
406
+ * @returns The portable column type
407
+ *
408
+ * @example
409
+ * ```ts
410
+ * shapeToColumnType(stringShape()) // 'text'
411
+ * shapeToColumnType(integerShape()) // 'integer'
412
+ * shapeToColumnType(optionalShape(integerShape())) // 'integer'
413
+ * shapeToColumnType(objectShape({ a: stringShape() })) // 'json'
414
+ * ```
415
+ */
416
+ function shapeToColumnType(shape) {
417
+ switch (shape.type) {
418
+ case "string": return "text";
419
+ case "number": return shape.integer === true ? "integer" : "real";
420
+ case "boolean": return "boolean";
421
+ case "literal":
422
+ if (shape.values.every((value) => typeof value === "boolean")) return "boolean";
423
+ if (shape.values.every((value) => typeof value === "number")) return shape.values.every((value) => Number.isInteger(value)) ? "integer" : "real";
424
+ return "text";
425
+ case "optional":
426
+ case "nullable": return shapeToColumnType(shape.inner);
427
+ case "null":
428
+ case "object":
429
+ case "array":
430
+ case "union":
431
+ case "json":
432
+ case "raw": return "json";
433
+ }
434
+ }
435
+ /**
436
+ * Whether a value is a well-formed {@link DriverMeta} — the boundary guard a
437
+ * versioning driver's `meta()` narrows a stored (structured-clone or
438
+ * `JSON.parse`d) value through before trusting it, replacing the per-driver
439
+ * duplicated narrowing every backend used to hand-roll (AGENTS §14: never `as`).
440
+ *
441
+ * @remarks
442
+ * Total and total-recursive over the whole shape: a finite `version`, and a
443
+ * `schema` array of well-formed {@link TableSchema} entries — each a `name` /
444
+ * `primary` string pair, a `columns` array of well-formed {@link ColumnSchema}
445
+ * entries (a `name` string, a {@link ColumnType} literal, a `nullable`
446
+ * boolean), and an `indexes` array of string arrays. Anything off-shape
447
+ * (including a non-record) returns `false` rather than throwing.
448
+ *
449
+ * @param value - The value to test
450
+ * @returns `true` when `value` is a well-formed `DriverMeta`
451
+ *
452
+ * @example
453
+ * ```ts
454
+ * isDriverMeta({ version: 1, schema: [] }) // true
455
+ * isDriverMeta({ version: 1, schema: [{ name: 'users' }] }) // false
456
+ * ```
457
+ */
458
+ function isDriverMeta(value) {
459
+ const COLUMN_TYPES = [
460
+ "text",
461
+ "integer",
462
+ "real",
463
+ "boolean",
464
+ "json",
465
+ "blob"
466
+ ];
467
+ const isColumnType = (candidate) => (0, _orkestrel_contract.isString)(candidate) && COLUMN_TYPES.some((type) => type === candidate);
468
+ const isColumnSchema = (candidate) => (0, _orkestrel_contract.isRecord)(candidate) && (0, _orkestrel_contract.isString)(candidate.name) && isColumnType(candidate.type) && (0, _orkestrel_contract.isBoolean)(candidate.nullable);
469
+ const isIndexGroup = (candidate) => (0, _orkestrel_contract.isArray)(candidate) && candidate.every((entry) => (0, _orkestrel_contract.isString)(entry));
470
+ 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);
471
+ return (0, _orkestrel_contract.isRecord)(value) && (0, _orkestrel_contract.isFiniteNumber)(value.version) && (0, _orkestrel_contract.isArray)(value.schema) && value.schema.every(isTableSchema);
472
+ }
473
+ /**
474
+ * Throw when an {@link ReadOptions.signal | AbortSignal} has fired — the shared
475
+ * cancellation gate checked at operation boundaries and between streamed rows.
476
+ *
477
+ * @remarks
478
+ * A no-op for `undefined` or a live signal, so callers thread `options?.signal`
479
+ * straight through. When the signal has aborted, throws an `ABORTED`
480
+ * {@link DatabaseError} carrying the signal's `reason` in its context — callers
481
+ * mint signals with whatever tool they like (`AbortSignal.timeout(ms)`,
482
+ * `new AbortController()`, `@orkestrel/abort`).
483
+ *
484
+ * @param signal - The signal to check, if any
485
+ * @returns Nothing — returns normally while the signal is live
486
+ * @throws An `ABORTED` {@link DatabaseError} when the signal has aborted
487
+ *
488
+ * @example
489
+ * ```ts
490
+ * import { checkAbort } from '@orkestrel/database'
491
+ *
492
+ * const controller = new AbortController()
493
+ * checkAbort(controller.signal) // returns
494
+ * controller.abort('too slow')
495
+ * checkAbort(controller.signal) // throws DatabaseError('ABORTED', …)
496
+ * ```
497
+ */
498
+ function checkAbort(signal) {
499
+ if (signal?.aborted) throw new DatabaseError("ABORTED", "Operation aborted", { reason: signal.reason });
500
+ }
501
+ /**
502
+ * Structurally diff a deployed and a declared table set into a {@link Migration}
503
+ * plan.
504
+ *
505
+ * @remarks
506
+ * Tables present in `declared` but not `deployed` become `table.add` steps
507
+ * (carrying the full declared {@link TableSchema}); tables present in
508
+ * `deployed` but not `declared` become `table.remove` steps. Tables present in
509
+ * both are diffed column-by-column (by name) and index-group-by-index-group
510
+ * (by deep equality of the column-name array), each producing `column.add` /
511
+ * `column.remove` / `index.add` / `index.remove` steps. Step order is
512
+ * deterministic: every `table.remove`, then every `table.add`, then each
513
+ * shared table's column/index changes in `declared` order. `from` / `to` are
514
+ * plan labels only — version tracking itself is deferred to persistent
515
+ * backends.
516
+ *
517
+ * A column present in BOTH schemas under the same name but with a different
518
+ * `type` or `nullable` throws a `MIGRATION` {@link DatabaseError} naming the
519
+ * table, the column, and the from→to difference — a name-only diff would
520
+ * otherwise silently produce NO step for the drift, and versioned
521
+ * reconciliation would stamp over it. There is no automatic in-place
522
+ * type-change step: the manual path is to add a new column, copy/convert the
523
+ * data at the application layer, then remove the old column — two separate
524
+ * plans, never a single implicit "alter" step.
525
+ *
526
+ * @param deployed - The table schemas currently applied
527
+ * @param declared - The table schemas the caller wants applied
528
+ * @param from - The plan's source version label (defaults to `0`)
529
+ * @param to - The plan's target version label (defaults to `1`)
530
+ * @returns The migration plan moving `deployed` toward `declared`
531
+ * @throws A `MIGRATION` {@link DatabaseError} when a shared column's `type` or
532
+ * `nullable` differs between `deployed` and `declared`
533
+ *
534
+ * @example
535
+ * ```ts
536
+ * const plan = planMigration(
537
+ * [{ name: 'users', primary: 'id', columns: [], indexes: [] }],
538
+ * [{ name: 'users', primary: 'id', columns: [{ name: 'age', type: 'integer', nullable: false }], indexes: [] }],
539
+ * )
540
+ * // plan.steps === [{ operation: 'column.add', table: 'users', column: { name: 'age', ... } }]
541
+ * ```
542
+ */
543
+ function planMigration(deployed, declared, from = 0, to = 1) {
544
+ const deployedByName = new Map(deployed.map((table) => [table.name, table]));
545
+ const declaredByName = new Map(declared.map((table) => [table.name, table]));
546
+ const steps = [];
547
+ for (const table of deployed) if (!declaredByName.has(table.name)) steps.push({
548
+ operation: "table.remove",
549
+ table: table.name
550
+ });
551
+ for (const table of declared) if (!deployedByName.has(table.name)) steps.push({
552
+ operation: "table.add",
553
+ table
554
+ });
555
+ for (const table of declared) {
556
+ const before = deployedByName.get(table.name);
557
+ if (before === void 0) continue;
558
+ const beforeColumns = new Map(before.columns.map((column) => [column.name, column]));
559
+ const afterColumns = new Map(table.columns.map((column) => [column.name, column]));
560
+ for (const column of before.columns) if (!afterColumns.has(column.name)) steps.push({
561
+ operation: "column.remove",
562
+ table: table.name,
563
+ column: column.name
564
+ });
565
+ for (const column of table.columns) {
566
+ const previous = beforeColumns.get(column.name);
567
+ if (previous === void 0) {
568
+ steps.push({
569
+ operation: "column.add",
570
+ table: table.name,
571
+ column
572
+ });
573
+ continue;
574
+ }
575
+ 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`, {
576
+ table: table.name,
577
+ column: column.name,
578
+ from: {
579
+ type: previous.type,
580
+ nullable: previous.nullable
581
+ },
582
+ to: {
583
+ type: column.type,
584
+ nullable: column.nullable
585
+ }
586
+ });
587
+ }
588
+ const sameIndex = (left, right) => left.length === right.length && left.every((column, position) => column === right[position]);
589
+ for (const index of before.indexes) if (!table.indexes.some((candidate) => sameIndex(candidate, index))) steps.push({
590
+ operation: "index.remove",
591
+ table: table.name,
592
+ index
593
+ });
594
+ for (const index of table.indexes) if (!before.indexes.some((candidate) => sameIndex(candidate, index))) steps.push({
595
+ operation: "index.add",
596
+ table: table.name,
597
+ index
598
+ });
599
+ }
600
+ return {
601
+ from,
602
+ to,
603
+ steps
604
+ };
605
+ }
606
+ /**
607
+ * Apply one table's {@link MigrationStep}s to its rows — a pure row transform.
608
+ *
609
+ * @remarks
610
+ * `column.remove` drops that field from every row (a fresh copy — inputs are
611
+ * never mutated, AGENTS §11); `column.add` leaves rows as-is (an absent field
612
+ * reads as `undefined`, backfill is application policy). `table.add` /
613
+ * `table.remove` / `index.add` / `index.remove` are no-ops here (they operate
614
+ * on storage shape, not row shape). Steps for tables other than the one
615
+ * `rows` belongs to are ignored — pass only the steps relevant to this table.
616
+ *
617
+ * @param rows - The table's current rows
618
+ * @param steps - The migration steps to apply (typically one table's slice of a {@link Migration})
619
+ * @returns A new array of transformed rows; `rows` is never mutated
620
+ *
621
+ * @example
622
+ * ```ts
623
+ * const rows = [{ id: 'a', name: 'Ada', legacy: true }]
624
+ * migrateRows(rows, [{ operation: 'column.remove', table: 'users', column: 'legacy' }])
625
+ * // => [{ id: 'a', name: 'Ada' }]
626
+ * ```
627
+ */
628
+ function migrateRows(rows, steps) {
629
+ const removed = steps.filter((step) => step.operation === "column.remove").map((step) => step.column);
630
+ if (removed.length === 0) return rows.map((row) => ({ ...row }));
631
+ return rows.map((row) => {
632
+ const next = {};
633
+ for (const key of Object.keys(row)) if (!removed.includes(key)) next[key] = row[key];
634
+ return next;
635
+ });
636
+ }
637
+ /**
638
+ * Run the driver-conformance battery against a fresh {@link DriverInterface}
639
+ * per phase, yielding one {@link ConformanceFinding} per violated invariant —
640
+ * the shared invariant suite every backend (in-memory, SQLite, IndexedDB)
641
+ * must uphold to be a drop-in {@link DriverInterface}.
642
+ *
643
+ * @remarks
644
+ * Framework-agnostic: no test-runner or Node imports, only sibling core
645
+ * modules — so it runs equally from a unit test, a smoke script, or a new
646
+ * driver's own README. Opens a fixed two-table schema (`users` keyed by the
647
+ * default `id`, `posts` keyed by a non-id `slug`) and, calling `factory()`
648
+ * fresh for each phase so failures stay isolated, verifies: `open`/`close`;
649
+ * `read` of a missing key returns `undefined`; `write`/`read` round-trip with
650
+ * DEEP copy-in/copy-out isolation (mutating the caller's row — including a
651
+ * NESTED field — after `write`, or a row `read` returns, never perturbs
652
+ * stored state) and upsert-overwrite; `delete` returns `true` then `false`;
653
+ * `keys`/`scan` yield in ascending key order; `clear` empties only its target
654
+ * table; `snapshot`'s rollback thunk restores pre-snapshot state, including a
655
+ * NESTED field mutated in place on a read-back row between capture and
656
+ * restore; a scoped `snapshot(['users'])` rolls back only the named table,
657
+ * leaving a concurrent mutation to another table intact; a
658
+ * non-`id` primary key (`posts.slug`) round-trips; a nested-object row
659
+ * round-trips structurally (via {@link deepEqual}). The optional surface is
660
+ * presence-gated: when `migrate` exists, a `column.remove` plan strips the
661
+ * column from stored rows and a plan referencing an unknown table throws
662
+ * `DatabaseError` `MIGRATION`; when `stream` exists, it yields only
663
+ * condition-matching rows and honors `offset`/`limit`; when `transaction`
664
+ * exists, `commit` persists and `rollback` restores; when both `meta` and
665
+ * `stamp` exist, a fresh store's `meta()` is `undefined`, and after
666
+ * `stamp({ version, schema })`, `meta()` returns the exact stamped value.
667
+ *
668
+ * Each phase runs within a `try`/`catch`: an EXPECTED mismatch yields a
669
+ * finding built from the assertion, while an UNEXPECTED throw (a driver
670
+ * crash mid-phase) is caught and yielded as a finding too, naming the phase
671
+ * as `check` and carrying the caught error in `context.error` — a broken
672
+ * driver can never escape the battery as an unhandled rejection. Within a
673
+ * phase, the FIRST violated assertion yields and the phase stops (matching
674
+ * the historical fail-fast shape at phase granularity); the generator then
675
+ * moves on to the next phase regardless. Because this is a **generator**,
676
+ * consuming only the first yielded value reproduces true fail-fast (later
677
+ * phases never run) — that is exactly what {@link conformDriver} does.
678
+ *
679
+ * @param factory - Mints a fresh, unopened driver instance (called once per phase)
680
+ * @yields One {@link ConformanceFinding} per violated invariant, in phase order
681
+ *
682
+ * @example
683
+ * ```ts
684
+ * import { createMemoryDriver, driverFindings } from '@orkestrel/database'
685
+ *
686
+ * for await (const finding of driverFindings(() => createMemoryDriver())) {
687
+ * console.log(finding.check, finding.message)
688
+ * }
689
+ * ```
690
+ */
691
+ async function* driverFindings(factory) {
692
+ const CONFORMANCE_USERS_SCHEMA = {
693
+ name: "users",
694
+ primary: "id",
695
+ columns: [
696
+ {
697
+ name: "id",
698
+ type: "text",
699
+ nullable: false
700
+ },
701
+ {
702
+ name: "name",
703
+ type: "text",
704
+ nullable: false
705
+ },
706
+ {
707
+ name: "age",
708
+ type: "integer",
709
+ nullable: true
710
+ },
711
+ {
712
+ name: "meta",
713
+ type: "json",
714
+ nullable: true
715
+ }
716
+ ],
717
+ indexes: []
718
+ };
719
+ const CONFORMANCE_POSTS_SCHEMA = {
720
+ name: "posts",
721
+ primary: "slug",
722
+ columns: [{
723
+ name: "slug",
724
+ type: "text",
725
+ nullable: false
726
+ }, {
727
+ name: "title",
728
+ type: "text",
729
+ nullable: false
730
+ }],
731
+ indexes: []
732
+ };
733
+ const CONFORMANCE_SCHEMA = [CONFORMANCE_USERS_SCHEMA, CONFORMANCE_POSTS_SCHEMA];
734
+ const findingOf = (check, message, context) => ({
735
+ check,
736
+ message,
737
+ context
738
+ });
739
+ const phases = [
740
+ {
741
+ check: "open-close",
742
+ run: async () => {
743
+ const driver = factory();
744
+ await driver.open(CONFORMANCE_SCHEMA);
745
+ await driver.close();
746
+ }
747
+ },
748
+ {
749
+ check: "read-missing",
750
+ run: async () => {
751
+ const driver = factory();
752
+ await driver.open(CONFORMANCE_SCHEMA);
753
+ const missing = await driver.read("users", "nope");
754
+ await driver.close();
755
+ if (missing !== void 0) return findingOf("read-missing", "read of a missing key must return undefined", {
756
+ table: "users",
757
+ expected: void 0,
758
+ actual: missing
759
+ });
760
+ }
761
+ },
762
+ {
763
+ check: "write-read",
764
+ run: async () => {
765
+ const driver = factory();
766
+ await driver.open(CONFORMANCE_SCHEMA);
767
+ const input = {
768
+ id: "u1",
769
+ name: "Ada",
770
+ age: 30,
771
+ meta: { tags: ["a"] }
772
+ };
773
+ await driver.write("users", "u1", input);
774
+ input.name = "Mutated after write";
775
+ if ((0, _orkestrel_contract.isRecord)(input.meta) && Array.isArray(input.meta.tags)) input.meta.tags.push("mutated");
776
+ const stored = await driver.read("users", "u1");
777
+ const original = {
778
+ id: "u1",
779
+ name: "Ada",
780
+ age: 30,
781
+ meta: { tags: ["a"] }
782
+ };
783
+ if (stored === void 0 || !deepEqual(stored, original)) {
784
+ await driver.close();
785
+ return findingOf("copy-in", "write must deep-copy the input row (including nested fields) rather than store it by reference", {
786
+ table: "users",
787
+ expected: original,
788
+ actual: stored
789
+ });
790
+ }
791
+ stored.name = "Mutated after read";
792
+ if ((0, _orkestrel_contract.isRecord)(stored.meta) && Array.isArray(stored.meta.tags)) stored.meta.tags.push("mutated");
793
+ const reread = await driver.read("users", "u1");
794
+ if (reread === void 0 || !deepEqual(reread, original)) {
795
+ await driver.close();
796
+ return findingOf("copy-out", "read must deep-copy the stored row (including nested fields) rather than return it by reference", {
797
+ table: "users",
798
+ expected: original,
799
+ actual: reread
800
+ });
801
+ }
802
+ const overwrite = {
803
+ id: "u1",
804
+ name: "Ada Overwritten",
805
+ age: 31
806
+ };
807
+ await driver.write("users", "u1", overwrite);
808
+ const overwritten = await driver.read("users", "u1");
809
+ await driver.close();
810
+ if (overwritten === void 0 || !deepEqual(overwritten, overwrite)) return findingOf("upsert", "write must upsert-overwrite an existing key", {
811
+ table: "users",
812
+ expected: overwrite,
813
+ actual: overwritten
814
+ });
815
+ }
816
+ },
817
+ {
818
+ check: "delete",
819
+ run: async () => {
820
+ const driver = factory();
821
+ await driver.open(CONFORMANCE_SCHEMA);
822
+ await driver.write("users", "u1", {
823
+ id: "u1",
824
+ name: "Ada",
825
+ age: 30
826
+ });
827
+ const first = await driver.delete("users", "u1");
828
+ if (first !== true) {
829
+ await driver.close();
830
+ return findingOf("delete-true", "delete of an existing key must return true", {
831
+ table: "users",
832
+ expected: true,
833
+ actual: first
834
+ });
835
+ }
836
+ const second = await driver.delete("users", "u1");
837
+ await driver.close();
838
+ if (second !== false) return findingOf("delete-false", "delete of an already-removed key must return false", {
839
+ table: "users",
840
+ expected: false,
841
+ actual: second
842
+ });
843
+ }
844
+ },
845
+ {
846
+ check: "order",
847
+ run: async () => {
848
+ const driver = factory();
849
+ await driver.open(CONFORMANCE_SCHEMA);
850
+ for (const row of [
851
+ {
852
+ id: "c",
853
+ name: "C",
854
+ age: 3
855
+ },
856
+ {
857
+ id: "a",
858
+ name: "A",
859
+ age: 1
860
+ },
861
+ {
862
+ id: "b",
863
+ name: "B",
864
+ age: 2
865
+ }
866
+ ]) await driver.write("users", row.id, row);
867
+ const expected = [
868
+ "a",
869
+ "b",
870
+ "c"
871
+ ];
872
+ const keys = [...await driver.keys("users")];
873
+ if (!deepEqual(keys, expected)) {
874
+ await driver.close();
875
+ return findingOf("keys-order", "keys must be returned in ascending key order", {
876
+ table: "users",
877
+ expected,
878
+ actual: keys
879
+ });
880
+ }
881
+ const scanned = [];
882
+ for await (const row of driver.scan("users")) scanned.push(row);
883
+ const scannedIds = scanned.map((row) => row.id);
884
+ await driver.close();
885
+ if (!deepEqual(scannedIds, expected)) return findingOf("scan-order", "scan must yield rows in ascending key order", {
886
+ table: "users",
887
+ expected,
888
+ actual: scannedIds
889
+ });
890
+ }
891
+ },
892
+ {
893
+ check: "clear",
894
+ run: async () => {
895
+ const driver = factory();
896
+ await driver.open(CONFORMANCE_SCHEMA);
897
+ await driver.write("users", "u1", {
898
+ id: "u1",
899
+ name: "Ada",
900
+ age: 30
901
+ });
902
+ await driver.write("posts", "p1", {
903
+ slug: "p1",
904
+ title: "Post"
905
+ });
906
+ await driver.clear("users");
907
+ const usersKeys = await driver.keys("users");
908
+ const postsKeys = await driver.keys("posts");
909
+ await driver.close();
910
+ if (usersKeys.length !== 0) return findingOf("clear-target", "clear must empty the targeted table", {
911
+ table: "users",
912
+ expected: [],
913
+ actual: usersKeys
914
+ });
915
+ if (postsKeys.length !== 1) return findingOf("clear-other", "clear must not affect other tables", {
916
+ table: "posts",
917
+ expected: 1,
918
+ actual: postsKeys.length
919
+ });
920
+ }
921
+ },
922
+ {
923
+ check: "snapshot",
924
+ run: async () => {
925
+ const driver = factory();
926
+ await driver.open(CONFORMANCE_SCHEMA);
927
+ const original = {
928
+ id: "u1",
929
+ name: "Ada",
930
+ age: 30
931
+ };
932
+ await driver.write("users", "u1", original);
933
+ const rollback = await driver.snapshot();
934
+ await driver.write("users", "u2", {
935
+ id: "u2",
936
+ name: "Grace",
937
+ age: 40
938
+ });
939
+ await driver.delete("users", "u1");
940
+ await rollback();
941
+ const keys = [...await driver.keys("users")];
942
+ if (!deepEqual(keys, ["u1"])) {
943
+ await driver.close();
944
+ return findingOf("snapshot-rollback", "snapshot rollback must restore the pre-snapshot key set", {
945
+ table: "users",
946
+ expected: ["u1"],
947
+ actual: keys
948
+ });
949
+ }
950
+ const restored = await driver.read("users", "u1");
951
+ await driver.close();
952
+ if (restored === void 0 || !deepEqual(restored, original)) return findingOf("snapshot-rollback-value", "snapshot rollback must restore pre-snapshot row values", {
953
+ table: "users",
954
+ expected: original,
955
+ actual: restored
956
+ });
957
+ }
958
+ },
959
+ {
960
+ check: "snapshot-nested",
961
+ run: async () => {
962
+ const driver = factory();
963
+ await driver.open(CONFORMANCE_SCHEMA);
964
+ const original = {
965
+ id: "u3",
966
+ name: "Nested",
967
+ age: 20,
968
+ meta: { tags: ["a"] }
969
+ };
970
+ await driver.write("users", "u3", original);
971
+ const rollback = await driver.snapshot();
972
+ const before = await driver.read("users", "u3");
973
+ if ((0, _orkestrel_contract.isRecord)(before) && (0, _orkestrel_contract.isRecord)(before.meta) && Array.isArray(before.meta.tags)) before.meta.tags.push("mutated-before-restore");
974
+ await driver.write("users", "u3", {
975
+ id: "u3",
976
+ name: "Nested",
977
+ age: 20,
978
+ meta: { tags: ["a", "mutated-after-write"] }
979
+ });
980
+ await rollback();
981
+ const restored = await driver.read("users", "u3");
982
+ await driver.close();
983
+ 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", {
984
+ table: "users",
985
+ expected: original,
986
+ actual: restored
987
+ });
988
+ }
989
+ },
990
+ {
991
+ check: "non-id-primary",
992
+ run: async () => {
993
+ const driver = factory();
994
+ await driver.open(CONFORMANCE_SCHEMA);
995
+ await driver.write("posts", "hello-world", {
996
+ slug: "hello-world",
997
+ title: "Hello"
998
+ });
999
+ const post = await driver.read("posts", "hello-world");
1000
+ const key = post === void 0 ? void 0 : extractKey(post, "slug");
1001
+ await driver.close();
1002
+ if (key !== "hello-world") return findingOf("non-id-primary", "a non-id primary key column must round-trip through the store", {
1003
+ table: "posts",
1004
+ expected: "hello-world",
1005
+ actual: key
1006
+ });
1007
+ }
1008
+ },
1009
+ {
1010
+ check: "nested-roundtrip",
1011
+ run: async () => {
1012
+ const driver = factory();
1013
+ await driver.open(CONFORMANCE_SCHEMA);
1014
+ const nested = {
1015
+ id: "u3",
1016
+ name: "Nested",
1017
+ age: 20,
1018
+ meta: {
1019
+ tags: ["a", "b"],
1020
+ deep: { flag: true }
1021
+ }
1022
+ };
1023
+ await driver.write("users", "u3", nested);
1024
+ const readBack = await driver.read("users", "u3");
1025
+ await driver.close();
1026
+ if (readBack === void 0 || !deepEqual(readBack, nested)) return findingOf("nested-roundtrip", "a nested-object row must round-trip structurally", {
1027
+ table: "users",
1028
+ expected: nested,
1029
+ actual: readBack
1030
+ });
1031
+ }
1032
+ },
1033
+ {
1034
+ check: "migrate",
1035
+ run: async () => {
1036
+ const driver = factory();
1037
+ if (driver.migrate === void 0) return void 0;
1038
+ const deployedUsers = {
1039
+ ...CONFORMANCE_USERS_SCHEMA,
1040
+ columns: [...CONFORMANCE_USERS_SCHEMA.columns, {
1041
+ name: "legacy",
1042
+ type: "boolean",
1043
+ nullable: true
1044
+ }]
1045
+ };
1046
+ await driver.open([deployedUsers, CONFORMANCE_POSTS_SCHEMA]);
1047
+ await driver.write("users", "u1", {
1048
+ id: "u1",
1049
+ name: "Ada",
1050
+ age: 30,
1051
+ legacy: true
1052
+ });
1053
+ const removePlan = planMigration([deployedUsers], [CONFORMANCE_USERS_SCHEMA]);
1054
+ await driver.migrate(removePlan);
1055
+ const migrated = await driver.read("users", "u1");
1056
+ if (migrated === void 0 || "legacy" in migrated) {
1057
+ await driver.close();
1058
+ return findingOf("migrate-column-remove", "a column.remove migration must strip the column from stored rows", {
1059
+ table: "users",
1060
+ expected: void 0,
1061
+ actual: migrated === void 0 ? void 0 : migrated.legacy
1062
+ });
1063
+ }
1064
+ let caught;
1065
+ try {
1066
+ await driver.migrate({
1067
+ from: 0,
1068
+ to: 1,
1069
+ steps: [{
1070
+ operation: "table.remove",
1071
+ table: "ghost"
1072
+ }]
1073
+ });
1074
+ } catch (error) {
1075
+ caught = error;
1076
+ }
1077
+ await driver.close();
1078
+ if (!isDatabaseError(caught) || caught.code !== "MIGRATION") return findingOf("migrate-unknown-table", "a migration step referencing an unknown table must throw a MIGRATION DatabaseError", {
1079
+ table: "ghost",
1080
+ expected: "MIGRATION",
1081
+ actual: isDatabaseError(caught) ? caught.code : caught
1082
+ });
1083
+ }
1084
+ },
1085
+ {
1086
+ check: "stream",
1087
+ run: async () => {
1088
+ const driver = factory();
1089
+ if (driver.stream === void 0) return void 0;
1090
+ await driver.open(CONFORMANCE_SCHEMA);
1091
+ for (const row of [
1092
+ {
1093
+ id: "a",
1094
+ name: "A",
1095
+ age: 10
1096
+ },
1097
+ {
1098
+ id: "b",
1099
+ name: "B",
1100
+ age: 20
1101
+ },
1102
+ {
1103
+ id: "c",
1104
+ name: "C",
1105
+ age: 30
1106
+ }
1107
+ ]) await driver.write("users", row.id, row);
1108
+ const criteria = { conditions: [{
1109
+ column: "age",
1110
+ operator: "above",
1111
+ values: [10],
1112
+ connector: "and"
1113
+ }] };
1114
+ const matched = [];
1115
+ for await (const row of driver.stream("users", criteria)) matched.push(row);
1116
+ const matchedIds = matched.map((row) => row.id).sort();
1117
+ if (!deepEqual(matchedIds, ["b", "c"])) {
1118
+ await driver.close();
1119
+ return findingOf("stream-match", "stream must yield only condition-matching rows", {
1120
+ table: "users",
1121
+ expected: ["b", "c"],
1122
+ actual: matchedIds
1123
+ });
1124
+ }
1125
+ const paged = [];
1126
+ for await (const row of driver.stream("users", {
1127
+ offset: 1,
1128
+ limit: 1
1129
+ })) paged.push(row);
1130
+ await driver.close();
1131
+ if (paged.length !== 1) return findingOf("stream-page", "stream must honor offset and limit", {
1132
+ table: "users",
1133
+ expected: 1,
1134
+ actual: paged.length
1135
+ });
1136
+ }
1137
+ },
1138
+ {
1139
+ check: "transaction",
1140
+ run: async () => {
1141
+ const driver = factory();
1142
+ if (driver.transaction === void 0) return void 0;
1143
+ await driver.open(CONFORMANCE_SCHEMA);
1144
+ await driver.write("users", "u1", {
1145
+ id: "u1",
1146
+ name: "Ada",
1147
+ age: 30
1148
+ });
1149
+ const committing = await driver.transaction();
1150
+ await driver.write("users", "u2", {
1151
+ id: "u2",
1152
+ name: "Grace",
1153
+ age: 40
1154
+ });
1155
+ await committing.commit();
1156
+ const afterCommit = [...await driver.keys("users")].sort();
1157
+ if (!deepEqual(afterCommit, ["u1", "u2"])) {
1158
+ await driver.close();
1159
+ return findingOf("transaction-commit", "transaction commit must persist writes made during the scope", {
1160
+ table: "users",
1161
+ expected: ["u1", "u2"],
1162
+ actual: afterCommit
1163
+ });
1164
+ }
1165
+ const rollingBack = await driver.transaction();
1166
+ await driver.write("users", "u3", {
1167
+ id: "u3",
1168
+ name: "Marie",
1169
+ age: 50
1170
+ });
1171
+ await rollingBack.rollback();
1172
+ const afterRollback = [...await driver.keys("users")].sort();
1173
+ await driver.close();
1174
+ if (!deepEqual(afterRollback, ["u1", "u2"])) return findingOf("transaction-rollback", "transaction rollback must restore pre-transaction state", {
1175
+ table: "users",
1176
+ expected: ["u1", "u2"],
1177
+ actual: afterRollback
1178
+ });
1179
+ }
1180
+ },
1181
+ {
1182
+ check: "meta-stamp",
1183
+ run: async () => {
1184
+ const driver = factory();
1185
+ if (driver.meta === void 0 || driver.stamp === void 0) return void 0;
1186
+ await driver.open(CONFORMANCE_SCHEMA);
1187
+ const fresh = await driver.meta();
1188
+ if (fresh !== void 0) {
1189
+ await driver.close();
1190
+ return findingOf("meta-fresh", "a fresh store must report undefined meta", {
1191
+ expected: void 0,
1192
+ actual: fresh
1193
+ });
1194
+ }
1195
+ const stamped = {
1196
+ version: 1,
1197
+ schema: CONFORMANCE_SCHEMA
1198
+ };
1199
+ await driver.stamp(stamped);
1200
+ const read = await driver.meta();
1201
+ await driver.close();
1202
+ if (read === void 0 || !deepEqual(read, stamped)) return findingOf("meta-stamp", "meta() must return exactly the last-stamped value", {
1203
+ expected: stamped,
1204
+ actual: read
1205
+ });
1206
+ }
1207
+ },
1208
+ {
1209
+ check: "snapshot-scoped",
1210
+ run: async () => {
1211
+ const driver = factory();
1212
+ await driver.open(CONFORMANCE_SCHEMA);
1213
+ await driver.write("users", "u1", {
1214
+ id: "u1",
1215
+ name: "Ada",
1216
+ age: 30
1217
+ });
1218
+ await driver.write("posts", "p1", {
1219
+ slug: "p1",
1220
+ title: "Post"
1221
+ });
1222
+ const rollback = await driver.snapshot(["users"]);
1223
+ await driver.write("users", "u2", {
1224
+ id: "u2",
1225
+ name: "Grace",
1226
+ age: 40
1227
+ });
1228
+ await driver.write("posts", "p2", {
1229
+ slug: "p2",
1230
+ title: "Another post"
1231
+ });
1232
+ await rollback();
1233
+ const usersKeys = [...await driver.keys("users")];
1234
+ if (!deepEqual(usersKeys, ["u1"])) {
1235
+ await driver.close();
1236
+ return findingOf("snapshot-scoped-users", "a scoped snapshot must roll back only the named table", {
1237
+ table: "users",
1238
+ expected: ["u1"],
1239
+ actual: usersKeys
1240
+ });
1241
+ }
1242
+ const postsKeys = [...await driver.keys("posts")].sort();
1243
+ await driver.close();
1244
+ if (!deepEqual(postsKeys, ["p1", "p2"])) return findingOf("snapshot-scoped-posts", "a scoped snapshot must leave an unnamed table's mutations intact", {
1245
+ table: "posts",
1246
+ expected: ["p1", "p2"],
1247
+ actual: postsKeys
1248
+ });
1249
+ }
1250
+ }
1251
+ ];
1252
+ for (const phase of phases) try {
1253
+ const finding = await phase.run();
1254
+ if (finding !== void 0) yield finding;
1255
+ } catch (error) {
1256
+ yield findingOf(phase.check, error instanceof Error ? error.message : String(error), { error });
1257
+ }
1258
+ }
1259
+ /**
1260
+ * Run the driver-conformance battery, throwing on the first violated
1261
+ * invariant — the fail-fast entry point most callers (test setup, CI smoke
1262
+ * checks) want.
1263
+ *
1264
+ * @remarks
1265
+ * A thin driver over {@link driverFindings}: because that generator is
1266
+ * lazy, consuming only its first yielded value means every LATER phase
1267
+ * never runs — true fail-fast, not merely "report only the first". The
1268
+ * thrown error is byte-compatible with the historical shape: a
1269
+ * `CONFORMANCE` {@link DatabaseError} whose `message` is the finding's
1270
+ * `message` and whose `context` is `{ check, ...finding.context }`.
1271
+ *
1272
+ * @param factory - Mints a fresh, unopened driver instance (called once per phase)
1273
+ * @returns Nothing — resolves once every phase has passed
1274
+ * @throws A `CONFORMANCE` {@link DatabaseError} on the first violated invariant
1275
+ *
1276
+ * @example
1277
+ * ```ts
1278
+ * import { conformDriver, createMemoryDriver } from '@orkestrel/database'
1279
+ *
1280
+ * await conformDriver(() => createMemoryDriver()) // resolves when every invariant holds
1281
+ * ```
1282
+ */
1283
+ async function conformDriver(factory) {
1284
+ for await (const finding of driverFindings(factory)) throw new DatabaseError("CONFORMANCE", finding.message, {
1285
+ check: finding.check,
1286
+ ...finding.context
1287
+ });
1288
+ }
1289
+ /**
1290
+ * Run the FULL driver-conformance battery and collect every violation — the
1291
+ * audit entry point for a driver author who wants a complete report rather
1292
+ * than a single fail-fast throw.
1293
+ *
1294
+ * @remarks
1295
+ * Drains {@link driverFindings} to completion: every phase runs regardless
1296
+ * of earlier violations, so a driver breaking two independent invariants
1297
+ * reports both. An empty array means the driver is fully conformant.
1298
+ *
1299
+ * @param factory - Mints a fresh, unopened driver instance (called once per phase)
1300
+ * @returns Every violated invariant found, in phase order (empty when fully conformant)
1301
+ *
1302
+ * @example
1303
+ * ```ts
1304
+ * import { auditDriver, createMemoryDriver } from '@orkestrel/database'
1305
+ *
1306
+ * const findings = await auditDriver(() => createMemoryDriver())
1307
+ * for (const finding of findings) console.log(`${finding.check}: ${finding.message}`)
1308
+ * ```
1309
+ */
1310
+ async function auditDriver(factory) {
1311
+ const findings = [];
1312
+ for await (const finding of driverFindings(factory)) findings.push(finding);
1313
+ return findings;
1314
+ }
1315
+ //#endregion
1316
+ //#region src/core/Cursor.ts
1317
+ /**
1318
+ * A forward row cursor for bulk in-place mutation.
1319
+ *
1320
+ * @remarks
1321
+ * Iterates a snapshot of the table's keys captured when the cursor was opened,
1322
+ * reading each row lazily through the owning table — so a mutation made during
1323
+ * iteration cannot corrupt the walk, and a key removed mid-iteration is simply
1324
+ * skipped. `update` and `remove` act on the row at the current position.
1325
+ */
1326
+ var Cursor = class {
1327
+ #table;
1328
+ #keys;
1329
+ #index = -1;
1330
+ #value;
1331
+ #closed = false;
1332
+ constructor(table, keys) {
1333
+ this.#table = table;
1334
+ this.#keys = keys;
1335
+ }
1336
+ get value() {
1337
+ return this.#value;
1338
+ }
1339
+ get index() {
1340
+ return this.#index;
1341
+ }
1342
+ get done() {
1343
+ return this.#closed || this.#index >= this.#keys.length;
1344
+ }
1345
+ async next() {
1346
+ if (this.#closed) return;
1347
+ this.#index += 1;
1348
+ while (this.#index < this.#keys.length) {
1349
+ const row = await this.#table.get(this.#keys[this.#index]);
1350
+ if (row !== void 0) {
1351
+ this.#value = row;
1352
+ return;
1353
+ }
1354
+ this.#index += 1;
1355
+ }
1356
+ this.#value = void 0;
1357
+ }
1358
+ async update(changes) {
1359
+ if (this.#closed || this.#value === void 0) return;
1360
+ const key = this.#keys[this.#index];
1361
+ await this.#table.update(key, changes);
1362
+ this.#value = await this.#table.get(key);
1363
+ }
1364
+ async remove() {
1365
+ if (this.#closed || this.#value === void 0) return;
1366
+ await this.#table.remove(this.#keys[this.#index]);
1367
+ this.#value = void 0;
1368
+ }
1369
+ close() {
1370
+ this.#closed = true;
1371
+ this.#value = void 0;
1372
+ }
1373
+ };
1374
+ //#endregion
1375
+ //#region src/core/Clause.ts
1376
+ /**
1377
+ * A pending condition opened by a query's `where` / `and` / `or`.
1378
+ *
1379
+ * @remarks
1380
+ * Holds the column, the connector that will join this condition to the ones
1381
+ * before it, and a recorder the owning query supplies. Each operator builds the
1382
+ * {@link Condition}, hands it to the recorder, and returns the query — so the
1383
+ * fluent chain flows straight back into the builder without exposing a mutator.
1384
+ */
1385
+ var Clause = class {
1386
+ #record;
1387
+ #column;
1388
+ #connector;
1389
+ constructor(record, column, connector) {
1390
+ this.#record = record;
1391
+ this.#column = column;
1392
+ this.#connector = connector;
1393
+ }
1394
+ equals(value) {
1395
+ return this.#apply("equals", [value]);
1396
+ }
1397
+ not(value) {
1398
+ return this.#apply("not", [value]);
1399
+ }
1400
+ above(value) {
1401
+ return this.#apply("above", [value]);
1402
+ }
1403
+ below(value) {
1404
+ return this.#apply("below", [value]);
1405
+ }
1406
+ from(value) {
1407
+ return this.#apply("from", [value]);
1408
+ }
1409
+ to(value) {
1410
+ return this.#apply("to", [value]);
1411
+ }
1412
+ between(lower, upper) {
1413
+ return this.#apply("between", [lower, upper]);
1414
+ }
1415
+ like(pattern) {
1416
+ return this.#apply("like", [pattern]);
1417
+ }
1418
+ glob(pattern) {
1419
+ return this.#apply("glob", [pattern]);
1420
+ }
1421
+ starts(prefix) {
1422
+ return this.#apply("starts", [prefix]);
1423
+ }
1424
+ ends(suffix) {
1425
+ return this.#apply("ends", [suffix]);
1426
+ }
1427
+ any(values) {
1428
+ return this.#apply("any", values);
1429
+ }
1430
+ none(values) {
1431
+ return this.#apply("none", values);
1432
+ }
1433
+ absent() {
1434
+ return this.#apply("absent", []);
1435
+ }
1436
+ present() {
1437
+ return this.#apply("present", []);
1438
+ }
1439
+ #apply(operator, values) {
1440
+ return this.#record({
1441
+ column: this.#column,
1442
+ operator,
1443
+ values,
1444
+ connector: this.#connector
1445
+ });
1446
+ }
1447
+ };
1448
+ //#endregion
1449
+ //#region src/core/Query.ts
1450
+ /**
1451
+ * A fluent query builder bound to one table.
1452
+ *
1453
+ * @remarks
1454
+ * Accumulates conditions, ordering, JS filters, and a page; each builder method
1455
+ * mutates and returns the same instance, so a chain reads as one statement. The
1456
+ * portable parts (conditions, order, page) compile into a {@link Criteria} the
1457
+ * table resolves; a `filter` predicate is applied in memory after the read and
1458
+ * before paging, so it composes with the rest without a backend ever seeing a
1459
+ * JS callback.
1460
+ */
1461
+ var Query = class {
1462
+ #table;
1463
+ #conditions = [];
1464
+ #orders = [];
1465
+ #filters = [];
1466
+ #limit;
1467
+ #offset;
1468
+ constructor(table) {
1469
+ this.#table = table;
1470
+ }
1471
+ where(column) {
1472
+ return this.#clause(column, "and");
1473
+ }
1474
+ and(column) {
1475
+ return this.#clause(column, "and");
1476
+ }
1477
+ or(column) {
1478
+ return this.#clause(column, "or");
1479
+ }
1480
+ filter(predicate) {
1481
+ this.#filters.push(predicate);
1482
+ return this;
1483
+ }
1484
+ ascending(column) {
1485
+ this.#orders.push({
1486
+ column,
1487
+ direction: "ascending"
1488
+ });
1489
+ return this;
1490
+ }
1491
+ descending(column) {
1492
+ this.#orders.push({
1493
+ column,
1494
+ direction: "descending"
1495
+ });
1496
+ return this;
1497
+ }
1498
+ limit(count) {
1499
+ this.#limit = count;
1500
+ return this;
1501
+ }
1502
+ offset(count) {
1503
+ this.#offset = count;
1504
+ return this;
1505
+ }
1506
+ async all() {
1507
+ if (this.#filters.length === 0) return this.#table.records({
1508
+ conditions: this.#conditions,
1509
+ order: this.#orders,
1510
+ limit: this.#limit,
1511
+ offset: this.#offset
1512
+ });
1513
+ const fetched = await this.#table.records({
1514
+ conditions: this.#conditions,
1515
+ order: this.#orders
1516
+ });
1517
+ return this.#page(this.#filtered(fetched));
1518
+ }
1519
+ async first() {
1520
+ return (await this.all())[0];
1521
+ }
1522
+ async count() {
1523
+ if (this.#filters.length === 0) return this.#table.count({ conditions: this.#conditions });
1524
+ const fetched = await this.#table.records({ conditions: this.#conditions });
1525
+ return this.#filtered(fetched).length;
1526
+ }
1527
+ /**
1528
+ * Lazy per-row evaluation of this query's conditions / filters / offset /
1529
+ * limit.
1530
+ *
1531
+ * @remarks
1532
+ * `order` and its comparators are IGNORED (streaming yields unsorted, as rows
1533
+ * are evaluated one at a time). Same abort semantics as
1534
+ * `TableInterface.scan`: the signal (if any) is checked before each yield,
1535
+ * and breaking out early closes the underlying source.
1536
+ *
1537
+ * @param options - `signal` to cancel the iteration; checked before each yield
1538
+ * @returns An async iterable of matching rows
1539
+ */
1540
+ async *stream(options) {
1541
+ if (this.#filters.length === 0) {
1542
+ yield* this.#table.scan({
1543
+ conditions: this.#conditions,
1544
+ limit: this.#limit,
1545
+ offset: this.#offset
1546
+ }, options);
1547
+ return;
1548
+ }
1549
+ const offset = this.#offset ?? 0;
1550
+ let matched = 0;
1551
+ let yielded = 0;
1552
+ for await (const row of this.#table.scan({ conditions: this.#conditions }, options)) {
1553
+ if (this.#limit !== void 0 && yielded >= this.#limit) break;
1554
+ let matches = true;
1555
+ for (const predicate of this.#filters) if (!predicate(row)) {
1556
+ matches = false;
1557
+ break;
1558
+ }
1559
+ if (!matches) continue;
1560
+ if (matched < offset) {
1561
+ matched += 1;
1562
+ continue;
1563
+ }
1564
+ matched += 1;
1565
+ yielded += 1;
1566
+ yield row;
1567
+ }
1568
+ }
1569
+ aggregate(operation, column) {
1570
+ if (this.#filters.length === 0) return this.#table.aggregate(operation, column, { conditions: this.#conditions });
1571
+ return this.#table.records({ conditions: this.#conditions }).then((fetched) => computeAggregate(this.#filtered(fetched), operation, column));
1572
+ }
1573
+ sum(column) {
1574
+ return this.aggregate("sum", column);
1575
+ }
1576
+ average(column) {
1577
+ return this.aggregate("average", column);
1578
+ }
1579
+ minimum(column) {
1580
+ return this.aggregate("minimum", column);
1581
+ }
1582
+ maximum(column) {
1583
+ return this.aggregate("maximum", column);
1584
+ }
1585
+ #clause(column, connector) {
1586
+ return new Clause((condition) => {
1587
+ this.#conditions.push(condition);
1588
+ return this;
1589
+ }, column, connector);
1590
+ }
1591
+ #filtered(rows) {
1592
+ let result = rows;
1593
+ for (const predicate of this.#filters) result = result.filter(predicate);
1594
+ return result;
1595
+ }
1596
+ #page(rows) {
1597
+ const offset = this.#offset ?? 0;
1598
+ if (offset === 0 && this.#limit === void 0) return rows;
1599
+ return rows.slice(offset, this.#limit === void 0 ? void 0 : offset + this.#limit);
1600
+ }
1601
+ };
1602
+ //#endregion
1603
+ //#region src/core/Table.ts
1604
+ /**
1605
+ * A table — typed keyed CRUD plus fluent query and cursor access over a driver.
1606
+ *
1607
+ * @remarks
1608
+ * The table's contract is the load-bearing piece: writes go through `parse`
1609
+ * (coercing inputs and rejecting rows that don't fit with a `VALIDATION` throw),
1610
+ * reads come back through the contract guard (narrowing a stored {@link Row} to
1611
+ * the table's type — no assertion, AGENTS §1), and `contract` is exposed for
1612
+ * introspection and seeding. The driver only stores and scans; all querying is
1613
+ * the shared core engine in `helpers.ts`.
1614
+ *
1615
+ * @remarks
1616
+ * - **Observable (§13).** The owned {@link emitter} ({@link TableEventMap}) carries the
1617
+ * per-row mutation moments — `write` (set / add / update), `remove`, `clear` — for
1618
+ * fire-and-forget observers (cache invalidation, sync, an audit log), ALONGSIDE the
1619
+ * database-level lifecycle. Events carry the affected KEY only (no value payload, to
1620
+ * keep fan-out lean); reads / queries / counts are not emitted. Every event is emitted
1621
+ * directly, strictly AFTER the driver write / delete / clear completes; the emitter
1622
+ * isolates a listener throw and routes it to its `error` handler (the `error` option),
1623
+ * so a buggy observer can never corrupt a write or perturb a transaction.
1624
+ */
1625
+ var Table = class {
1626
+ #ready;
1627
+ #driver;
1628
+ #name;
1629
+ #key;
1630
+ #contract;
1631
+ #guard;
1632
+ #generate;
1633
+ #emitter;
1634
+ constructor(ready, driver, name, key, contract, generate, on, error) {
1635
+ this.#ready = ready;
1636
+ this.#driver = driver;
1637
+ this.#name = name;
1638
+ this.#key = key;
1639
+ this.#contract = contract;
1640
+ this.#guard = contract.is;
1641
+ this.#generate = generate;
1642
+ this.#emitter = new _orkestrel_emitter.Emitter({
1643
+ on,
1644
+ error
1645
+ });
1646
+ }
1647
+ get emitter() {
1648
+ return this.#emitter;
1649
+ }
1650
+ get name() {
1651
+ return this.#name;
1652
+ }
1653
+ get primary() {
1654
+ return this.#key;
1655
+ }
1656
+ get contract() {
1657
+ return this.#contract;
1658
+ }
1659
+ async get(keys) {
1660
+ await this.#ready();
1661
+ if ((0, _orkestrel_contract.isArray)(keys)) return this.#each(keys, (key) => this.#read(key));
1662
+ return this.#read(keys);
1663
+ }
1664
+ async resolve(keys) {
1665
+ await this.#ready();
1666
+ if ((0, _orkestrel_contract.isArray)(keys)) return this.#each(keys, (key) => this.#resolveOne(key));
1667
+ return this.#resolveOne(keys);
1668
+ }
1669
+ async has(keys) {
1670
+ await this.#ready();
1671
+ if ((0, _orkestrel_contract.isArray)(keys)) return this.#each(keys, async (key) => await this.#read(key) !== void 0);
1672
+ return await this.#read(keys) !== void 0;
1673
+ }
1674
+ async keys() {
1675
+ await this.#ready();
1676
+ return this.#driver.keys(this.#name);
1677
+ }
1678
+ async records(criteria, options) {
1679
+ checkAbort(options?.signal);
1680
+ await this.#ready();
1681
+ const source = await this.#driver.records?.(this.#name, criteria ?? {}) ?? applyCriteria(await this.#collect(), criteria);
1682
+ const rows = [];
1683
+ for (const row of source) if (this.#guard(row)) rows.push(row);
1684
+ return rows;
1685
+ }
1686
+ /**
1687
+ * Count rows matching `criteria`'s conditions.
1688
+ *
1689
+ * @remarks
1690
+ * Unlike {@link records}, which narrows every row through the table's
1691
+ * contract guard before returning it, `count` operates on STORED rows
1692
+ * WITHOUT that guard (both the native `driver.count` hook and the
1693
+ * `filterRows`-over-`#collect()` fallback count raw storage) — so it can
1694
+ * exceed `(await records(criteria)).length` when storage holds rows that
1695
+ * no longer conform to the table's contract (legacy or migrated data).
1696
+ *
1697
+ * @param criteria - Optional conditions to filter by (paging is ignored)
1698
+ * @param options - `{ signal }` to abort
1699
+ * @returns The count of matching stored rows
1700
+ */
1701
+ async count(criteria, options) {
1702
+ checkAbort(options?.signal);
1703
+ await this.#ready();
1704
+ const conditions = criteria?.conditions;
1705
+ const native = await this.#driver.count?.(this.#name, conditions ? { conditions } : {});
1706
+ if (native !== void 0) return native;
1707
+ return filterRows(await this.#collect(), criteria?.conditions ?? []).length;
1708
+ }
1709
+ /**
1710
+ * Compute an aggregate over `column` across rows matching `criteria`'s
1711
+ * conditions.
1712
+ *
1713
+ * @remarks
1714
+ * Like {@link count}, `aggregate` operates on STORED rows WITHOUT the
1715
+ * contract guard {@link records} / {@link scan} apply — a non-conforming
1716
+ * stored row still contributes to the computed aggregate when it matches
1717
+ * the conditions, even though it would never appear in `records()`'s
1718
+ * output.
1719
+ *
1720
+ * @param operation - The aggregate to compute
1721
+ * @param column - The column to aggregate
1722
+ * @param criteria - Optional conditions to filter by (paging is ignored)
1723
+ * @param options - `{ signal }` to abort
1724
+ * @returns The aggregate value, or `undefined` when undefined for the inputs
1725
+ */
1726
+ async aggregate(operation, column, criteria, options) {
1727
+ checkAbort(options?.signal);
1728
+ await this.#ready();
1729
+ const conditions = criteria?.conditions;
1730
+ const filter = conditions ? { conditions } : {};
1731
+ const native = this.#driver.aggregate?.(this.#name, operation, column, filter);
1732
+ if (native !== void 0) return native;
1733
+ return computeAggregate(await this.#driver.records?.(this.#name, filter) ?? filterRows(await this.#collect(), criteria?.conditions ?? []), operation, column);
1734
+ }
1735
+ /**
1736
+ * Stream the table's rows matching `criteria`, applying offset/limit paging.
1737
+ *
1738
+ * @remarks
1739
+ * `criteria.limit` counts rows that pass BOTH the criteria conditions AND the
1740
+ * table's contract guard (a stored row that fails the guard is skipped and
1741
+ * does not count toward `limit`) — this can differ from {@link records}'s
1742
+ * `limit`, which a driver's optional native `records` hook applies BEFORE
1743
+ * the contract guard runs, when storage holds rows that no longer conform
1744
+ * to the table's contract.
1745
+ *
1746
+ * @param criteria - Optional conditions plus offset/limit paging
1747
+ * @param options - `{ signal }` to abort mid-stream
1748
+ * @returns An async iterable of matching, guard-conforming rows
1749
+ */
1750
+ async *scan(criteria, options) {
1751
+ checkAbort(options?.signal);
1752
+ await this.#ready();
1753
+ if (this.#driver.stream !== void 0) {
1754
+ for await (const row of this.#driver.stream(this.#name, criteria ?? {})) {
1755
+ checkAbort(options?.signal);
1756
+ const narrowed = this.#cast(row);
1757
+ if (narrowed !== void 0) yield narrowed;
1758
+ }
1759
+ return;
1760
+ }
1761
+ const conditions = criteria?.conditions;
1762
+ const offset = criteria?.offset ?? 0;
1763
+ const limit = criteria?.limit;
1764
+ let matched = 0;
1765
+ let yielded = 0;
1766
+ for await (const row of this.#driver.scan(this.#name)) {
1767
+ checkAbort(options?.signal);
1768
+ if (limit !== void 0 && yielded >= limit) break;
1769
+ if (conditions !== void 0 && conditions.length > 0 && !matchesCriteria(row, conditions)) continue;
1770
+ if (matched < offset) {
1771
+ matched += 1;
1772
+ continue;
1773
+ }
1774
+ matched += 1;
1775
+ const narrowed = this.#cast(row);
1776
+ if (narrowed !== void 0) {
1777
+ yielded += 1;
1778
+ yield narrowed;
1779
+ }
1780
+ }
1781
+ }
1782
+ async set(rows, options) {
1783
+ checkAbort(options?.signal);
1784
+ await this.#ready();
1785
+ if ((0, _orkestrel_contract.isArray)(rows)) return this.#each(rows, (row) => this.#put(row, false), options?.signal);
1786
+ return this.#put(rows, false);
1787
+ }
1788
+ async add(rows, options) {
1789
+ checkAbort(options?.signal);
1790
+ await this.#ready();
1791
+ if ((0, _orkestrel_contract.isArray)(rows)) return this.#each(rows, (row) => this.#put(row, true), options?.signal);
1792
+ return this.#put(rows, true);
1793
+ }
1794
+ async update(keys, changes, options) {
1795
+ checkAbort(options?.signal);
1796
+ await this.#ready();
1797
+ if ((0, _orkestrel_contract.isArray)(keys)) return this.#each(keys, (key) => this.#updateOne(key, changes), options?.signal);
1798
+ return this.#updateOne(keys, changes);
1799
+ }
1800
+ async remove(keys, options) {
1801
+ checkAbort(options?.signal);
1802
+ await this.#ready();
1803
+ if ((0, _orkestrel_contract.isArray)(keys)) return this.#each(keys, (key) => this.#delete(key), options?.signal);
1804
+ return this.#delete(keys);
1805
+ }
1806
+ async clear() {
1807
+ await this.#ready();
1808
+ await this.#driver.clear(this.#name);
1809
+ this.#emitter.emit("clear");
1810
+ }
1811
+ query() {
1812
+ return new Query(this);
1813
+ }
1814
+ async cursor() {
1815
+ await this.#ready();
1816
+ const cursor = new Cursor(this, await this.#driver.keys(this.#name));
1817
+ await cursor.next();
1818
+ return cursor;
1819
+ }
1820
+ async #each(items, operation, signal) {
1821
+ const results = [];
1822
+ for (const item of items) {
1823
+ checkAbort(signal);
1824
+ results.push(await operation(item));
1825
+ }
1826
+ return results;
1827
+ }
1828
+ async #read(key) {
1829
+ return this.#cast(await this.#driver.read(this.#name, key));
1830
+ }
1831
+ async #resolveOne(key) {
1832
+ const row = await this.#read(key);
1833
+ if (row === void 0) throw new DatabaseError("NOT_FOUND", `No row '${key}' in table '${this.#name}'`, {
1834
+ table: this.#name,
1835
+ key
1836
+ });
1837
+ return row;
1838
+ }
1839
+ async #put(row, exclusive) {
1840
+ const validated = this.#validate(this.#prepare(row));
1841
+ const key = this.#resolveKey(validated);
1842
+ if (exclusive && await this.#driver.read(this.#name, key) !== void 0) throw new DatabaseError("CONFLICT", `Row '${key}' already exists in table '${this.#name}'`, {
1843
+ table: this.#name,
1844
+ key
1845
+ });
1846
+ await this.#driver.write(this.#name, key, validated);
1847
+ this.#emitter.emit("write", key);
1848
+ return key;
1849
+ }
1850
+ async #updateOne(key, changes) {
1851
+ const existing = await this.#driver.read(this.#name, key);
1852
+ if (existing === void 0) return false;
1853
+ await this.#driver.write(this.#name, key, this.#validate(Object.assign({}, existing, changes)));
1854
+ this.#emitter.emit("write", key);
1855
+ return true;
1856
+ }
1857
+ async #delete(key) {
1858
+ const removed = await this.#driver.delete(this.#name, key);
1859
+ if (removed) this.#emitter.emit("remove", key);
1860
+ return removed;
1861
+ }
1862
+ async #collect() {
1863
+ const rows = [];
1864
+ for await (const row of this.#driver.scan(this.#name)) rows.push(row);
1865
+ return rows;
1866
+ }
1867
+ #prepare(row) {
1868
+ if (!(0, _orkestrel_contract.isRecord)(row)) throw new DatabaseError("VALIDATION", `Row for table '${this.#name}' is not a record`, { table: this.#name });
1869
+ const prepared = { ...row };
1870
+ if (prepared[this.#key] === void 0) {
1871
+ 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`, {
1872
+ table: this.#name,
1873
+ column: this.#key
1874
+ });
1875
+ prepared[this.#key] = this.#generate();
1876
+ }
1877
+ return prepared;
1878
+ }
1879
+ #validate(row) {
1880
+ const parsed = this.#contract.parse(row);
1881
+ if (parsed === void 0 || !(0, _orkestrel_contract.isRecord)(parsed)) throw new DatabaseError("VALIDATION", `Row failed the '${this.#name}' contract`, {
1882
+ table: this.#name,
1883
+ row
1884
+ });
1885
+ return parsed;
1886
+ }
1887
+ #resolveKey(row) {
1888
+ const key = extractKey(row, this.#key);
1889
+ if (key === void 0) throw new DatabaseError("VALIDATION", `Row has no usable key in column '${this.#key}'`, {
1890
+ table: this.#name,
1891
+ column: this.#key
1892
+ });
1893
+ return key;
1894
+ }
1895
+ #cast(row) {
1896
+ return row !== void 0 && this.#guard(row) ? row : void 0;
1897
+ }
1898
+ };
1899
+ //#endregion
1900
+ //#region src/core/Database.ts
1901
+ /**
1902
+ * A database — the ergonomic entry point over a {@link DriverInterface}.
1903
+ *
1904
+ * @remarks
1905
+ * Owns the driver and a `tables` shape map, connecting the driver lazily on first
1906
+ * use so a freshly created database is immediately usable. `table(name)` returns
1907
+ * a table typed by that table's shape `Infer`. `import` registers more tables and
1908
+ * returns a database re-typed with them over the **same** driver and storage;
1909
+ * `export` emits a portable {@link TableExport} per table. `transaction` snapshots
1910
+ * the driver, runs the scope, and rolls every table back if it throws — an
1911
+ * optimistic model that works uniformly across backends rather than reconciling
1912
+ * SQL's and IndexedDB's incompatible native transactions.
1913
+ *
1914
+ * @remarks
1915
+ * - **Versioned (optional).** When {@link DatabaseOptions.version} is set and the driver
1916
+ * implements both {@link DriverInterface.meta} and {@link DriverInterface.stamp},
1917
+ * `open()` reconciles the driver's persisted {@link DriverMeta} against the declared
1918
+ * version INSIDE the same lazy-connect chain, AFTER the `open` event fires — see
1919
+ * {@link DatabaseOptions.version} for the full reconciliation contract.
1920
+ * - **Observable (§13).** The owned {@link emitter} ({@link DatabaseEventMap}) carries the
1921
+ * connection + transaction lifecycle — `open` / `close` / `transaction` / `commit` /
1922
+ * `rollback` — for fire-and-forget observers, ALONGSIDE each table's per-row events. Every
1923
+ * event is emitted directly, strictly AFTER the relevant transition: `commit` only after
1924
+ * the scope succeeds, `rollback` only after every table is restored. The `rollback` emit
1925
+ * OBSERVES the propagated error — it never swallows it (the original throw propagates
1926
+ * exactly as before). The emitter isolates a listener throw and routes it to its `error`
1927
+ * handler (the `error` option), so observation can never reorder, throw into, or corrupt
1928
+ * the snapshot / commit / rollback flow.
1929
+ */
1930
+ var Database = class Database {
1931
+ #driver;
1932
+ #tables;
1933
+ #keys;
1934
+ #indexes;
1935
+ #name;
1936
+ #generate;
1937
+ #version;
1938
+ #emitter;
1939
+ #status = "idle";
1940
+ #ready;
1941
+ constructor(options) {
1942
+ this.#driver = options.driver;
1943
+ this.#tables = options.tables;
1944
+ this.#keys = options.keys ?? {};
1945
+ this.#indexes = options.indexes ?? {};
1946
+ this.#name = options.name ?? "database";
1947
+ this.#generate = options.key;
1948
+ this.#version = options.version;
1949
+ this.#emitter = new _orkestrel_emitter.Emitter({
1950
+ on: options.on,
1951
+ error: options.error
1952
+ });
1953
+ }
1954
+ get emitter() {
1955
+ return this.#emitter;
1956
+ }
1957
+ get name() {
1958
+ return this.#name;
1959
+ }
1960
+ get status() {
1961
+ return this.#status;
1962
+ }
1963
+ table(name) {
1964
+ if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
1965
+ return this.#build(name, this.#key(name), (0, _orkestrel_contract.createContract)((0, _orkestrel_contract.objectShape)(this.#tables[name])));
1966
+ }
1967
+ import(tables, keys) {
1968
+ return this.#spawn(tables, {
1969
+ ...this.#keys,
1970
+ ...keys
1971
+ });
1972
+ }
1973
+ export() {
1974
+ const result = {};
1975
+ for (const name of Object.keys(this.#tables)) {
1976
+ const columns = this.#tables[name];
1977
+ result[name] = {
1978
+ key: this.#key(name),
1979
+ columns,
1980
+ schema: (0, _orkestrel_contract.compileSchema)((0, _orkestrel_contract.objectShape)(columns))
1981
+ };
1982
+ }
1983
+ return result;
1984
+ }
1985
+ async open() {
1986
+ await this.#connect();
1987
+ }
1988
+ async close() {
1989
+ this.#status = "closed";
1990
+ this.#ready = void 0;
1991
+ await this.#driver.close();
1992
+ this.#emitter.emit("close");
1993
+ }
1994
+ /**
1995
+ * Run `scope` transactionally: commit its writes on success, roll every table
1996
+ * back if it throws.
1997
+ *
1998
+ * @remarks
1999
+ * When the driver implements the optional native {@link DriverInterface.transaction}
2000
+ * hook, that native `commit` / `rollback` handle drives the transaction; otherwise
2001
+ * the universal snapshot floor (`driver.snapshot()`) runs unchanged. Either path
2002
+ * emits the same `transaction` / `commit` / `rollback` lifecycle (AGENTS §13).
2003
+ * `options.signal` is checked ONCE at entry, before connecting or starting any
2004
+ * transactional work — an already-aborted signal throws `ABORTED` and neither the
2005
+ * native hook nor the snapshot floor is invoked. Nesting is unguarded and
2006
+ * unsupported exactly as before: this is a single-writer model, not reentrant.
2007
+ * On the native path, a `scope` throw rolls back via the native handle; a
2008
+ * native `commit` failure propagates as-is with no rollback attempt — the
2009
+ * engine owns transaction state after a failed COMMIT.
2010
+ *
2011
+ * @param scope - The transactional work to run
2012
+ * @param options - `{ signal }` to abort before the transaction starts
2013
+ * @returns The scope's resolved value
2014
+ * @throws An `ABORTED` {@link DatabaseError} when `options.signal` has already fired
2015
+ */
2016
+ async transaction(scope, options) {
2017
+ checkAbort(options?.signal);
2018
+ await this.#connect();
2019
+ const native = await this.#driver.transaction?.();
2020
+ if (native !== void 0) {
2021
+ this.#emitter.emit("transaction");
2022
+ let value;
2023
+ try {
2024
+ value = await scope();
2025
+ } catch (error) {
2026
+ await native.rollback();
2027
+ this.#emitter.emit("rollback", error);
2028
+ throw error;
2029
+ }
2030
+ await native.commit();
2031
+ this.#emitter.emit("commit");
2032
+ return value;
2033
+ }
2034
+ const rollback = await this.#driver.snapshot();
2035
+ this.#emitter.emit("transaction");
2036
+ try {
2037
+ const value = await scope();
2038
+ this.#emitter.emit("commit");
2039
+ return value;
2040
+ } catch (error) {
2041
+ await rollback();
2042
+ this.#emitter.emit("rollback", error);
2043
+ throw error;
2044
+ }
2045
+ }
2046
+ /**
2047
+ * Diff `deployed` against this database's declared schema and apply the
2048
+ * resulting plan through the driver's optional `migrate` hook.
2049
+ *
2050
+ * @param deployed - The schema currently deployed, as {@link TableSchema}s
2051
+ * @param options - `{ signal }` to abort before the migration starts
2052
+ * @returns The applied {@link Migration} plan
2053
+ * @throws A `MIGRATION` {@link DatabaseError} when the driver does not
2054
+ * implement `migrate`, or when a step references an unknown table
2055
+ * (propagated from the driver)
2056
+ * @throws An `ABORTED` {@link DatabaseError} when `options.signal` has
2057
+ * already fired at entry
2058
+ */
2059
+ async migrate(deployed, options) {
2060
+ checkAbort(options?.signal);
2061
+ await this.#connect();
2062
+ const plan = planMigration(deployed, this.#schema());
2063
+ if (this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, { name: this.#name });
2064
+ await this.#apply(plan);
2065
+ return plan;
2066
+ }
2067
+ #build(name, key, contract) {
2068
+ return new Table(() => this.#connect(), this.#driver, name, key, contract, this.#generate);
2069
+ }
2070
+ #spawn(tables, keys) {
2071
+ return new Database({
2072
+ driver: this.#driver,
2073
+ tables,
2074
+ keys,
2075
+ name: this.#name,
2076
+ ...this.#generate === void 0 ? {} : { key: this.#generate }
2077
+ });
2078
+ }
2079
+ #key(name) {
2080
+ return this.#keys[name] ?? "id";
2081
+ }
2082
+ #schema() {
2083
+ return Object.keys(this.#tables).map((name) => {
2084
+ const columns = this.#tables[name];
2085
+ return {
2086
+ name,
2087
+ primary: this.#key(name),
2088
+ columns: Object.keys(columns).map((column) => {
2089
+ const shape = columns[column];
2090
+ return {
2091
+ name: column,
2092
+ type: shapeToColumnType(shape),
2093
+ nullable: shape.type === "optional" || shape.type === "nullable"
2094
+ };
2095
+ }),
2096
+ indexes: this.#indexes[name] ?? []
2097
+ };
2098
+ });
2099
+ }
2100
+ #connect() {
2101
+ if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
2102
+ if (this.#ready === void 0) this.#ready = this.#driver.open(this.#schema()).then(async () => {
2103
+ if (this.#status === "idle") this.#status = "open";
2104
+ this.#emitter.emit("open");
2105
+ await this.#reconcile();
2106
+ });
2107
+ return this.#ready;
2108
+ }
2109
+ async #reconcile() {
2110
+ if (this.#version === void 0 || this.#driver.meta === void 0) return;
2111
+ const declared = this.#schema();
2112
+ const meta = await this.#driver.meta();
2113
+ if (meta === void 0) {
2114
+ await this.#stamp();
2115
+ return;
2116
+ }
2117
+ if (meta.version > this.#version) throw new DatabaseError("MIGRATION", `Database '${this.#name}' store version ${meta.version} is newer than declared version ${this.#version}`, {
2118
+ name: this.#name,
2119
+ stored: meta.version,
2120
+ declared: this.#version
2121
+ });
2122
+ if (meta.version < this.#version) {
2123
+ const plan = planMigration(meta.schema, declared, meta.version, this.#version);
2124
+ if (plan.steps.length > 0 && this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, {
2125
+ name: this.#name,
2126
+ stored: meta.version,
2127
+ declared: this.#version
2128
+ });
2129
+ await this.#apply(plan);
2130
+ }
2131
+ }
2132
+ async #apply(plan) {
2133
+ const native = await this.#driver.transaction?.();
2134
+ if (native !== void 0) {
2135
+ try {
2136
+ await this.#driver.migrate?.(plan);
2137
+ await this.#stamp();
2138
+ } catch (error) {
2139
+ await native.rollback();
2140
+ throw error;
2141
+ }
2142
+ await native.commit();
2143
+ this.#emitter.emit("migrate", plan);
2144
+ return;
2145
+ }
2146
+ await this.#driver.migrate?.(plan);
2147
+ await this.#stamp();
2148
+ this.#emitter.emit("migrate", plan);
2149
+ }
2150
+ async #stamp() {
2151
+ if (this.#version === void 0 || this.#driver.stamp === void 0) return;
2152
+ const meta = {
2153
+ version: this.#version,
2154
+ schema: this.#schema()
2155
+ };
2156
+ await this.#driver.stamp(meta);
2157
+ }
2158
+ };
2159
+ //#endregion
2160
+ //#region src/core/drivers/MemoryDriver.ts
2161
+ /**
2162
+ * The reference {@link DriverInterface} — nested maps, no I/O.
2163
+ *
2164
+ * @remarks
2165
+ * The in-between made concrete: it runs identically in a browser or on a server,
2166
+ * so it is the storage behind tests, ephemeral caches, and any code that wants
2167
+ * the database API without a persistent backend. Rows are DEEP-copied (via
2168
+ * `structuredClone`) in and out — at `write`, `read`, `scan`, `stream`, and both
2169
+ * snapshot capture and restore — so a caller mutating a nested field of an input
2170
+ * row, a returned row, or a row mutated in place between snapshot and rollback
2171
+ * can never perturb stored state (AGENTS §11); a shallow `{ ...row }` spread
2172
+ * would still share nested object/array references. `snapshot`
2173
+ * clones every table to give transactions an exact rollback point. `scan` and
2174
+ * `keys` yield in key order — sorted by the core {@link compareValues} total
2175
+ * order, the same contract the SQLite (`ORDER BY`) and IndexedDB (key-ordered
2176
+ * reads) backends honor, so an unordered read agrees across every backend rather
2177
+ * than leaking Map insertion order. A persistent backend (IndexedDB, SQLite)
2178
+ * implements the same nine methods over real storage.
2179
+ */
2180
+ var MemoryDriver = class {
2181
+ #tables = /* @__PURE__ */ new Map();
2182
+ #meta;
2183
+ async open(schema) {
2184
+ for (const table of schema) if (!this.#tables.has(table.name)) this.#tables.set(table.name, /* @__PURE__ */ new Map());
2185
+ }
2186
+ async close() {}
2187
+ async read(table, key) {
2188
+ const row = this.#store(table).get(key);
2189
+ return row === void 0 ? void 0 : structuredClone(row);
2190
+ }
2191
+ async write(table, key, row) {
2192
+ this.#store(table).set(key, structuredClone(row));
2193
+ }
2194
+ async delete(table, key) {
2195
+ return this.#store(table).delete(key);
2196
+ }
2197
+ async keys(table) {
2198
+ return this.#ordered(table);
2199
+ }
2200
+ async *scan(table) {
2201
+ const store = this.#store(table);
2202
+ for (const key of this.#ordered(table)) {
2203
+ const row = store.get(key);
2204
+ if (row !== void 0) yield structuredClone(row);
2205
+ }
2206
+ }
2207
+ /**
2208
+ * Natively filtered lazy iteration — the {@link DriverInterface.stream} hook.
2209
+ *
2210
+ * @remarks
2211
+ * Iterates the table's keys in the same key order `scan` and `keys` yield
2212
+ * (sorted by {@link compareValues}), testing each row against
2213
+ * `criteria.conditions` (via {@link matchesCriteria}) before counting it
2214
+ * toward `offset` / `limit`. Both are applied lazily as matches are found —
2215
+ * `offset` matches are skipped without being yielded, and iteration stops the
2216
+ * instant `limit` yields have been produced, so a large table is never fully
2217
+ * walked for a small page. `criteria.order` is IGNORED (the same contract as
2218
+ * `TableInterface.scan` and `QueryInterface.stream`): streaming yields key
2219
+ * order, sorted output is `records()`'s job. Rows yield copy-out (AGENTS
2220
+ * §11), and an unknown table mirrors `scan`'s empty-yield behavior.
2221
+ *
2222
+ * @param table - The table to stream
2223
+ * @param criteria - The filter / offset / limit to apply lazily
2224
+ *
2225
+ * @example
2226
+ * ```ts
2227
+ * for await (const row of driver.stream('users', { conditions, limit: 10 })) {
2228
+ * // one matched row at a time, in key order
2229
+ * }
2230
+ * ```
2231
+ */
2232
+ async *stream(table, criteria) {
2233
+ const store = this.#store(table);
2234
+ const conditions = criteria.conditions;
2235
+ const offset = criteria.offset ?? 0;
2236
+ const limit = criteria.limit;
2237
+ let skipped = 0;
2238
+ let yielded = 0;
2239
+ for (const key of this.#ordered(table)) {
2240
+ if (limit !== void 0 && yielded >= limit) return;
2241
+ const row = store.get(key);
2242
+ if (row === void 0) continue;
2243
+ if (conditions !== void 0 && conditions.length > 0 && !matchesCriteria(row, conditions)) continue;
2244
+ if (skipped < offset) {
2245
+ skipped += 1;
2246
+ continue;
2247
+ }
2248
+ yield structuredClone(row);
2249
+ yielded += 1;
2250
+ }
2251
+ }
2252
+ async clear(table) {
2253
+ this.#store(table).clear();
2254
+ }
2255
+ /**
2256
+ * Capture the current state and return a thunk that rolls back to it.
2257
+ *
2258
+ * @remarks
2259
+ * `tables` omitted clones and restores the WHOLE store, byte-identical to the
2260
+ * prior whole-store behavior. `tables` provided clones ONLY the named tables,
2261
+ * and the returned thunk restores ONLY those — every other table keeps
2262
+ * whatever it was mutated to after the snapshot was taken.
2263
+ *
2264
+ * @param tables - The table names to scope the snapshot to; omitted captures every table
2265
+ * @returns A thunk that restores the captured tables
2266
+ */
2267
+ async snapshot(tables) {
2268
+ if (tables === void 0) {
2269
+ const copy = /* @__PURE__ */ new Map();
2270
+ for (const [name, store] of this.#tables) {
2271
+ const cloned = /* @__PURE__ */ new Map();
2272
+ for (const [key, row] of store) cloned.set(key, structuredClone(row));
2273
+ copy.set(name, cloned);
2274
+ }
2275
+ return async () => {
2276
+ this.#tables.clear();
2277
+ for (const [name, store] of copy) {
2278
+ const restored = /* @__PURE__ */ new Map();
2279
+ for (const [key, row] of store) restored.set(key, structuredClone(row));
2280
+ this.#tables.set(name, restored);
2281
+ }
2282
+ };
2283
+ }
2284
+ const copy = /* @__PURE__ */ new Map();
2285
+ for (const name of tables) {
2286
+ const store = this.#tables.get(name);
2287
+ if (store === void 0) continue;
2288
+ const cloned = /* @__PURE__ */ new Map();
2289
+ for (const [key, row] of store) cloned.set(key, structuredClone(row));
2290
+ copy.set(name, cloned);
2291
+ }
2292
+ return async () => {
2293
+ for (const [name, store] of copy) {
2294
+ const restored = /* @__PURE__ */ new Map();
2295
+ for (const [key, row] of store) restored.set(key, structuredClone(row));
2296
+ this.#tables.set(name, restored);
2297
+ }
2298
+ };
2299
+ }
2300
+ /**
2301
+ * Return the persisted {@link DriverMeta}, or `undefined` when the store has
2302
+ * never been stamped.
2303
+ *
2304
+ * @remarks
2305
+ * In-process only — the metadata lives in this instance's memory, exactly
2306
+ * like the rest of this driver's storage. A driver-conformance-valid
2307
+ * implementation of the optional `meta` / `stamp` pair.
2308
+ *
2309
+ * @returns The last-stamped {@link DriverMeta}, or `undefined`
2310
+ */
2311
+ async meta() {
2312
+ return this.#meta;
2313
+ }
2314
+ /**
2315
+ * Persist `meta` verbatim for a later `meta()` to return.
2316
+ *
2317
+ * @param meta - The {@link DriverMeta} to persist
2318
+ */
2319
+ async stamp(meta) {
2320
+ this.#meta = meta;
2321
+ }
2322
+ /**
2323
+ * Apply a {@link Migration} plan's steps against the in-memory store.
2324
+ *
2325
+ * @remarks
2326
+ * A multi-step plan applies its steps sequentially and is NOT atomic — a
2327
+ * failure partway through a plan leaves the earlier steps already applied.
2328
+ *
2329
+ * @param plan - The migration plan to apply
2330
+ */
2331
+ async migrate(plan) {
2332
+ for (const step of plan.steps) switch (step.operation) {
2333
+ case "table.add":
2334
+ if (!this.#tables.has(step.table.name)) this.#tables.set(step.table.name, /* @__PURE__ */ new Map());
2335
+ break;
2336
+ case "table.remove":
2337
+ this.#require(step.table);
2338
+ this.#tables.delete(step.table);
2339
+ break;
2340
+ case "column.add":
2341
+ case "column.remove": {
2342
+ const store = this.#require(step.table);
2343
+ const rows = [...store.entries()];
2344
+ const migrated = migrateRows(rows.map(([, row]) => row), [step]);
2345
+ rows.forEach(([key], index) => store.set(key, migrated[index]));
2346
+ break;
2347
+ }
2348
+ case "index.add":
2349
+ case "index.remove":
2350
+ this.#require(step.table);
2351
+ break;
2352
+ }
2353
+ }
2354
+ #ordered(table) {
2355
+ return [...this.#store(table).keys()].sort(compareValues);
2356
+ }
2357
+ #require(table) {
2358
+ const store = this.#tables.get(table);
2359
+ if (store === void 0) throw new DatabaseError("MIGRATION", `migrate: unknown table '${table}'`, { table });
2360
+ return store;
2361
+ }
2362
+ #store(table) {
2363
+ let store = this.#tables.get(table);
2364
+ if (store === void 0) {
2365
+ store = /* @__PURE__ */ new Map();
2366
+ this.#tables.set(table, store);
2367
+ }
2368
+ return store;
2369
+ }
2370
+ };
2371
+ //#endregion
2372
+ //#region src/core/factories.ts
2373
+ /**
2374
+ * Create a database over a driver and a declared `tables` schema.
2375
+ *
2376
+ * @remarks
2377
+ * `tables` maps each name to its columns (a `column → shape` map); the database
2378
+ * wraps each in an `objectShape`, so you never write `objectShape` at the table
2379
+ * level. The `const` type parameter captures the literal names and columns, so
2380
+ * `db.table('users')` is checked against the schema and typed by `Infer` of its
2381
+ * columns — no annotations. Name a non-`id` primary-key column per table via the
2382
+ * optional `keys` map.
2383
+ *
2384
+ * @param options - The driver, the `tables` column map, optional `keys`, and an
2385
+ * optional `name`
2386
+ * @returns A typed {@link DatabaseInterface}
2387
+ *
2388
+ * @example
2389
+ * ```ts
2390
+ * import { createDatabase, createMemoryDriver } from '@orkestrel/database'
2391
+ * import { integerShape, stringShape } from '@orkestrel/contract'
2392
+ *
2393
+ * const db = createDatabase({
2394
+ * driver: createMemoryDriver(),
2395
+ * tables: {
2396
+ * users: { id: stringShape(), age: integerShape() },
2397
+ * posts: { slug: stringShape(), title: stringShape() },
2398
+ * },
2399
+ * keys: { posts: 'slug' },
2400
+ * })
2401
+ * await db.table('users').set({ id: 'u1', age: 36 }) // typed; coerced + validated
2402
+ * ```
2403
+ */
2404
+ function createDatabase(options) {
2405
+ return new Database(options);
2406
+ }
2407
+ /**
2408
+ * Create the in-memory reference {@link DriverInterface}.
2409
+ *
2410
+ * @remarks
2411
+ * Backed by nested maps with no I/O — the same driver runs in a browser or on a
2412
+ * server, making it the natural choice for tests and ephemeral storage.
2413
+ *
2414
+ * @returns A fresh in-memory driver
2415
+ */
2416
+ function createMemoryDriver() {
2417
+ return new MemoryDriver();
2418
+ }
2419
+ //#endregion
2420
+ exports.Clause = Clause;
2421
+ exports.Cursor = Cursor;
2422
+ exports.DEFAULT_PRIMARY = DEFAULT_PRIMARY;
2423
+ exports.Database = Database;
2424
+ exports.DatabaseError = DatabaseError;
2425
+ exports.MAX_PATTERN_LENGTH = MAX_PATTERN_LENGTH;
2426
+ exports.MemoryDriver = MemoryDriver;
2427
+ exports.Query = Query;
2428
+ exports.Table = Table;
2429
+ exports.applyCriteria = applyCriteria;
2430
+ exports.auditDriver = auditDriver;
2431
+ exports.checkAbort = checkAbort;
2432
+ exports.compareValues = compareValues;
2433
+ exports.computeAggregate = computeAggregate;
2434
+ exports.conformDriver = conformDriver;
2435
+ exports.createDatabase = createDatabase;
2436
+ exports.createMemoryDriver = createMemoryDriver;
2437
+ exports.deepEqual = deepEqual;
2438
+ exports.driverFindings = driverFindings;
2439
+ exports.extractKey = extractKey;
2440
+ exports.filterRows = filterRows;
2441
+ exports.globMatch = globMatch;
2442
+ exports.isDatabaseError = isDatabaseError;
2443
+ exports.isDriverMeta = isDriverMeta;
2444
+ exports.likeMatch = likeMatch;
2445
+ exports.matchesCondition = matchesCondition;
2446
+ exports.matchesCriteria = matchesCriteria;
2447
+ exports.migrateRows = migrateRows;
2448
+ exports.planMigration = planMigration;
2449
+ exports.shapeToColumnType = shapeToColumnType;
2450
+ exports.sortRows = sortRows;
2451
+ exports.wildcardMatch = wildcardMatch;
2452
+
2453
+ //# sourceMappingURL=index.cjs.map