@orkestrel/database 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1663 @@
1
+ import { isBoolean, isFiniteNumber, isRecord, isString } from "@orkestrel/contract";
2
+ import { randomUUID } from "node:crypto";
3
+ import { DatabaseError, MemoryDriver, applyCriteria, computeAggregate, extractKey, filterRows, isDriverMeta, matchesCriteria } from "../core/index.js";
4
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
+ import { dirname } from "node:path";
6
+ import { createSQLiteDatabase, isSQLiteError } from "@orkestrel/sqlite";
7
+ //#region src/server/helpers.ts
8
+ /**
9
+ * Generate a fresh unique key — a v4 UUID string, backed by `node:crypto`.
10
+ *
11
+ * @remarks
12
+ * Supply this as {@link import('@orkestrel/database').DatabaseOptions.key} so a table mints
13
+ * a key when a written row lacks its primary-key value. Strings work as keys on
14
+ * every backend; supply your own key values directly to use numeric keys instead.
15
+ *
16
+ * @returns A new UUID string
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * const db = createDatabase({ driver, tables, key: generateKey })
21
+ * ```
22
+ */
23
+ function generateKey() {
24
+ return randomUUID();
25
+ }
26
+ /**
27
+ * The declared {@link ColumnType}s whose SQL EQUALITY comparisons (`equals` /
28
+ * `not` / `any` / `none`) and `starts` / `ends` compiles are provably
29
+ * engine-exact under declared-type trust — `text` / `integer` / `real` /
30
+ * `boolean`; a `json` or `blob` column always refines instead.
31
+ *
32
+ * @remarks
33
+ * This set governs equality and prefix/suffix matching only. RANGE
34
+ * comparisons (`above` / `below` / `from` / `to` / `between`) and `ORDER BY`
35
+ * are exact for `integer` / `real` / `boolean` but NOT for `text`: compiled
36
+ * SQL orders/ranges under SQLite's default BINARY collation, which compares
37
+ * TEXT byte-for-byte as UTF-8 — equivalent to Unicode CODE-POINT order —
38
+ * while the core engine's `compareValues` orders JS strings with `<`, which
39
+ * compares UTF-16 CODE-UNIT order. The two orders diverge for supplementary-
40
+ * plane characters (code points ≥ U+10000, e.g. many emoji): a lead surrogate
41
+ * (`\uD800`–`\uDBFF`) sorts BELOW ``–`￿` in code-unit order, while
42
+ * its code point sorts ABOVE them. So `isExactCondition`'s range family and
43
+ * `isExactOrder` exclude `text`, refining through the core engine instead. A
44
+ * future opt-in "trusted collation" mode (the caller vouches the column's
45
+ * values are BMP-only, or a custom SQLite collation matching `compareValues`
46
+ * is registered) could restore native text ranges/ordering.
47
+ */
48
+ var EXACT_COLUMN_TYPES = [
49
+ "text",
50
+ "integer",
51
+ "real",
52
+ "boolean"
53
+ ];
54
+ /**
55
+ * The declared {@link ColumnType}s whose SQL RANGE comparisons
56
+ * (`above` / `below` / `from` / `to` / `between`) and `ORDER BY` compiles are
57
+ * provably engine-exact — `integer` / `real` / `boolean` only. `text` is
58
+ * excluded: see {@link EXACT_COLUMN_TYPES}'s remarks for the BINARY-collation
59
+ * (code-point) vs. JS `<` (code-unit) divergence on supplementary-plane
60
+ * characters.
61
+ */
62
+ var EXACT_RANGE_COLUMN_TYPES = [
63
+ "integer",
64
+ "real",
65
+ "boolean"
66
+ ];
67
+ /**
68
+ * Whether a value's runtime type matches a column's declared exact type —
69
+ * the operand side of the declared-type-trust proof.
70
+ *
71
+ * @remarks
72
+ * `text` ↔ string, `integer` / `real` ↔ FINITE number (`NaN` / `±Infinity`
73
+ * fail), `boolean` ↔ boolean. Backs {@link isExactCondition}'s operand checks.
74
+ *
75
+ * @param value - The condition operand to test
76
+ * @param type - The column's declared portable type
77
+ * @returns `true` when the operand's runtime type matches the declared type
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * matchesDeclaredType('Ada', 'text') // true
82
+ * matchesDeclaredType(Number.NaN, 'integer') // false — only finite numbers
83
+ * ```
84
+ */
85
+ function matchesDeclaredType(value, type) {
86
+ if (type === "text") return isString(value);
87
+ if (type === "boolean") return isBoolean(value);
88
+ return isFiniteNumber(value);
89
+ }
90
+ /**
91
+ * Whether one {@link Condition} compiles to SQL that is PROVABLY identical to
92
+ * the core engine's `matchesCondition` for every value its column's declared
93
+ * type can store.
94
+ *
95
+ * @remarks
96
+ * `false` for a nested `FieldPath` (an array), a column absent from `schema`,
97
+ * or a column whose declared type is not `text` / `integer` / `real` /
98
+ * `boolean` (a `json` / `blob` column) — EXCEPT `absent` / `present`, which
99
+ * compile to `IS NULL` / `IS NOT NULL` and match `decodeRow`'s "a stored NULL
100
+ * decodes to `undefined`" rule for every column type, so they are exact
101
+ * regardless of declared type. `equals` / `not` require a operand matching the
102
+ * column's declared type (a `null` / `undefined` operand is never exact here —
103
+ * `encodeRow` stores both an explicit `null` and an absent field as SQL NULL,
104
+ * so native `IS NULL` semantics cannot match the engine's `deepEqual`-over-
105
+ * decoded-rows truth). `above` / `below` / `from` / `to` / `between` are exact
106
+ * ONLY for a declared type in {@link EXACT_RANGE_COLUMN_TYPES} (`integer` /
107
+ * `real` / `boolean`) — a `text` column's range conditions REFINE, because
108
+ * SQLite's default BINARY collation orders TEXT by Unicode CODE POINT while
109
+ * the core engine's `compareValues` orders JS strings by UTF-16 CODE UNIT,
110
+ * and the two diverge for supplementary-plane characters (see
111
+ * {@link EXACT_COLUMN_TYPES}'s remarks for the full rationale).
112
+ * `any` / `none` require a NON-EMPTY list where every element matches (an empty
113
+ * list is exact under neither: the engine's `any([])` matches nothing while
114
+ * `none([])` matches everything, and SQL `IN ()` is a syntax error) — these
115
+ * stay exact on `text` (byte equality is collation-independent and engine-
116
+ * identical). `starts` / `ends` are exact only on a `text` column with a
117
+ * string operand (case-sensitive `substr` compile, see {@link fragment}) —
118
+ * likewise collation-independent. `like` / `glob` are NEVER exact — SQLite
119
+ * `LIKE` folds case ASCII-only against the engine's Unicode fold, and `GLOB`
120
+ * has character classes the engine treats literally.
121
+ *
122
+ * @param condition - The condition to test
123
+ * @param schema - The table's schema
124
+ * @returns Whether `condition` is exact
125
+ */
126
+ function isExactCondition(condition, schema) {
127
+ if (!isString(condition.column)) return false;
128
+ const column = schema.columns.find((candidate) => candidate.name === condition.column);
129
+ if (column === void 0) return false;
130
+ if (condition.operator === "absent" || condition.operator === "present") return true;
131
+ if (!EXACT_COLUMN_TYPES.some((type) => type === column.type)) return false;
132
+ const first = condition.values[0];
133
+ const second = condition.values[1];
134
+ switch (condition.operator) {
135
+ case "equals":
136
+ case "not": return matchesDeclaredType(first, column.type);
137
+ case "above":
138
+ case "below":
139
+ case "from":
140
+ case "to": return EXACT_RANGE_COLUMN_TYPES.some((type) => type === column.type) && matchesDeclaredType(first, column.type);
141
+ case "between": return EXACT_RANGE_COLUMN_TYPES.some((type) => type === column.type) && matchesDeclaredType(first, column.type) && matchesDeclaredType(second, column.type);
142
+ case "any":
143
+ case "none": return condition.values.length > 0 && condition.values.every((value) => matchesDeclaredType(value, column.type));
144
+ case "starts":
145
+ case "ends": return column.type === "text" && isString(first);
146
+ case "like":
147
+ case "glob": return false;
148
+ }
149
+ }
150
+ /**
151
+ * Whether one {@link Order} term's column compiles to an `ORDER BY` that
152
+ * matches the engine's {@link import('@src/core').sortRows} exactly.
153
+ *
154
+ * @remarks
155
+ * `false` for a nested `FieldPath`, a column absent from `schema`, or a
156
+ * declared type outside {@link EXACT_RANGE_COLUMN_TYPES} (`integer` / `real` /
157
+ * `boolean`). `text` is NOT exact here: SQLite's default BINARY collation
158
+ * orders TEXT by Unicode code point while the core engine's `compareValues`
159
+ * orders JS strings by UTF-16 code unit, and the two diverge for
160
+ * supplementary-plane characters (see {@link EXACT_COLUMN_TYPES}'s remarks) —
161
+ * a `text` order term REFINES through the core engine instead.
162
+ *
163
+ * @param order - The order term to test
164
+ * @param schema - The table's schema
165
+ * @returns Whether `order` is exact
166
+ */
167
+ function isExactOrder(order, schema) {
168
+ if (!isString(order.column)) return false;
169
+ const column = schema.columns.find((candidate) => candidate.name === order.column);
170
+ if (column === void 0) return false;
171
+ return EXACT_RANGE_COLUMN_TYPES.some((type) => type === column.type);
172
+ }
173
+ /**
174
+ * Whether a whole {@link Criteria} is exact — every condition and every order
175
+ * term is exact. `limit` / `offset` never affect exactness (SQL `LIMIT` /
176
+ * `OFFSET` are always engine-identical).
177
+ *
178
+ * @param criteria - The criteria to test
179
+ * @param schema - The table's schema
180
+ * @returns Whether every part of `criteria` is exact
181
+ */
182
+ function isExactCriteria(criteria, schema) {
183
+ const conditions = criteria.conditions ?? [];
184
+ const order = criteria.order ?? [];
185
+ return conditions.every((condition) => isExactCondition(condition, schema)) && order.every((term) => isExactOrder(term, schema));
186
+ }
187
+ /**
188
+ * Map a portable {@link ColumnType} to its SQLite column type.
189
+ *
190
+ * @remarks
191
+ * `text` / `json` → `TEXT` (JSON is stored as text and read back with
192
+ * `json_extract` for nested-field queries); `integer` / `boolean` → `INTEGER`
193
+ * (a boolean stores `1` / `0`); `real` → `REAL`; `blob` → `BLOB`. No `NOT NULL`
194
+ * is ever emitted — the contract validates required-ness; the database is just
195
+ * storage (AGENTS §14, the typed layer above imposes the shape).
196
+ *
197
+ * @param type - The portable column type
198
+ * @returns The SQLite column type keyword
199
+ *
200
+ * @example
201
+ * ```ts
202
+ * columnSQL('integer') // 'INTEGER'
203
+ * columnSQL('json') // 'TEXT'
204
+ * ```
205
+ */
206
+ function columnSQL(type) {
207
+ switch (type) {
208
+ case "text":
209
+ case "json": return "TEXT";
210
+ case "integer":
211
+ case "boolean": return "INTEGER";
212
+ case "real": return "REAL";
213
+ case "blob": return "BLOB";
214
+ }
215
+ }
216
+ /**
217
+ * Quote a SQL identifier (a table or column name) so any characters are literal.
218
+ *
219
+ * @remarks
220
+ * Wraps the name in double quotes and doubles any embedded quote — the standard
221
+ * SQL identifier-quoting that lets a column named `order` or `from` be referenced
222
+ * safely. Identifiers cannot be bound as parameters, so they are quoted instead.
223
+ *
224
+ * @param identifier - The raw identifier
225
+ * @returns The double-quoted identifier
226
+ *
227
+ * @example
228
+ * ```ts
229
+ * quote('order') // '"order"'
230
+ * ```
231
+ */
232
+ function quote(identifier) {
233
+ return "\"" + identifier.replaceAll("\"", "\"\"") + "\"";
234
+ }
235
+ /**
236
+ * Compile a {@link FieldPath} to the SQL expression that reads it.
237
+ *
238
+ * @remarks
239
+ * A single string is ONE column — `quote(path)`. An array descends a JSON column:
240
+ * the first element is the (quoted) column, the rest a `json_extract` path
241
+ * (`json_extract("payload", '$.user.id')`), matching the guide's nested-field
242
+ * examples (simple identifier keys). The string's value is never split on `.`
243
+ * (AGENTS — `FieldPath` semantics): a dotted string is one column literally.
244
+ *
245
+ * @param path - The field path (a column, or a column + nested keys)
246
+ * @returns The SQL expression selecting the value
247
+ *
248
+ * @example
249
+ * ```ts
250
+ * fieldColumn('payload') // '"payload"'
251
+ * fieldColumn(['payload', 'user', 'id']) // 'json_extract("payload", \'$.user.id\')'
252
+ * ```
253
+ */
254
+ function fieldColumn(path) {
255
+ if (isString(path)) return quote(path);
256
+ const rest = path.slice(1).map((key) => "." + key.replaceAll("'", "''")).join("");
257
+ return "json_extract(" + quote(path[0]) + ", '$" + rest + "')";
258
+ }
259
+ /**
260
+ * Compile an {@link AggregateFunction} over a {@link FieldPath} to its SQL
261
+ * aggregate expression — the SELECT body the SQLite driver's native `aggregate`
262
+ * runs.
263
+ *
264
+ * @remarks
265
+ * `count` → `COUNT(*)` (counting all matched ROWS, not non-null column values —
266
+ * mirroring the engine's `computeAggregate('count')`, which is `rows.length`); the
267
+ * numeric aggregates wrap the column's read expression (a flat column, or a nested
268
+ * `json_extract` path) in `SUM` / `AVG` / `MIN` / `MAX`. Over zero matched rows
269
+ * `COUNT(*)` is `0` and the numeric aggregates are SQL `NULL` (→ `undefined`),
270
+ * matching the engine.
271
+ *
272
+ * @param operation - The aggregate to compute
273
+ * @param column - The column (or nested path) to aggregate
274
+ * @returns The SQL aggregate expression
275
+ *
276
+ * @example
277
+ * ```ts
278
+ * aggregateSQL('count', 'age') // 'COUNT(*)'
279
+ * aggregateSQL('sum', 'age') // 'SUM("age")'
280
+ * aggregateSQL('average', ['payload', 'score']) // 'AVG(json_extract("payload", \'$.score\'))'
281
+ * ```
282
+ */
283
+ function aggregateSQL(operation, column) {
284
+ switch (operation) {
285
+ case "count": return "COUNT(*)";
286
+ case "sum": return "SUM(" + fieldColumn(column) + ")";
287
+ case "average": return "AVG(" + fieldColumn(column) + ")";
288
+ case "minimum": return "MIN(" + fieldColumn(column) + ")";
289
+ case "maximum": return "MAX(" + fieldColumn(column) + ")";
290
+ }
291
+ }
292
+ /**
293
+ * Encode a JS value to its stored {@link SQLiteValue} for a column's type.
294
+ *
295
+ * @remarks
296
+ * The forward half of the bridge, total (AGENTS §14): a value that does not fit
297
+ * its column's storage type encodes to `null` rather than throwing. A `boolean`
298
+ * column stores `1` / `0` (and `null` / `undefined` → `null`); a `json` column
299
+ * stores `JSON.stringify` (or `null` for `null` / `undefined`); `integer` /
300
+ * `real` keep a `number` / `bigint`, else `null`; `text` keeps a `string`, else
301
+ * `null`; `blob` keeps a `Uint8Array`, else `null`. Narrowed with `typeof` /
302
+ * `instanceof`, never `as`.
303
+ *
304
+ * @param value - The JS value to store
305
+ * @param type - The column's portable storage type
306
+ * @returns The value SQLite stores
307
+ *
308
+ * @example
309
+ * ```ts
310
+ * encodeValue(true, 'boolean') // 1
311
+ * encodeValue({ a: 1 }, 'json') // '{"a":1}'
312
+ * ```
313
+ */
314
+ function encodeValue(value, type) {
315
+ switch (type) {
316
+ case "boolean": return value === void 0 || value === null ? null : value === true ? 1 : 0;
317
+ case "json": return value === void 0 || value === null ? null : JSON.stringify(value);
318
+ case "integer":
319
+ case "real": return typeof value === "number" || typeof value === "bigint" ? value : null;
320
+ case "text": return typeof value === "string" ? value : null;
321
+ case "blob": return value instanceof Uint8Array ? value : null;
322
+ }
323
+ }
324
+ /**
325
+ * Decode a stored {@link SQLiteValue} back to its JS value for a column's type —
326
+ * the exact inverse of {@link encodeValue}.
327
+ *
328
+ * @remarks
329
+ * A `boolean` column reads `1` / `0` back to `true` / `false` (a stored `null`
330
+ * → `undefined`); a `json` column `JSON.parse`s a string (anything else →
331
+ * `undefined`); every other type passes the value through, mapping a stored
332
+ * `NULL` to `undefined`. NULL decodes to `undefined` so {@link decodeRow} can
333
+ * omit absent columns.
334
+ *
335
+ * @param value - The stored SQLite value
336
+ * @param type - The column's portable storage type
337
+ * @returns The decoded JS value (`undefined` for a stored `NULL`)
338
+ *
339
+ * @example
340
+ * ```ts
341
+ * decodeValue(1, 'boolean') // true
342
+ * decodeValue('{"a":1}', 'json') // { a: 1 }
343
+ * ```
344
+ */
345
+ function decodeValue(value, type) {
346
+ switch (type) {
347
+ case "boolean": return value === null ? void 0 : value !== 0;
348
+ case "json": return typeof value === "string" ? JSON.parse(value) : void 0;
349
+ default: return value === null ? void 0 : value;
350
+ }
351
+ }
352
+ /**
353
+ * Encode a whole {@link Row} to a {@link SQLiteRow} by its table's schema.
354
+ *
355
+ * @remarks
356
+ * Encodes each declared column's value with {@link encodeValue}; columns the row
357
+ * does not carry encode from `undefined` (so they store `null`). Only the
358
+ * schema's columns appear in the result — an extra row key is dropped.
359
+ *
360
+ * @param row - The JS row to store
361
+ * @param schema - The table's schema
362
+ * @returns The storable SQLite row
363
+ *
364
+ * @example
365
+ * ```ts
366
+ * encodeRow({ id: 'u1', active: true }, schema) // { id: 'u1', active: 1, ... }
367
+ * ```
368
+ */
369
+ function encodeRow(row, schema) {
370
+ const result = {};
371
+ for (const column of schema.columns) result[column.name] = encodeValue(row[column.name], column.type);
372
+ return result;
373
+ }
374
+ /**
375
+ * Decode a stored {@link SQLiteRow} back to a {@link Row} by its table's schema.
376
+ *
377
+ * @remarks
378
+ * Decodes each declared column with {@link decodeValue} and **omits** any column
379
+ * whose decoded value is `undefined` — so an absent / `NULL` optional column does
380
+ * not surface as `{ bio: undefined }`, matching how the contract's optional
381
+ * columns expect absence. A known, documented edge: a non-optional `nullableShape`
382
+ * column storing `null` round-trips to absent (a `null` cell decodes to
383
+ * `undefined`, and an `undefined` value is omitted).
384
+ *
385
+ * @param row - The stored SQLite row
386
+ * @param schema - The table's schema
387
+ * @returns The decoded JS row (absent columns omitted)
388
+ *
389
+ * @example
390
+ * ```ts
391
+ * decodeRow({ id: 'u1', active: 1, bio: null }, schema) // { id: 'u1', active: true }
392
+ * ```
393
+ */
394
+ function decodeRow(row, schema) {
395
+ const result = {};
396
+ for (const column of schema.columns) {
397
+ const decoded = decodeValue(row[column.name], column.type);
398
+ if (decoded !== void 0) result[column.name] = decoded;
399
+ }
400
+ return result;
401
+ }
402
+ /**
403
+ * Project a {@link TableSchema} to the `CREATE TABLE IF NOT EXISTS` statement a
404
+ * SQLite driver's `open` issues for it.
405
+ *
406
+ * @remarks
407
+ * Each column compiles to `<quoted name> <columnSQL(type)>`; the statement ends
408
+ * with `PRIMARY KEY (<quoted primary>)`. No `NOT NULL` is emitted — the contract
409
+ * validates required-ness, the database is just storage (AGENTS §14).
410
+ *
411
+ * @param schema - The table's schema
412
+ * @returns The `CREATE TABLE IF NOT EXISTS …` statement
413
+ *
414
+ * @example
415
+ * ```ts
416
+ * schemaToTable(schema)
417
+ * // 'CREATE TABLE IF NOT EXISTS "users" ("id" TEXT, "age" INTEGER, PRIMARY KEY ("id"))'
418
+ * ```
419
+ */
420
+ function schemaToTable(schema) {
421
+ const columns = schema.columns.map((column) => quote(column.name) + " " + columnSQL(column.type));
422
+ return "CREATE TABLE IF NOT EXISTS " + quote(schema.name) + " (" + columns.join(", ") + ", PRIMARY KEY (" + quote(schema.primary) + "))";
423
+ }
424
+ /**
425
+ * Build a collision-free SQL index name for a table + column-group index —
426
+ * shared by {@link schemaToIndexes} (an `open`-time `CREATE INDEX`) and
427
+ * {@link stepToSQL}'s `index.add` / `index.remove` (a migration-time DDL),
428
+ * so a plan-built index name always matches one `open` would have created.
429
+ *
430
+ * @remarks
431
+ * A naive `idx_<table>_<cols joined by _>` is AMBIGUOUS: table `'a_b'` with
432
+ * column `'c'` and table `'a'` with columns `['b', 'c']` both produce
433
+ * `idx_a_b_c`. This encodes each part (the table name, then each column name)
434
+ * length-prefixed (`<len>_<part>`) so the boundary between parts is always
435
+ * unambiguous, however the names themselves are punctuated.
436
+ *
437
+ * @param table - The table name
438
+ * @param columns - The index's column names, in order
439
+ * @returns The deterministic, collision-free index identifier (unquoted)
440
+ *
441
+ * @example
442
+ * ```ts
443
+ * indexName('users', ['name']) // 'idx_5_users_4_name'
444
+ * indexName('a_b', ['c']) // 'idx_3_a_b_1_c'
445
+ * indexName('a', ['b', 'c']) // 'idx_1_a_1_b_1_c'
446
+ * ```
447
+ */
448
+ function indexName(table, columns) {
449
+ return "idx_" + [table, ...columns].map((part) => String(part.length) + "_" + part).join("_");
450
+ }
451
+ /**
452
+ * Project a {@link TableSchema} to the `CREATE INDEX IF NOT EXISTS` statements a
453
+ * SQLite driver's `open` issues for its declared indexes.
454
+ *
455
+ * @remarks
456
+ * One statement per index group; the index name is built by {@link indexName}
457
+ * (collision-free and deterministic), matching the driver's naming so a
458
+ * repeated `open` is idempotent.
459
+ *
460
+ * @param schema - The table's schema
461
+ * @returns One `CREATE INDEX IF NOT EXISTS …` statement per declared index
462
+ *
463
+ * @example
464
+ * ```ts
465
+ * schemaToIndexes(schema)
466
+ * // ['CREATE INDEX IF NOT EXISTS "idx_5_users_4_name" ON "users" ("name")']
467
+ * ```
468
+ */
469
+ function schemaToIndexes(schema) {
470
+ return schema.indexes.map((group) => "CREATE INDEX IF NOT EXISTS " + quote(indexName(schema.name, group)) + " ON " + quote(schema.name) + " (" + group.map(quote).join(", ") + ")");
471
+ }
472
+ /**
473
+ * Project one {@link MigrationStep} to the DDL statement(s) a SQLite driver's
474
+ * `migrate` executes for it.
475
+ *
476
+ * @remarks
477
+ * `table.add` emits the `CREATE TABLE` plus one `CREATE INDEX` per declared
478
+ * index (via {@link schemaToTable} / {@link schemaToIndexes}); `table.remove`
479
+ * emits `DROP TABLE IF EXISTS`; `column.add` / `column.remove` emit `ALTER
480
+ * TABLE … ADD COLUMN` / `… DROP COLUMN`; `index.add` / `index.remove` emit
481
+ * `CREATE INDEX IF NOT EXISTS` / `DROP INDEX IF EXISTS`, naming the index the
482
+ * same way `schemaToIndexes` does (`idx_<table>_<columns joined by _>`) so a
483
+ * plan-built index matches one `open` would have created. Whether the named
484
+ * table actually exists is the caller's concern (a driver's `migrate` checks
485
+ * its own declared schema before running these statements) — this projection
486
+ * is pure and never inspects live state.
487
+ *
488
+ * @param step - The migration step to project
489
+ * @returns The DDL statement(s) that apply the step
490
+ *
491
+ * @example
492
+ * ```ts
493
+ * stepToSQL({ operation: 'column.remove', table: 'users', column: 'legacy' })
494
+ * // ['ALTER TABLE "users" DROP COLUMN "legacy"']
495
+ * ```
496
+ */
497
+ function stepToSQL(step) {
498
+ switch (step.operation) {
499
+ case "table.add": return [schemaToTable(step.table), ...schemaToIndexes(step.table)];
500
+ case "table.remove": return ["DROP TABLE IF EXISTS " + quote(step.table)];
501
+ case "column.add": return ["ALTER TABLE " + quote(step.table) + " ADD COLUMN " + quote(step.column.name) + " " + columnSQL(step.column.type)];
502
+ case "column.remove": return ["ALTER TABLE " + quote(step.table) + " DROP COLUMN " + quote(step.column)];
503
+ case "index.add": return ["CREATE INDEX IF NOT EXISTS " + quote(indexName(step.table, step.index)) + " ON " + quote(step.table) + " (" + step.index.map(quote).join(", ") + ")"];
504
+ case "index.remove": return ["DROP INDEX IF EXISTS " + quote(indexName(step.table, step.index))];
505
+ }
506
+ }
507
+ /**
508
+ * Project one {@link MigrationStep} onto its table's declared {@link TableSchema}
509
+ * — the bookkeeping counterpart to {@link stepToSQL} (which projects the DDL a
510
+ * driver's `migrate` runs against the live database).
511
+ *
512
+ * @remarks
513
+ * `column.add` / `column.remove` add / filter the named column;
514
+ * `index.add` / `index.remove` add / filter the matching index group (an exact
515
+ * ordered match on `index`). `table.add` / `table.remove` act on a WHOLE
516
+ * schema map rather than one table's shape, so they are the caller's concern
517
+ * (a driver's `migrate` applies them directly against its table map) — passed
518
+ * here, they return `schema` unchanged.
519
+ *
520
+ * @param schema - The table's current declared schema
521
+ * @param step - The migration step to project onto it
522
+ * @returns The table's schema after the step
523
+ *
524
+ * @example
525
+ * ```ts
526
+ * stepToSchema(schema, { operation: 'column.remove', table: 'users', column: 'legacy' })
527
+ * // schema with the 'legacy' column dropped from `columns`
528
+ * ```
529
+ */
530
+ function stepToSchema(schema, step) {
531
+ switch (step.operation) {
532
+ case "column.add": return {
533
+ ...schema,
534
+ columns: [...schema.columns, step.column]
535
+ };
536
+ case "column.remove": return {
537
+ ...schema,
538
+ columns: schema.columns.filter((column) => column.name !== step.column)
539
+ };
540
+ case "index.add": return {
541
+ ...schema,
542
+ indexes: [...schema.indexes, step.index]
543
+ };
544
+ case "index.remove": return {
545
+ ...schema,
546
+ indexes: schema.indexes.filter((group) => !(group.length === step.index.length && group.every((name, position) => name === step.index[position])))
547
+ };
548
+ case "table.add":
549
+ case "table.remove": return schema;
550
+ }
551
+ }
552
+ //#endregion
553
+ //#region src/server/compilers.ts
554
+ /**
555
+ * Compile a NESTED {@link FieldPath} to the `json_type(<col>, <path>)` SQL
556
+ * expression — the {@link fieldColumn} `json_extract` sibling used to tell a
557
+ * PRESENT JSON `null` apart from an ABSENT path (both read back as SQL `NULL`
558
+ * through `json_extract`, but `json_type` reports `'null'` for the former and
559
+ * SQL `NULL` for the latter).
560
+ *
561
+ * @param path - The nested field path (a column plus its JSON keys)
562
+ * @returns The SQL expression reading the value's JSON type
563
+ *
564
+ * @example
565
+ * ```ts
566
+ * jsonTypeColumn(['payload', 'user', 'id']) // "json_type(\"payload\", '$.user.id')"
567
+ * ```
568
+ */
569
+ function jsonTypeColumn(path) {
570
+ const rest = path.slice(1).map((key) => "." + key.replaceAll("'", "''")).join("");
571
+ return "json_type(" + quote(path[0]) + ", '$" + rest + "')";
572
+ }
573
+ /**
574
+ * Escape `\`, `%`, and `_` (each with a leading `\`) so a `starts` / `ends`
575
+ * operand is matched literally under the `LIKE … ESCAPE '\'` clause.
576
+ *
577
+ * @param text - The raw operand text
578
+ * @returns The text with LIKE metacharacters escaped
579
+ *
580
+ * @example
581
+ * ```ts
582
+ * escapeLike('50%_off') // '50\\%\\_off'
583
+ * ```
584
+ */
585
+ function escapeLike(text) {
586
+ return text.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
587
+ }
588
+ /**
589
+ * The declared storage type of a flat (string) column, read from the schema.
590
+ *
591
+ * @param column - The column name
592
+ * @param schema - The table's schema
593
+ * @returns The column's {@link ColumnType}, or `undefined` if the schema does not carry it
594
+ *
595
+ * @example
596
+ * ```ts
597
+ * declaredType('age', schema) // 'integer'
598
+ * ```
599
+ */
600
+ function declaredType(column, schema) {
601
+ return schema.columns.find((candidate) => candidate.name === column)?.type;
602
+ }
603
+ /**
604
+ * The storage type a nested (`json_extract`) operand encodes as, derived from its
605
+ * RUNTIME value — NOT `json`.
606
+ *
607
+ * @remarks
608
+ * `json_extract` returns the unquoted, natively-typed scalar (a JSON boolean as
609
+ * `1` / `0`, a number as-is, a string as-is), so the operand must encode to that
610
+ * same scalar to compare. A boolean → `'boolean'` (→ `1` / `0`); a number →
611
+ * `'integer'` / `'real'`; a bigint → `'integer'`; a string → `'text'`; `null` /
612
+ * `undefined` → `'text'` (encodes to `null`); an object / array → `'json'` (the
613
+ * edge of comparing against a json subtree).
614
+ *
615
+ * @param value - The runtime operand value
616
+ * @returns The {@link ColumnType} to encode it as
617
+ *
618
+ * @example
619
+ * ```ts
620
+ * valueType(true) // 'boolean'
621
+ * valueType(9) // 'integer'
622
+ * ```
623
+ */
624
+ function valueType(value) {
625
+ if (typeof value === "boolean") return "boolean";
626
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
627
+ if (typeof value === "bigint") return "integer";
628
+ if (typeof value === "object" && value !== null) return "json";
629
+ return "text";
630
+ }
631
+ /**
632
+ * Compile one condition to its `<column> <operator>` SQL fragment and the params
633
+ * it binds — engine-exact under SQL's three-valued NULL logic.
634
+ *
635
+ * @remarks
636
+ * Every operand is run through `encodeValue`, so a bound value matches the SQL
637
+ * the column side compiles to. A flat column encodes operands with its DECLARED
638
+ * schema type (a flat `json` column → `JSON.stringify`); a nested `FieldPath`
639
+ * encodes each operand as the NATIVE scalar `json_extract` returns, derived from
640
+ * the operand's runtime type (per-operand, since `between` / `any` / `none` can
641
+ * mix types). `any` / `none` collapse an empty list to a constant (`0` matches
642
+ * nothing, `1` matches all) with no params.
643
+ *
644
+ * The core engine's total order ranks `undefined` (rank 0) BELOW `null`
645
+ * (rank 1) (see `compareValues`), so a MISSING/`NULL` column MATCHES
646
+ * `below` / `to` / a scalar `not` / `none` — the opposite of raw SQL, where a
647
+ * comparison against `NULL` is `NULL` (excluded). This fragment replicates the
648
+ * engine exactly. Truth table (`value` = the engine's decoded field read; a
649
+ * FLAT column's stored `NULL` decodes to `undefined` per `decodeRow`, so a
650
+ * flat `value` is NEVER a present `null` — only a NESTED path can be
651
+ * present-but-`null`):
652
+ *
653
+ * ```text
654
+ * operator | value=undefined (absent) | value=null (nested only) | value=scalar
655
+ * --------------------|--------------------------------|---------------------------|-------------
656
+ * equals, first=null | no match | MATCH | no match
657
+ * equals, first=X | no match | no match | value===X
658
+ * not, first=null | MATCH (flat: unconditionally; | no match | MATCH
659
+ * | nested: absent still matches)| |
660
+ * not, first=X | MATCH | MATCH | value!==X
661
+ * below/to, first=X | MATCH (rank 0 < rank(X)) | MATCH (rank 1 < rank(X)) | rank compare
662
+ * none, list=[…] | MATCH (no scalar rank-equal) | MATCH | not-in-list
663
+ * any, list=[…] | no match | no match | in-list
664
+ * above/from/between | no match | no match | rank compare
665
+ * like/glob/starts/… | no match (not a string) | no match | string test
666
+ * present | false | false | true
667
+ * absent | true | true | false
668
+ * ```
669
+ *
670
+ * Because a flat column's `NULL` always decodes to `undefined`, `equals`
671
+ * against a `null` operand needs no special flat compilation (`col = ?`
672
+ * binding a `NULL` param is already always-false in SQL, matching "no match"
673
+ * above) — but flat `not` against `null` must match EVERY row (both the
674
+ * absent and the scalar rows), which `col != ? OR col IS NULL` cannot express
675
+ * (it only catches the `IS NULL` row), so a flat `not`-with-`null`-operand
676
+ * compiles to the constant `1`.
677
+ *
678
+ * A NESTED path can be present-but-`null` (a stored JSON `null`), which
679
+ * `json_extract` reads back as SQL `NULL` — indistinguishable from an ABSENT
680
+ * path. `json_type(col, path)` disambiguates them (`'null'` for present-null,
681
+ * SQL `NULL` for absent), so nested `equals` / `not` against a `null` operand
682
+ * compile through `json_type` instead of `IS NULL` / `IS NOT NULL`.
683
+ *
684
+ * Every other MATCH-on-null-or-absent row is expressed uniformly (flat and
685
+ * nested alike) as `(<column> <op> ? OR <column> IS NULL)` — for a nested
686
+ * path, `json_extract` already collapses BOTH absent and present-null to SQL
687
+ * `NULL`, so `IS NULL` catches both in one clause; for a flat column there is
688
+ * only the absent case to catch.
689
+ *
690
+ * @param condition - The condition to compile
691
+ * @param schema - The table's schema (for declared column types)
692
+ * @returns The SQL fragment and its bound parameters
693
+ *
694
+ * @example
695
+ * ```ts
696
+ * fragment({ column: 'age', operator: 'above', values: [18], connector: 'and' }, schema)
697
+ * // { sql: '"age" > ?', params: [18] }
698
+ * fragment({ column: 'age', operator: 'below', values: [18], connector: 'and' }, schema)
699
+ * // { sql: '("age" < ? OR "age" IS NULL)', params: [18] }
700
+ * ```
701
+ */
702
+ function fragment(condition, schema) {
703
+ const column = fieldColumn(condition.column);
704
+ const nested = !isString(condition.column);
705
+ const declared = isString(condition.column) ? declaredType(condition.column, schema) : void 0;
706
+ const encode = (value) => encodeValue(value, nested ? valueType(value) : declared ?? "json");
707
+ const first = condition.values[0];
708
+ const second = condition.values[1];
709
+ const nullOperand = first === null || first === void 0;
710
+ const jsonType = !isString(condition.column) ? jsonTypeColumn(condition.column) : "";
711
+ switch (condition.operator) {
712
+ case "equals":
713
+ if (nullOperand && nested) return {
714
+ sql: jsonType + " = 'null'",
715
+ params: []
716
+ };
717
+ return {
718
+ sql: column + " = ?",
719
+ params: [encode(first)]
720
+ };
721
+ case "not":
722
+ if (nullOperand) {
723
+ if (nested) return {
724
+ sql: "(" + jsonType + " IS NULL OR " + jsonType + " != 'null')",
725
+ params: []
726
+ };
727
+ return {
728
+ sql: "1",
729
+ params: []
730
+ };
731
+ }
732
+ return {
733
+ sql: "(" + column + " != ? OR " + column + " IS NULL)",
734
+ params: [encode(first)]
735
+ };
736
+ case "above": return {
737
+ sql: column + " > ?",
738
+ params: [encode(first)]
739
+ };
740
+ case "below": return {
741
+ sql: "(" + column + " < ? OR " + column + " IS NULL)",
742
+ params: [encode(first)]
743
+ };
744
+ case "from": return {
745
+ sql: column + " >= ?",
746
+ params: [encode(first)]
747
+ };
748
+ case "to": return {
749
+ sql: "(" + column + " <= ? OR " + column + " IS NULL)",
750
+ params: [encode(first)]
751
+ };
752
+ case "between": return {
753
+ sql: column + " BETWEEN ? AND ?",
754
+ params: [encode(first), encode(second)]
755
+ };
756
+ case "like": return {
757
+ sql: column + " LIKE ?",
758
+ params: [encode(first)]
759
+ };
760
+ case "glob": return {
761
+ sql: column + " GLOB ?",
762
+ params: [encode(first)]
763
+ };
764
+ case "starts": {
765
+ const text = isString(first) ? first : "";
766
+ if (text === "") return {
767
+ sql: "typeof(" + column + ") = 'text'",
768
+ params: []
769
+ };
770
+ const length = Array.from(text).length;
771
+ return {
772
+ sql: "(typeof(" + column + ") = 'text' AND substr(" + column + ", 1, " + length + ") = ?)",
773
+ params: [encode(first)]
774
+ };
775
+ }
776
+ case "ends": {
777
+ const text = isString(first) ? first : "";
778
+ if (text === "") return {
779
+ sql: "typeof(" + column + ") = 'text'",
780
+ params: []
781
+ };
782
+ const length = Array.from(text).length;
783
+ return {
784
+ sql: "(typeof(" + column + ") = 'text' AND substr(" + column + ", -" + length + ") = ?)",
785
+ params: [encode(first)]
786
+ };
787
+ }
788
+ case "any":
789
+ if (condition.values.length === 0) return {
790
+ sql: "0",
791
+ params: []
792
+ };
793
+ return {
794
+ sql: column + " IN (" + condition.values.map(() => "?").join(", ") + ")",
795
+ params: condition.values.map(encode)
796
+ };
797
+ case "none":
798
+ if (condition.values.length === 0) return {
799
+ sql: "1",
800
+ params: []
801
+ };
802
+ return {
803
+ sql: "(" + column + " NOT IN (" + condition.values.map(() => "?").join(", ") + ") OR " + column + " IS NULL)",
804
+ params: condition.values.map(encode)
805
+ };
806
+ case "absent": return {
807
+ sql: column + " IS NULL",
808
+ params: []
809
+ };
810
+ case "present": return {
811
+ sql: column + " IS NOT NULL",
812
+ params: []
813
+ };
814
+ }
815
+ }
816
+ /**
817
+ * Fold the conditions into one WHERE clause, parenthesizing progressively
818
+ * left-to-right so the grouping matches the engine's `matchesCriteria` fold.
819
+ *
820
+ * @remarks
821
+ * The first condition's connector is ignored, per the {@link Condition} types.
822
+ * Every fragment (see {@link fragment}'s truth table) replicates the core
823
+ * engine's total order EXACTLY under SQL's three-valued NULL logic, so this
824
+ * clause matches `applyCriteria` row-for-row over the same table — a native
825
+ * `records` / `count` read never disagrees with a scan-and-filter fallback.
826
+ *
827
+ * @param conditions - The conditions to fold
828
+ * @param schema - The table's schema
829
+ * @returns The `WHERE …` clause and its bound parameters, or an empty clause for zero conditions
830
+ *
831
+ * @example
832
+ * ```ts
833
+ * compileWhere([{ column: 'age', operator: 'from', values: [18], connector: 'and' }], schema)
834
+ * // { sql: 'WHERE "age" >= ?', params: [18] }
835
+ * ```
836
+ */
837
+ function compileWhere(conditions, schema) {
838
+ if (conditions.length === 0) return {
839
+ sql: "",
840
+ params: []
841
+ };
842
+ const head = fragment(conditions[0], schema);
843
+ let clause = head.sql;
844
+ const params = [...head.params];
845
+ for (let index = 1; index < conditions.length; index += 1) {
846
+ const next = fragment(conditions[index], schema);
847
+ const operator = conditions[index].connector === "or" ? "OR" : "AND";
848
+ clause = "(" + clause + " " + operator + " " + next.sql + ")";
849
+ params.push(...next.params);
850
+ }
851
+ return {
852
+ sql: "WHERE " + clause,
853
+ params
854
+ };
855
+ }
856
+ /**
857
+ * Compile the ORDER BY clause from the order terms, always ending with the
858
+ * primary key as the final determinant.
859
+ *
860
+ * @remarks
861
+ * The native `records` read then resolves ties in key order, matching a
862
+ * primary-key-ordered `scan` and the core engine's stable `sortRows` over a
863
+ * key-ordered scan (and IndexedDB's key-ordered reads), so a native read equals
864
+ * the scan path (AGENTS §21 / §22 native ↔ engine parity). SQLite without an
865
+ * `ORDER BY` returns rowid (insertion) order, and an explicit order alone breaks
866
+ * ties by rowid too — both diverge from every key-ordered backend. The
867
+ * tie-breaker is ASCENDING regardless of the explicit directions: the engine's
868
+ * stable sort runs over key-ascending input, so equal rows stay in
869
+ * ascending-key order whichever way the explicit terms point. Skipped when the
870
+ * primary is already an explicit order term (no double-append).
871
+ *
872
+ * @param order - The explicit order terms, or `undefined`
873
+ * @param schema - The table's schema (for the primary key)
874
+ * @returns The `ORDER BY …` clause, or an empty string when there is nothing to order by
875
+ *
876
+ * @example
877
+ * ```ts
878
+ * compileOrder([{ column: 'age', direction: 'descending' }], schema)
879
+ * // 'ORDER BY "age" DESC, "id"'
880
+ * ```
881
+ */
882
+ function compileOrder(order, schema) {
883
+ const terms = (order ?? []).map((term) => fieldColumn(term.column) + (term.direction === "descending" ? " DESC" : " ASC"));
884
+ if (!(order ?? []).some((term) => isString(term.column) && term.column === schema.primary)) terms.push(quote(schema.primary));
885
+ return terms.length === 0 ? "" : "ORDER BY " + terms.join(", ");
886
+ }
887
+ /**
888
+ * Compile the LIMIT / OFFSET clause.
889
+ *
890
+ * @remarks
891
+ * An offset without a limit uses `LIMIT -1` (SQLite's "no limit") so OFFSET is
892
+ * still honored.
893
+ *
894
+ * @param limit - The maximum row count, or `undefined`
895
+ * @param offset - The row count to skip, or `undefined`
896
+ * @returns The `LIMIT …` clause and its bound parameters, or an empty clause when neither is set
897
+ *
898
+ * @example
899
+ * ```ts
900
+ * compilePage(undefined, 5) // { sql: 'LIMIT -1 OFFSET ?', params: [5] }
901
+ * ```
902
+ */
903
+ function compilePage(limit, offset) {
904
+ if (limit !== void 0 && offset !== void 0) return {
905
+ sql: "LIMIT ? OFFSET ?",
906
+ params: [limit, offset]
907
+ };
908
+ if (limit !== void 0) return {
909
+ sql: "LIMIT ?",
910
+ params: [limit]
911
+ };
912
+ if (offset !== void 0) return {
913
+ sql: "LIMIT -1 OFFSET ?",
914
+ params: [offset]
915
+ };
916
+ return {
917
+ sql: "",
918
+ params: []
919
+ };
920
+ }
921
+ /**
922
+ * Compile a {@link Criteria} into the SQL clause that follows a table name, with
923
+ * its bound parameters in clause order.
924
+ *
925
+ * @remarks
926
+ * The driver's native `records` / `count` path: it assembles
927
+ * `[where, orderBy, limitOffset]` (each possibly empty) into one clause so a
928
+ * `SELECT * FROM <table> <clause>` runs the whole read in the engine instead of
929
+ * over a JS `scan`. The WHERE fold is parenthesized **left-to-right** to mirror
930
+ * the core engine's `matchesCriteria` (not SQL's native AND-over-OR precedence),
931
+ * so a native and an engine read return identical rows. Each operand is encoded
932
+ * via `encodeValue`: a flat column uses its declared schema type, while a nested
933
+ * `FieldPath` (a `json_extract` read) encodes each operand as the native scalar
934
+ * the extract returns — derived from the operand's runtime type — so it compares.
935
+ * The 15 operators map per the databases guide's operator table, with
936
+ * `starts` / `ends` using `LIKE … ESCAPE '\'` and an empty `any` / `none` list
937
+ * collapsing to a constant. A `undefined` criteria (or one with no parts)
938
+ * compiles to an empty clause.
939
+ *
940
+ * @param criteria - The read specification, or `undefined` for all rows
941
+ * @param schema - The table's schema (column types for operand encoding)
942
+ * @returns The SQL tail and its bound parameters
943
+ *
944
+ * @example
945
+ * ```ts
946
+ * compileCriteria({ conditions: [{ column: 'age', operator: 'from', values: [18], connector: 'and' }] }, schema)
947
+ * // { sql: 'WHERE "age" >= ? ORDER BY "id"', params: [18] }
948
+ * ```
949
+ */
950
+ function compileCriteria(criteria, schema) {
951
+ const where = compileWhere(criteria?.conditions ?? [], schema);
952
+ const orderBy = compileOrder(criteria?.order, schema);
953
+ const page = compilePage(criteria?.limit, criteria?.offset);
954
+ return {
955
+ sql: [
956
+ where.sql,
957
+ orderBy,
958
+ page.sql
959
+ ].filter((part) => part !== "").join(" "),
960
+ params: [...where.params, ...page.params]
961
+ };
962
+ }
963
+ //#endregion
964
+ //#region src/server/constants.ts
965
+ /**
966
+ * The reserved metadata table the {@link SQLiteDriver} creates on `open` to
967
+ * persist its stamped `DriverMeta` (`version` + declared schema JSON) — the
968
+ * SQLite realization of the `meta` / `stamp` driver hooks.
969
+ *
970
+ * @remarks
971
+ * A single-row table (`id = 1`). A user table named `_meta` collides with the
972
+ * reservation — the caller's concern to avoid, documented on the driver class.
973
+ */
974
+ var META_TABLE = "_meta";
975
+ //#endregion
976
+ //#region src/server/drivers/JSONDriver.ts
977
+ /**
978
+ * A persistent {@link DriverInterface} backed by a single JSON file — the
979
+ * reference {@link MemoryDriver} plus file load / flush.
980
+ *
981
+ * @remarks
982
+ * A decorator, not a reimplementation: every primitive delegates to an inner
983
+ * {@link MemoryDriver}, so querying, key-order `scan` / `keys`, and capture-replay
984
+ * `snapshot` are inherited unchanged — this layer adds only persistence. `open`
985
+ * loads the file into memory; every mutation (`write` / `delete` / `clear`) flushes
986
+ * the whole store back. The file is one JSON object, `{ meta?: DriverMeta, tables: {
987
+ * [name]: rows } }` — `meta` is present only once the store has been `stamp`ed
988
+ * (an unstamped store serializes the old `{ tables }` shape, preserving
989
+ * backward compatibility); a per-table array of rows, each row carrying its own
990
+ * primary (the table contract), so the key is recovered on load with
991
+ * {@link extractKey} and the file need not store it. The parsed JSON crosses the
992
+ * boundary as `unknown` and is narrowed with {@link isRecord} / {@link extractKey},
993
+ * never asserted (AGENTS §14): a missing, corrupt, or wrong-shaped file starts
994
+ * empty rather than throwing, and a malformed row (or malformed `meta`) is
995
+ * skipped/dropped rather than thrown on. It is scan-only — it implements none of
996
+ * the optional native `records` / `count` / `aggregate` hooks, so the core engine
997
+ * over `scan` answers every query. For development, small datasets, and portable /
998
+ * inspectable data; for large or concurrent workloads reach for a SQLite-backed
999
+ * driver.
1000
+ *
1001
+ * A failure in the write path ({@link JSONDriver.#serialize} — `mkdir` /
1002
+ * `writeFile` / `rename`) is wrapped and rethrown as `DatabaseError` `DRIVER`,
1003
+ * carrying the target `path` in its context; the read path ({@link
1004
+ * JSONDriver.#load}) tolerance above is a separate, deliberate contract and is
1005
+ * never touched by this wrapping.
1006
+ */
1007
+ var JSONDriver = class {
1008
+ #path;
1009
+ #memory = new MemoryDriver();
1010
+ #schema = [];
1011
+ #meta;
1012
+ #flushCount = 0;
1013
+ #chain = Promise.resolve();
1014
+ #deferring = false;
1015
+ constructor(path) {
1016
+ this.#path = path;
1017
+ }
1018
+ async open(schema) {
1019
+ this.#schema = schema;
1020
+ await this.#memory.open(schema);
1021
+ await this.#load();
1022
+ }
1023
+ async close() {
1024
+ await this.#memory.close();
1025
+ }
1026
+ async read(table, key) {
1027
+ return this.#memory.read(table, key);
1028
+ }
1029
+ async write(table, key, row) {
1030
+ await this.#memory.write(table, key, row);
1031
+ if (!this.#deferring) await this.#flush();
1032
+ }
1033
+ async delete(table, key) {
1034
+ const removed = await this.#memory.delete(table, key);
1035
+ if (!this.#deferring) await this.#flush();
1036
+ return removed;
1037
+ }
1038
+ keys(table) {
1039
+ return this.#memory.keys(table);
1040
+ }
1041
+ scan(table) {
1042
+ return this.#memory.scan(table);
1043
+ }
1044
+ /**
1045
+ * Natively filtered lazy iteration — delegates to the inner {@link MemoryDriver}.
1046
+ *
1047
+ * @remarks
1048
+ * Semantics are the memory driver's own: `criteria.conditions` filters, `offset`
1049
+ * / `limit` page lazily, and `criteria.order` is ignored (streaming yields key
1050
+ * order; sorted output is `records()`'s job).
1051
+ *
1052
+ * @param table - The table to stream
1053
+ * @param criteria - The filter / offset / limit to apply lazily
1054
+ */
1055
+ stream(table, criteria) {
1056
+ return this.#memory.stream(table, criteria);
1057
+ }
1058
+ async clear(table) {
1059
+ await this.#memory.clear(table);
1060
+ if (!this.#deferring) await this.#flush();
1061
+ }
1062
+ /**
1063
+ * Begin a native transaction — flush-coalescing over the inner {@link MemoryDriver}.
1064
+ *
1065
+ * @remarks
1066
+ * Single-writer: throws `DatabaseError` `CONFLICT` if a transaction is already
1067
+ * active — this driver does not support nesting. On begin, captures the inner
1068
+ * memory rollback thunk via `#memory.snapshot()` and suppresses per-mutation
1069
+ * `#flush` — `write` / `delete` / `clear` still mutate memory but no longer
1070
+ * touch the file, so N mutations under the handle cost ONE file write instead
1071
+ * of N. `commit()` releases the suppression and performs that one atomic
1072
+ * `#flush()`, persisting the transaction's net state. `rollback()` restores
1073
+ * memory via the captured snapshot thunk, then `#flush()`s so the file reflects
1074
+ * the restored state. Outside a transaction, behavior is unchanged — every
1075
+ * mutation flushes on its own. Calling `commit` / `rollback` a second time (on
1076
+ * either method, in either order) throws `DatabaseError` `CONFLICT`.
1077
+ *
1078
+ * @returns A {@link TransactionInterface} handle to `commit` or `rollback`
1079
+ */
1080
+ async transaction() {
1081
+ if (this.#deferring) throw new DatabaseError("CONFLICT", "A transaction is already active on this driver", {});
1082
+ const rollback = await this.#memory.snapshot();
1083
+ this.#deferring = true;
1084
+ let settled = false;
1085
+ return {
1086
+ commit: async () => {
1087
+ if (settled) throw new DatabaseError("CONFLICT", "Transaction already settled", {});
1088
+ settled = true;
1089
+ this.#deferring = false;
1090
+ await this.#flush();
1091
+ },
1092
+ rollback: async () => {
1093
+ if (settled) throw new DatabaseError("CONFLICT", "Transaction already settled", {});
1094
+ settled = true;
1095
+ await rollback();
1096
+ this.#deferring = false;
1097
+ await this.#flush();
1098
+ }
1099
+ };
1100
+ }
1101
+ async snapshot(tables) {
1102
+ const rollback = await this.#memory.snapshot(tables);
1103
+ return async () => {
1104
+ await rollback();
1105
+ await this.#flush();
1106
+ };
1107
+ }
1108
+ async meta() {
1109
+ return this.#meta;
1110
+ }
1111
+ /**
1112
+ * Persist `meta` verbatim for a later `meta()` to return.
1113
+ *
1114
+ * @remarks
1115
+ * Respects the same defer-flush suppression as `write` / `delete` / `clear`
1116
+ * (see {@link JSONDriver.transaction} @remarks) — stamping inside an active
1117
+ * transaction updates memory but does not flush until the transaction settles.
1118
+ *
1119
+ * @param meta - The {@link DriverMeta} to persist
1120
+ */
1121
+ async stamp(meta) {
1122
+ this.#meta = meta;
1123
+ if (!this.#deferring) await this.#flush();
1124
+ }
1125
+ /**
1126
+ * Apply a {@link Migration} plan by delegating to the inner {@link MemoryDriver},
1127
+ * then persist the migrated state.
1128
+ *
1129
+ * @remarks
1130
+ * The inner `MemoryDriver.migrate` applies each step (adding/removing tables,
1131
+ * adding/removing columns from stored rows, no-op index steps) and throws
1132
+ * `DatabaseError` `MIGRATION` for a step referencing an unknown table — that
1133
+ * error propagates untouched. `table.add` / `table.remove` steps also update
1134
+ * this driver's own declared `#schema`, mirroring the bookkeeping `open` does,
1135
+ * so a subsequent `#flush` / `#load` round-trip includes (or drops) the table.
1136
+ * A successful migration ends with one atomic `#flush()` so the new state
1137
+ * survives a close and reopen. A multi-step plan applies its steps
1138
+ * sequentially and is NOT atomic — a failure partway through a plan leaves
1139
+ * the earlier steps already applied.
1140
+ *
1141
+ * @param plan - The migration plan to apply
1142
+ */
1143
+ async migrate(plan) {
1144
+ await this.#memory.migrate?.(plan);
1145
+ let schema = this.#schema;
1146
+ for (const step of plan.steps) if (step.operation === "table.add") schema = schema.some((table) => table.name === step.table.name) ? schema : [...schema, step.table];
1147
+ else if (step.operation === "table.remove") schema = schema.filter((table) => table.name !== step.table);
1148
+ this.#schema = schema;
1149
+ await this.#flush();
1150
+ }
1151
+ async #load() {
1152
+ let raw;
1153
+ try {
1154
+ raw = await readFile(this.#path, "utf-8");
1155
+ } catch {
1156
+ return;
1157
+ }
1158
+ let parsed;
1159
+ try {
1160
+ parsed = JSON.parse(raw);
1161
+ } catch {
1162
+ return;
1163
+ }
1164
+ if (!isRecord(parsed) || !isRecord(parsed.tables)) return;
1165
+ const tables = parsed.tables;
1166
+ for (const table of this.#schema) {
1167
+ const rows = tables[table.name];
1168
+ if (!Array.isArray(rows)) continue;
1169
+ for (const entry of rows) {
1170
+ if (!isRecord(entry)) continue;
1171
+ const key = extractKey(entry, table.primary);
1172
+ if (key === void 0) continue;
1173
+ await this.#memory.write(table.name, key, entry);
1174
+ }
1175
+ }
1176
+ if (isDriverMeta(parsed.meta)) this.#meta = parsed.meta;
1177
+ }
1178
+ async #flush() {
1179
+ const next = this.#chain.then(() => this.#serialize());
1180
+ this.#chain = next.catch(() => {});
1181
+ await next;
1182
+ }
1183
+ async #serialize() {
1184
+ const tables = {};
1185
+ for (const table of this.#schema) {
1186
+ const rows = [];
1187
+ for await (const row of this.#memory.scan(table.name)) rows.push(row);
1188
+ tables[table.name] = rows;
1189
+ }
1190
+ this.#flushCount += 1;
1191
+ const temp = `${this.#path}.${process.pid}.${this.#flushCount}.tmp`;
1192
+ const payload = this.#meta === void 0 ? { tables } : {
1193
+ meta: this.#meta,
1194
+ tables
1195
+ };
1196
+ try {
1197
+ await mkdir(dirname(this.#path), { recursive: true });
1198
+ await writeFile(temp, JSON.stringify(payload, null, 2), "utf-8");
1199
+ await rename(temp, this.#path);
1200
+ } catch (error) {
1201
+ await rm(temp, { force: true }).catch(() => {});
1202
+ throw new DatabaseError("DRIVER", "Failed to persist the database file", {
1203
+ path: this.#path,
1204
+ cause: error
1205
+ });
1206
+ }
1207
+ }
1208
+ };
1209
+ //#endregion
1210
+ //#region src/server/drivers/SQLiteDriver.ts
1211
+ /**
1212
+ * The SQLite {@link DriverInterface} — the server-native, trusted-mode backend
1213
+ * built on the published `@orkestrel/sqlite` synchronous wrapper.
1214
+ *
1215
+ * @remarks
1216
+ * A thin adapter: it implements the storage primitives the core database layer
1217
+ * needs by delegating to the wrapper's prepared statements — it never touches
1218
+ * raw `node:sqlite`. `open` issues `CREATE TABLE IF NOT EXISTS` with real typed
1219
+ * columns (mapped from each {@link TableSchema}'s portable column types) and a
1220
+ * `PRIMARY KEY`, plus a `CREATE INDEX IF NOT EXISTS` per declared index (both
1221
+ * reopen-safe), and readies a reserved `_meta` single-row table `meta()` /
1222
+ * `stamp()` read and write — **a user table named `_meta` collides with it**;
1223
+ * avoid the name. Rows cross the boundary through the codecs in `helpers.ts`
1224
+ * (`json` columns store / parse JSON text, a `boolean` stores `1` / `0`), so the
1225
+ * typed layer above imposes the exact shape (AGENTS §14). `write` is an
1226
+ * `INSERT OR REPLACE` upsert — the `Table` layer detects a `CONFLICT` via a
1227
+ * prior `has`, so this never translates a constraint error; a backend
1228
+ * `SQLiteError` otherwise propagates unchanged. Querying, ordering, paging, and
1229
+ * aggregation are native: `records` / `count` / `stream` compile a `Criteria`
1230
+ * to SQL with `compileCriteria`, and `aggregate` runs a SQL
1231
+ * `COUNT`/`SUM`/`AVG`/`MIN`/`MAX` (via `aggregateSQL`) over the same compiled
1232
+ * WHERE. `transaction` wraps native `BEGIN` / `COMMIT` / `ROLLBACK` with
1233
+ * double-settle guards. `migrate` runs the plan's projected DDL
1234
+ * ({@link import('../helpers.js').stepToSQL}) inside whichever native
1235
+ * transaction is active: joined into an already-open `transaction()` handle
1236
+ * when one exists (the core's versioned reconcile path wraps migrate + stamp
1237
+ * in one native `BEGIN`, and node:sqlite rejects a nested `BEGIN`), or inside
1238
+ * its own `database.transaction` otherwise — a mid-plan failure rolls back
1239
+ * atomically either way, an improvement over the non-atomic `MemoryDriver` /
1240
+ * `JSONDriver` migrate; a step referencing an undeclared table throws
1241
+ * `DatabaseError` `MIGRATION` before any DDL for that step runs. `snapshot` is
1242
+ * capture-replay (SELECT the
1243
+ * named tables' rows, replay via DELETE + INSERT OR REPLACE inside a native
1244
+ * transaction on rollback) rather than a SQL `SAVEPOINT`, since the core
1245
+ * `transaction` calls the rollback thunk only on failure with no commit-on-
1246
+ * success signal — a long-lived `SAVEPOINT` would leave the connection
1247
+ * uncommitted (lost on close). Every backend interaction runs through `#guard`,
1248
+ * which maps a thrown backend `SQLiteError` (or any unexpected non-`SQLiteError`
1249
+ * throw) to a typed {@link DatabaseError} — never a raw backend error escapes
1250
+ * `DriverInterface`: `CONSTRAINT` → `CONFLICT`, the wrapper's own `CLOSED` →
1251
+ * `CLOSED`, `BUSY` (a locked database that outlasted the configured `timeout`)
1252
+ * → a retryable `DRIVER` (`context.retryable` is `true`), and `UNKNOWN` / any
1253
+ * other throw → `DRIVER`. The original error is preserved as `context.cause`.
1254
+ * A `DatabaseError` this driver throws directly (`CLOSED` from the `#require`
1255
+ * gate, `NOT_FOUND` from `#table`, `MIGRATION` from a migration-plan fault)
1256
+ * passes through `#guard` unchanged, never re-wrapped.
1257
+ */
1258
+ var SQLiteDriver = class {
1259
+ #path;
1260
+ #options;
1261
+ #database;
1262
+ #schema = /* @__PURE__ */ new Map();
1263
+ #transacting = false;
1264
+ constructor(path, options) {
1265
+ this.#path = path;
1266
+ this.#options = options ?? {};
1267
+ }
1268
+ async open(schema) {
1269
+ if (schema.some((table) => table.name === "_meta")) throw new DatabaseError("VALIDATION", `A declared table cannot be named '${META_TABLE}' — it is reserved for driver metadata`, { table: META_TABLE });
1270
+ this.#guard(() => {
1271
+ this.#database?.close();
1272
+ const database = createSQLiteDatabase({
1273
+ path: this.#path,
1274
+ readonly: this.#options.readonly,
1275
+ timeout: this.#options.timeout,
1276
+ foreignKeys: this.#options.foreignKeys
1277
+ });
1278
+ database.connect();
1279
+ for (const [name, value] of Object.entries(this.#options.pragmas ?? {})) database.pragma(name, value);
1280
+ const map = /* @__PURE__ */ new Map();
1281
+ for (const table of schema) {
1282
+ map.set(table.name, table);
1283
+ database.exec(schemaToTable(table));
1284
+ for (const sql of schemaToIndexes(table)) database.exec(sql);
1285
+ }
1286
+ database.exec("CREATE TABLE IF NOT EXISTS " + quote(META_TABLE) + " (\"id\" INTEGER, \"version\" INTEGER, \"schema\" TEXT, PRIMARY KEY (\"id\"))");
1287
+ this.#schema = map;
1288
+ this.#database = database;
1289
+ });
1290
+ }
1291
+ async close() {
1292
+ this.#database?.close();
1293
+ this.#database = void 0;
1294
+ }
1295
+ async read(table, key) {
1296
+ const schema = this.#table(table);
1297
+ return this.#guard(() => {
1298
+ const row = this.#require().prepare("SELECT * FROM " + quote(table) + " WHERE " + quote(schema.primary) + " = ?").get([this.#key(key, schema)]);
1299
+ return row === void 0 ? void 0 : decodeRow(row, schema);
1300
+ });
1301
+ }
1302
+ async write(table, key, row) {
1303
+ const schema = this.#table(table);
1304
+ this.#guard(() => {
1305
+ const encoded = encodeRow({
1306
+ ...row,
1307
+ [schema.primary]: key
1308
+ }, schema);
1309
+ const names = schema.columns.map((column) => column.name);
1310
+ const values = names.map((name) => encoded[name]);
1311
+ this.#require().prepare("INSERT OR REPLACE INTO " + quote(table) + " (" + names.map(quote).join(", ") + ") VALUES (" + names.map(() => "?").join(", ") + ")").run(values);
1312
+ });
1313
+ }
1314
+ async delete(table, key) {
1315
+ const schema = this.#table(table);
1316
+ return this.#guard(() => {
1317
+ return this.#require().prepare("DELETE FROM " + quote(table) + " WHERE " + quote(schema.primary) + " = ?").run([this.#key(key, schema)]).changes > 0;
1318
+ });
1319
+ }
1320
+ async keys(table) {
1321
+ const schema = this.#table(table);
1322
+ return this.#guard(() => {
1323
+ const primary = quote(schema.primary);
1324
+ const rows = this.#require().prepare("SELECT " + primary + " FROM " + quote(table) + " ORDER BY " + primary).all();
1325
+ const keys = [];
1326
+ for (const row of rows) {
1327
+ const value = row[schema.primary];
1328
+ if (typeof value === "string" || typeof value === "number") keys.push(value);
1329
+ }
1330
+ return keys;
1331
+ });
1332
+ }
1333
+ async *scan(table) {
1334
+ const schema = this.#table(table);
1335
+ const iterator = this.#guard(() => this.#require().prepare("SELECT * FROM " + quote(table) + " ORDER BY " + quote(schema.primary)).iterate())[Symbol.iterator]();
1336
+ while (true) {
1337
+ const step = this.#guard(() => iterator.next());
1338
+ if (step.done === true) return;
1339
+ yield this.#guard(() => decodeRow(step.value, schema));
1340
+ }
1341
+ }
1342
+ async clear(table) {
1343
+ this.#table(table);
1344
+ this.#guard(() => {
1345
+ this.#require().prepare("DELETE FROM " + quote(table)).run();
1346
+ });
1347
+ }
1348
+ async records(table, criteria) {
1349
+ const schema = this.#table(table);
1350
+ if (isExactCriteria(criteria, schema)) return this.#guard(() => {
1351
+ const { sql, params } = compileCriteria(criteria, schema);
1352
+ return this.#require().prepare("SELECT * FROM " + quote(table) + (sql === "" ? "" : " " + sql)).all(params).map((row) => decodeRow(row, schema));
1353
+ });
1354
+ const rows = [];
1355
+ for await (const row of this.scan(table)) rows.push(row);
1356
+ return applyCriteria(rows, criteria);
1357
+ }
1358
+ async count(table, criteria) {
1359
+ const schema = this.#table(table);
1360
+ const conditions = criteria.conditions ?? [];
1361
+ if (conditions.every((condition) => isExactCondition(condition, schema))) return this.#guard(() => {
1362
+ const { sql, params } = compileWhere(conditions, schema);
1363
+ const value = this.#require().prepare("SELECT COUNT(*) AS count FROM " + quote(table) + (sql === "" ? "" : " " + sql)).get(params)?.count;
1364
+ return typeof value === "number" || typeof value === "bigint" ? Number(value) : 0;
1365
+ });
1366
+ const rows = [];
1367
+ for await (const row of this.scan(table)) rows.push(row);
1368
+ return filterRows(rows, conditions).length;
1369
+ }
1370
+ async aggregate(table, operation, column, criteria) {
1371
+ const schema = this.#table(table);
1372
+ const conditions = criteria.conditions ?? [];
1373
+ const conditionsExact = conditions.every((condition) => isExactCondition(condition, schema));
1374
+ const columnExact = operation === "count" || isString(column) && schema.columns.some((candidate) => candidate.name === column && (candidate.type === "integer" || candidate.type === "real"));
1375
+ if (conditionsExact && columnExact) return this.#guard(() => {
1376
+ const { sql, params } = compileWhere(conditions, schema);
1377
+ const value = this.#require().prepare("SELECT " + aggregateSQL(operation, column) + " AS value FROM " + quote(table) + (sql === "" ? "" : " " + sql)).get(params)?.value;
1378
+ return value === null || value === void 0 ? void 0 : Number(value);
1379
+ });
1380
+ const rows = [];
1381
+ for await (const row of this.scan(table)) rows.push(row);
1382
+ return computeAggregate(filterRows(rows, conditions), operation, column);
1383
+ }
1384
+ async *stream(table, criteria) {
1385
+ const schema = this.#table(table);
1386
+ const conditions = criteria.conditions ?? [];
1387
+ if (conditions.every((condition) => isExactCondition(condition, schema))) {
1388
+ const compiled = compileCriteria({
1389
+ conditions,
1390
+ limit: criteria.limit,
1391
+ offset: criteria.offset
1392
+ }, schema);
1393
+ for (const row of this.#require().prepare("SELECT * FROM " + quote(table) + (compiled.sql === "" ? "" : " " + compiled.sql)).iterate(compiled.params)) yield decodeRow(row, schema);
1394
+ return;
1395
+ }
1396
+ const offset = criteria.offset ?? 0;
1397
+ const limit = criteria.limit;
1398
+ let skipped = 0;
1399
+ let yielded = 0;
1400
+ for await (const row of this.scan(table)) {
1401
+ if (limit !== void 0 && yielded >= limit) return;
1402
+ if (conditions.length > 0 && !matchesCriteria(row, conditions)) continue;
1403
+ if (skipped < offset) {
1404
+ skipped += 1;
1405
+ continue;
1406
+ }
1407
+ yield row;
1408
+ yielded += 1;
1409
+ }
1410
+ }
1411
+ /**
1412
+ * Begin a native transaction — real `BEGIN`, `COMMIT`, `ROLLBACK`.
1413
+ *
1414
+ * @remarks
1415
+ * Calling `commit` or `rollback` a second time (on either method, in either
1416
+ * order) throws `DatabaseError` `CONFLICT`.
1417
+ *
1418
+ * @returns A {@link TransactionInterface} handle to `commit` or `rollback`
1419
+ */
1420
+ async transaction() {
1421
+ const database = this.#require();
1422
+ this.#guard(() => database.exec("BEGIN"));
1423
+ let settled = false;
1424
+ this.#transacting = true;
1425
+ return {
1426
+ commit: async () => {
1427
+ if (settled) throw new DatabaseError("CONFLICT", "Transaction already settled", {});
1428
+ settled = true;
1429
+ this.#transacting = false;
1430
+ this.#guard(() => database.exec("COMMIT"));
1431
+ },
1432
+ rollback: async () => {
1433
+ if (settled) throw new DatabaseError("CONFLICT", "Transaction already settled", {});
1434
+ settled = true;
1435
+ this.#transacting = false;
1436
+ this.#guard(() => database.exec("ROLLBACK"));
1437
+ }
1438
+ };
1439
+ }
1440
+ /**
1441
+ * Apply a {@link Migration} plan by executing each step's projected DDL
1442
+ * ({@link import('../helpers.js').stepToSQL}).
1443
+ *
1444
+ * @remarks
1445
+ * Atomicity is provided by whichever native transaction is active: when
1446
+ * this driver's own `transaction()` hook already has a handle open (the
1447
+ * core's versioned reconcile / migrate path joins migrate + stamp under
1448
+ * one native `BEGIN`), the plan's DDL runs directly inside that enclosing
1449
+ * transaction — a mid-plan failure propagates out and the CALLER's
1450
+ * `commit`/`rollback` provides atomicity. node:sqlite (and SQLite
1451
+ * generally) rejects a nested `BEGIN`, so this driver must never open a
1452
+ * second native transaction while one is already open. Otherwise (no
1453
+ * enclosing transaction), `migrate` wraps the plan in its own native
1454
+ * `database.transaction` — atomic on its own: a mid-plan failure rolls
1455
+ * back every DDL statement already applied by the plan. A step
1456
+ * referencing a table not in this driver's declared schema (and that is
1457
+ * not itself a `table.add`) throws `DatabaseError` `MIGRATION` before any
1458
+ * DDL for that step runs, propagating out of whichever transaction is
1459
+ * active (which rolls back on a throw).
1460
+ *
1461
+ * @param plan - The migration plan to apply
1462
+ */
1463
+ async migrate(plan) {
1464
+ const database = this.#require();
1465
+ const schema = new Map(this.#schema);
1466
+ this.#guard(() => {
1467
+ if (this.#transacting) this.#applyPlan(database, plan, schema);
1468
+ else database.transaction(() => this.#applyPlan(database, plan, schema));
1469
+ });
1470
+ this.#schema = schema;
1471
+ }
1472
+ /**
1473
+ * Read the persisted {@link DriverMeta} from the reserved `_meta` table.
1474
+ *
1475
+ * @returns The last-stamped `DriverMeta`, or `undefined` when never stamped
1476
+ * (or the stored row is malformed)
1477
+ */
1478
+ async meta() {
1479
+ const row = this.#guard(() => this.#require().prepare("SELECT \"version\", \"schema\" FROM " + quote(META_TABLE) + " WHERE \"id\" = 1").get());
1480
+ if (row === void 0) return void 0;
1481
+ const version = row.version;
1482
+ const text = row.schema;
1483
+ if (typeof text !== "string") return void 0;
1484
+ if (typeof version !== "number" && typeof version !== "bigint") return void 0;
1485
+ let parsed;
1486
+ try {
1487
+ parsed = JSON.parse(text);
1488
+ } catch {
1489
+ return;
1490
+ }
1491
+ const candidate = {
1492
+ version: Number(version),
1493
+ schema: parsed
1494
+ };
1495
+ if (!isDriverMeta(candidate)) return void 0;
1496
+ return candidate;
1497
+ }
1498
+ /**
1499
+ * Persist `meta` verbatim (as JSON) into the reserved `_meta` table's single
1500
+ * row.
1501
+ *
1502
+ * @param meta - The {@link DriverMeta} to persist
1503
+ */
1504
+ async stamp(meta) {
1505
+ this.#guard(() => {
1506
+ this.#require().prepare("INSERT OR REPLACE INTO " + quote(META_TABLE) + " (\"id\", \"version\", \"schema\") VALUES (1, ?, ?)").run([meta.version, JSON.stringify(meta.schema)]);
1507
+ });
1508
+ }
1509
+ async snapshot(tables) {
1510
+ const database = this.#require();
1511
+ const names = tables ?? [...this.#schema.keys()];
1512
+ const captured = /* @__PURE__ */ new Map();
1513
+ for (const name of names) {
1514
+ const schema = this.#schema.get(name);
1515
+ if (schema === void 0) continue;
1516
+ captured.set(name, {
1517
+ names: schema.columns.map((column) => column.name),
1518
+ rows: database.prepare("SELECT * FROM " + quote(name)).all()
1519
+ });
1520
+ }
1521
+ return async () => {
1522
+ const current = this.#require();
1523
+ current.transaction(() => {
1524
+ for (const [name, snapshot] of captured) {
1525
+ current.exec("DELETE FROM " + quote(name));
1526
+ const statement = current.prepare("INSERT OR REPLACE INTO " + quote(name) + " (" + snapshot.names.map(quote).join(", ") + ") VALUES (" + snapshot.names.map(() => "?").join(", ") + ")");
1527
+ for (const row of snapshot.rows) statement.run(snapshot.names.map((column) => row[column]));
1528
+ }
1529
+ });
1530
+ };
1531
+ }
1532
+ #guard(run) {
1533
+ try {
1534
+ return run();
1535
+ } catch (error) {
1536
+ if (error instanceof DatabaseError) throw error;
1537
+ if (isSQLiteError(error)) {
1538
+ if (error.code === "CONSTRAINT") throw new DatabaseError("CONFLICT", error.message, {
1539
+ cause: error,
1540
+ code: error.code
1541
+ });
1542
+ if (error.code === "CLOSED") throw new DatabaseError("CLOSED", error.message, {
1543
+ cause: error,
1544
+ code: error.code
1545
+ });
1546
+ if (error.code === "BUSY") throw new DatabaseError("DRIVER", error.message, {
1547
+ cause: error,
1548
+ code: error.code,
1549
+ retryable: true
1550
+ });
1551
+ throw new DatabaseError("DRIVER", error.message, {
1552
+ cause: error,
1553
+ code: error.code
1554
+ });
1555
+ }
1556
+ throw new DatabaseError("DRIVER", error instanceof Error ? error.message : String(error), { cause: error });
1557
+ }
1558
+ }
1559
+ #require() {
1560
+ if (this.#database === void 0) throw new DatabaseError("CLOSED", `SQLite database '${this.#path}' is not open`, { path: this.#path });
1561
+ return this.#database;
1562
+ }
1563
+ #table(name) {
1564
+ this.#require();
1565
+ const schema = this.#schema.get(name);
1566
+ if (schema === void 0) throw new DatabaseError("NOT_FOUND", `Table '${name}' is not in the schema`, { table: name });
1567
+ return schema;
1568
+ }
1569
+ #key(key, schema) {
1570
+ const primary = schema.columns.find((column) => column.name === schema.primary);
1571
+ return encodeValue(key, primary === void 0 ? "text" : primary.type);
1572
+ }
1573
+ #applyPlan(database, plan, schema) {
1574
+ for (const step of plan.steps) {
1575
+ const table = step.operation === "table.add" ? step.table.name : step.table;
1576
+ if (step.operation !== "table.add" && !schema.has(table)) throw new DatabaseError("MIGRATION", `migrate: unknown table '${table}'`, { table });
1577
+ for (const sql of stepToSQL(step)) database.exec(sql);
1578
+ if (step.operation === "table.add") schema.set(step.table.name, step.table);
1579
+ else if (step.operation === "table.remove") schema.delete(step.table);
1580
+ else {
1581
+ const existing = schema.get(table);
1582
+ if (existing !== void 0) schema.set(table, stepToSchema(existing, step));
1583
+ }
1584
+ }
1585
+ }
1586
+ };
1587
+ //#endregion
1588
+ //#region src/server/factories.ts
1589
+ /**
1590
+ * Create a persistent JSON-file {@link DriverInterface} for the core database layer.
1591
+ *
1592
+ * @remarks
1593
+ * Pass it to `createDatabase` from `@orkestrel/database` to run the whole typed database +
1594
+ * relations stack against a single JSON file instead of memory — the `Database` /
1595
+ * `Table` / `Query` / relations API is unchanged; only where the bytes live changes.
1596
+ * The driver is the reference `MemoryDriver` plus JSON-file persistence: `open` loads
1597
+ * the file, every mutation flushes the whole store back, and querying runs through
1598
+ * the core engine over `scan` (it is scan-only — no native `records` / `count` /
1599
+ * `aggregate`). A missing, corrupt, or wrong-shaped file starts empty rather than
1600
+ * throwing.
1601
+ *
1602
+ * @param path - The JSON file path data is loaded from and flushed to
1603
+ * @returns A {@link DriverInterface} backed by a JSON file
1604
+ *
1605
+ * @example
1606
+ * ```ts
1607
+ * import { createDatabase } from '@orkestrel/database'
1608
+ * import { stringShape } from '@orkestrel/contract'
1609
+ * import { createJSONDriver } from '@orkestrel/database/server'
1610
+ *
1611
+ * const db = createDatabase({
1612
+ * driver: createJSONDriver('data/app.json'),
1613
+ * tables: { users: { id: stringShape(), name: stringShape() } },
1614
+ * })
1615
+ * await db.table('users').set({ id: 'u1', name: 'Ada' }) // persisted to app.json
1616
+ * ```
1617
+ */
1618
+ function createJSONDriver(path) {
1619
+ return new JSONDriver(path);
1620
+ }
1621
+ /**
1622
+ * Create a trusted-mode SQLite {@link DriverInterface} for the core database layer.
1623
+ *
1624
+ * @remarks
1625
+ * Pass it to `createDatabase` from `@orkestrel/database` to run the whole typed database +
1626
+ * relations stack against a real SQLite database — the `Database` / `Table` /
1627
+ * `Query` / relations API is unchanged; only where the bytes live changes. Built
1628
+ * on the published `@orkestrel/sqlite` synchronous wrapper: `open` issues real
1629
+ * typed `CREATE TABLE` / `CREATE INDEX` statements (reopen-safe) plus a reserved
1630
+ * `_meta` table for `meta()` / `stamp()` — avoid naming a table `_meta`.
1631
+ * Querying, paging, and aggregation run natively (`records` / `count` /
1632
+ * `aggregate` / `stream`); `transaction` and `migrate` use real `BEGIN` /
1633
+ * `COMMIT` / `ROLLBACK`, so `migrate` is atomic even mid-plan.
1634
+ *
1635
+ * @param options - A bare database file path (`':memory:'` by default, for
1636
+ * back-compat), or a full {@link SQLiteDriverOptions} bag (`path`,
1637
+ * `readonly`, `timeout`, `foreignKeys`, `pragmas`)
1638
+ * @returns A {@link DriverInterface} backed by SQLite
1639
+ *
1640
+ * @example
1641
+ * ```ts
1642
+ * import { createDatabase } from '@orkestrel/database'
1643
+ * import { stringShape } from '@orkestrel/contract'
1644
+ * import { createSQLiteDriver } from '@orkestrel/database/server'
1645
+ *
1646
+ * const db = createDatabase({
1647
+ * driver: createSQLiteDriver('data/app.sqlite'),
1648
+ * tables: { users: { id: stringShape(), name: stringShape() } },
1649
+ * })
1650
+ * await db.table('users').set({ id: 'u1', name: 'Ada' }) // persisted to app.sqlite
1651
+ *
1652
+ * // Or with options:
1653
+ * createSQLiteDriver({ path: 'data/app.sqlite', pragmas: { journal_mode: 'WAL' } })
1654
+ * ```
1655
+ */
1656
+ function createSQLiteDriver(options = ":memory:") {
1657
+ const resolved = isString(options) ? { path: options } : options;
1658
+ return new SQLiteDriver(resolved.path ?? ":memory:", resolved);
1659
+ }
1660
+ //#endregion
1661
+ export { EXACT_COLUMN_TYPES, EXACT_RANGE_COLUMN_TYPES, JSONDriver, META_TABLE, SQLiteDriver, aggregateSQL, columnSQL, compileCriteria, compileOrder, compilePage, compileWhere, createJSONDriver, createSQLiteDriver, declaredType, decodeRow, decodeValue, encodeRow, encodeValue, escapeLike, fieldColumn, fragment, generateKey, indexName, isExactCondition, isExactCriteria, isExactOrder, jsonTypeColumn, matchesDeclaredType, quote, schemaToIndexes, schemaToTable, stepToSQL, stepToSchema, valueType };
1662
+
1663
+ //# sourceMappingURL=index.js.map