@orkestrel/database 0.0.1 → 0.0.3

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