@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.
@@ -1,128 +1,189 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _orkestrel_contract = require("@orkestrel/contract");
2
3
  let node_crypto = require("node:crypto");
3
- let _src_core = require("../core/index.js");
4
+ let _src_core = require("../core/index.cjs");
4
5
  let node_fs_promises = require("node:fs/promises");
5
6
  let node_path = require("node:path");
6
- Object.freeze([
7
- "null",
8
- "boolean",
9
- "object",
10
- "array",
11
- "number",
12
- "integer",
13
- "string"
14
- ]);
15
- /** Determine whether a value is a string. */
16
- function isString(value) {
17
- return typeof value === "string";
18
- }
19
- /** Determine whether a value is a boolean. */
20
- function isBoolean(value) {
21
- return typeof value === "boolean";
22
- }
7
+ let _orkestrel_sqlite = require("@orkestrel/sqlite");
8
+ //#region src/server/helpers.ts
23
9
  /**
24
- * Determine whether a value is a non-null object.
10
+ * Generate a fresh unique key — a v4 UUID string, backed by `node:crypto`.
25
11
  *
26
12
  * @remarks
27
- * `true` for arrays, class instances, plain objects, `Map`, `Set`, etc. — use
28
- * {@link isRecord} when you need a plain-record check.
13
+ * Supply this as {@link import('@orkestrel/database').DatabaseOptions.key} so a table mints
14
+ * a key when a written row lacks its primary-key value. Strings work as keys on
15
+ * every backend; supply your own key values directly to use numeric keys instead.
16
+ *
17
+ * @returns A new UUID string
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * const db = createDatabase({ driver, tables, key: generateKey })
22
+ * ```
29
23
  */
30
- function isObject(value) {
31
- return typeof value === "object" && value !== null;
24
+ function generateKey() {
25
+ return (0, node_crypto.randomUUID)();
32
26
  }
33
27
  /**
34
- * Determine whether a value is a plain record (object literal or null-prototype),
35
- * not an array or class instance.
28
+ * The declared {@link ColumnType}s whose SQL EQUALITY comparisons (`equals` /
29
+ * `not` / `any` / `none`) and `starts` / `ends` compiles are provably
30
+ * engine-exact under declared-type trust — `text` / `integer` / `real` /
31
+ * `boolean`; a `json` or `blob` column always refines instead.
36
32
  *
37
33
  * @remarks
38
- * Use instead of {@link isObject} to distinguish a plain `{}` /
39
- * `Object.create(null)` from arrays, `Date`, `Map`, etc. The prototype-chain
40
- * test is realm-agnostic: rather than comparing against the current realm's
41
- * `Object.prototype` (which a plain object from another `vm.Context`, iframe,
42
- * or worker would fail), it accepts any value whose prototype is `null`, OR
43
- * whose prototype's own prototype is `null` the shape every plain object
44
- * has in every realm, since `Object.prototype` itself always sits one step
45
- * above `null`. Arrays and class instances are still rejected: an array's
46
- * prototype chain runs through `Array.prototype` before `null`, and a class
47
- * instance's runs through the class's own prototype. The whole body runs
48
- * inside `attempt` (AGENTS §14) so a revoked `Proxy` or a hostile
49
- * `getPrototypeOf` trap cannot escape as a thrown error.
50
- */
51
- function isRecord(value) {
52
- const outcome = attempt(() => {
53
- if (!isObject(value) || isArray(value)) return false;
54
- const prototype = Object.getPrototypeOf(value);
55
- return prototype === null || Object.getPrototypeOf(prototype) === null;
56
- });
57
- return outcome.success && outcome.value;
58
- }
59
- /** Determine whether a value is an array. */
60
- function isArray(value) {
61
- return Array.isArray(value);
62
- }
34
+ * This set governs equality and prefix/suffix matching only. RANGE
35
+ * comparisons (`above` / `below` / `from` / `to` / `between`) and `ORDER BY`
36
+ * are exact for `integer` / `real` / `boolean` but NOT for `text`: compiled
37
+ * SQL orders/ranges under SQLite's default BINARY collation, which compares
38
+ * TEXT byte-for-byte as UTF-8 equivalent to Unicode CODE-POINT order
39
+ * while the core engine's `compareValues` orders JS strings with `<`, which
40
+ * compares UTF-16 CODE-UNIT order. The two orders diverge for supplementary-
41
+ * plane characters (code points U+10000, e.g. many emoji): a lead surrogate
42
+ * (`\uD800`–`\uDBFF`) sorts BELOW ``–`￿` in code-unit order, while
43
+ * its code point sorts ABOVE them. So `isExactCondition`'s range family and
44
+ * `isExactOrder` exclude `text`, refining through the core engine instead. A
45
+ * future opt-in "trusted collation" mode (the caller vouches the column's
46
+ * values are BMP-only, or a custom SQLite collation matching `compareValues`
47
+ * is registered) could restore native text ranges/ordering.
48
+ */
49
+ var EXACT_COLUMN_TYPES = [
50
+ "text",
51
+ "integer",
52
+ "real",
53
+ "boolean"
54
+ ];
63
55
  /**
64
- * Invoke a callback and capture its outcome as a {@link Result}, never letting
65
- * a throw escape.
56
+ * The declared {@link ColumnType}s whose SQL RANGE comparisons
57
+ * (`above` / `below` / `from` / `to` / `between`) and `ORDER BY` compiles are
58
+ * provably engine-exact — `integer` / `real` / `boolean` only. `text` is
59
+ * excluded: see {@link EXACT_COLUMN_TYPES}'s remarks for the BINARY-collation
60
+ * (code-point) vs. JS `<` (code-unit) divergence on supplementary-plane
61
+ * characters.
62
+ */
63
+ var EXACT_RANGE_COLUMN_TYPES = [
64
+ "integer",
65
+ "real",
66
+ "boolean"
67
+ ];
68
+ /**
69
+ * Whether a value's runtime type matches a column's declared exact type —
70
+ * the operand side of the declared-type-trust proof.
66
71
  *
67
72
  * @remarks
68
- * The single sanctioned never-throw boundary for the guards (AGENTS §14). The
69
- * `whereOf`, `lazyOf`, and `transformOf` combinators invoke caller-supplied
70
- * callbacks *inside* a guard body, yet a guard must NEVER throw — it returns a
71
- * `boolean`. This converts a throwing callback into a `Failure` so the
72
- * surrounding guard can treat it as a non-match instead of propagating the
73
- * exception, written once and shared rather than copy-pasted as ad-hoc
74
- * `try`/`catch`.
75
- *
76
- * @param callback - The callback to invoke with no arguments
77
- * @returns A `Success` carrying the return value, or a `Failure` carrying the
78
- * thrown reason normalised to an `Error`
73
+ * `text` string, `integer` / `real` FINITE number (`NaN` / `±Infinity`
74
+ * fail), `boolean` boolean. Backs {@link isExactCondition}'s operand checks.
75
+ *
76
+ * @param value - The condition operand to test
77
+ * @param type - The column's declared portable type
78
+ * @returns `true` when the operand's runtime type matches the declared type
79
79
  *
80
80
  * @example
81
81
  * ```ts
82
- * const outcome = attempt(() => predicate(value))
83
- * return outcome.success && outcome.value
82
+ * matchesDeclaredType('Ada', 'text') // true
83
+ * matchesDeclaredType(Number.NaN, 'integer') // false — only finite numbers
84
84
  * ```
85
85
  */
86
- function attempt(callback) {
87
- try {
88
- return {
89
- success: true,
90
- value: callback()
91
- };
92
- } catch (reason) {
93
- if (reason instanceof Error) return {
94
- success: false,
95
- error: reason
96
- };
97
- let message = "Unknown thrown value";
98
- try {
99
- message = String(reason);
100
- } catch {}
101
- return {
102
- success: false,
103
- error: new Error(message)
104
- };
105
- }
86
+ function matchesDeclaredType(value, type) {
87
+ if (type === "text") return (0, _orkestrel_contract.isString)(value);
88
+ if (type === "boolean") return (0, _orkestrel_contract.isBoolean)(value);
89
+ return (0, _orkestrel_contract.isFiniteNumber)(value);
106
90
  }
107
- //#endregion
108
- //#region src/server/helpers.ts
109
91
  /**
110
- * Generate a fresh unique key a v4 UUID string, backed by `node:crypto`.
92
+ * Whether one {@link Condition} compiles to SQL that is PROVABLY identical to
93
+ * the core engine's `matchesCondition` for every value its column's declared
94
+ * type can store.
111
95
  *
112
96
  * @remarks
113
- * Supply this as {@link import('@src/core').DatabaseOptions.key} so a table mints
114
- * a key when a written row lacks its primary-key value. Strings work as keys on
115
- * every backend; supply your own key values directly to use numeric keys instead.
97
+ * `false` for a nested `FieldPath` (an array), a column absent from `schema`,
98
+ * or a column whose declared type is not `text` / `integer` / `real` /
99
+ * `boolean` (a `json` / `blob` column) EXCEPT `absent` / `present`, which
100
+ * compile to `IS NULL` / `IS NOT NULL` and match `decodeRow`'s "a stored NULL
101
+ * decodes to `undefined`" rule for every column type, so they are exact
102
+ * regardless of declared type. `equals` / `not` require a operand matching the
103
+ * column's declared type (a `null` / `undefined` operand is never exact here —
104
+ * `encodeRow` stores both an explicit `null` and an absent field as SQL NULL,
105
+ * so native `IS NULL` semantics cannot match the engine's `deepEqual`-over-
106
+ * decoded-rows truth). `above` / `below` / `from` / `to` / `between` are exact
107
+ * ONLY for a declared type in {@link EXACT_RANGE_COLUMN_TYPES} (`integer` /
108
+ * `real` / `boolean`) — a `text` column's range conditions REFINE, because
109
+ * SQLite's default BINARY collation orders TEXT by Unicode CODE POINT while
110
+ * the core engine's `compareValues` orders JS strings by UTF-16 CODE UNIT,
111
+ * and the two diverge for supplementary-plane characters (see
112
+ * {@link EXACT_COLUMN_TYPES}'s remarks for the full rationale).
113
+ * `any` / `none` require a NON-EMPTY list where every element matches (an empty
114
+ * list is exact under neither: the engine's `any([])` matches nothing while
115
+ * `none([])` matches everything, and SQL `IN ()` is a syntax error) — these
116
+ * stay exact on `text` (byte equality is collation-independent and engine-
117
+ * identical). `starts` / `ends` are exact only on a `text` column with a
118
+ * string operand (case-sensitive `substr` compile, see {@link fragment}) —
119
+ * likewise collation-independent. `like` / `glob` are NEVER exact — SQLite
120
+ * `LIKE` folds case ASCII-only against the engine's Unicode fold, and `GLOB`
121
+ * has character classes the engine treats literally.
122
+ *
123
+ * @param condition - The condition to test
124
+ * @param schema - The table's schema
125
+ * @returns Whether `condition` is exact
126
+ */
127
+ function isExactCondition(condition, schema) {
128
+ if (!(0, _orkestrel_contract.isString)(condition.column)) return false;
129
+ const column = schema.columns.find((candidate) => candidate.name === condition.column);
130
+ if (column === void 0) return false;
131
+ if (condition.operator === "absent" || condition.operator === "present") return true;
132
+ if (!EXACT_COLUMN_TYPES.some((type) => type === column.type)) return false;
133
+ const first = condition.values[0];
134
+ const second = condition.values[1];
135
+ switch (condition.operator) {
136
+ case "equals":
137
+ case "not": return matchesDeclaredType(first, column.type);
138
+ case "above":
139
+ case "below":
140
+ case "from":
141
+ case "to": return EXACT_RANGE_COLUMN_TYPES.some((type) => type === column.type) && matchesDeclaredType(first, column.type);
142
+ case "between": return EXACT_RANGE_COLUMN_TYPES.some((type) => type === column.type) && matchesDeclaredType(first, column.type) && matchesDeclaredType(second, column.type);
143
+ case "any":
144
+ case "none": return condition.values.length > 0 && condition.values.every((value) => matchesDeclaredType(value, column.type));
145
+ case "starts":
146
+ case "ends": return column.type === "text" && (0, _orkestrel_contract.isString)(first);
147
+ case "like":
148
+ case "glob": return false;
149
+ }
150
+ }
151
+ /**
152
+ * Whether one {@link Order} term's column compiles to an `ORDER BY` that
153
+ * matches the engine's {@link import('@src/core').sortRows} exactly.
116
154
  *
117
- * @returns A new UUID string
155
+ * @remarks
156
+ * `false` for a nested `FieldPath`, a column absent from `schema`, or a
157
+ * declared type outside {@link EXACT_RANGE_COLUMN_TYPES} (`integer` / `real` /
158
+ * `boolean`). `text` is NOT exact here: SQLite's default BINARY collation
159
+ * orders TEXT by Unicode code point while the core engine's `compareValues`
160
+ * orders JS strings by UTF-16 code unit, and the two diverge for
161
+ * supplementary-plane characters (see {@link EXACT_COLUMN_TYPES}'s remarks) —
162
+ * a `text` order term REFINES through the core engine instead.
163
+ *
164
+ * @param order - The order term to test
165
+ * @param schema - The table's schema
166
+ * @returns Whether `order` is exact
167
+ */
168
+ function isExactOrder(order, schema) {
169
+ if (!(0, _orkestrel_contract.isString)(order.column)) return false;
170
+ const column = schema.columns.find((candidate) => candidate.name === order.column);
171
+ if (column === void 0) return false;
172
+ return EXACT_RANGE_COLUMN_TYPES.some((type) => type === column.type);
173
+ }
174
+ /**
175
+ * Whether a whole {@link Criteria} is exact — every condition and every order
176
+ * term is exact. `limit` / `offset` never affect exactness (SQL `LIMIT` /
177
+ * `OFFSET` are always engine-identical).
118
178
  *
119
- * @example
120
- * ```ts
121
- * const db = createDatabase({ driver, tables, key: generateKey })
122
- * ```
179
+ * @param criteria - The criteria to test
180
+ * @param schema - The table's schema
181
+ * @returns Whether every part of `criteria` is exact
123
182
  */
124
- function generateKey() {
125
- return (0, node_crypto.randomUUID)();
183
+ function isExactCriteria(criteria, schema) {
184
+ const conditions = criteria.conditions ?? [];
185
+ const order = criteria.order ?? [];
186
+ return conditions.every((condition) => isExactCondition(condition, schema)) && order.every((term) => isExactOrder(term, schema));
126
187
  }
127
188
  /**
128
189
  * Map a portable {@link ColumnType} to its SQLite column type.
@@ -192,7 +253,7 @@ function quote(identifier) {
192
253
  * ```
193
254
  */
194
255
  function fieldColumn(path) {
195
- if (isString(path)) return quote(path);
256
+ if ((0, _orkestrel_contract.isString)(path)) return quote(path);
196
257
  const rest = path.slice(1).map((key) => "." + key.replaceAll("'", "''")).join("");
197
258
  return "json_extract(" + quote(path[0]) + ", '$" + rest + "')";
198
259
  }
@@ -362,12 +423,40 @@ function schemaToTable(schema) {
362
423
  return "CREATE TABLE IF NOT EXISTS " + quote(schema.name) + " (" + columns.join(", ") + ", PRIMARY KEY (" + quote(schema.primary) + "))";
363
424
  }
364
425
  /**
426
+ * Build a collision-free SQL index name for a table + column-group index —
427
+ * shared by {@link schemaToIndexes} (an `open`-time `CREATE INDEX`) and
428
+ * {@link stepToSQL}'s `index.add` / `index.remove` (a migration-time DDL),
429
+ * so a plan-built index name always matches one `open` would have created.
430
+ *
431
+ * @remarks
432
+ * A naive `idx_<table>_<cols joined by _>` is AMBIGUOUS: table `'a_b'` with
433
+ * column `'c'` and table `'a'` with columns `['b', 'c']` both produce
434
+ * `idx_a_b_c`. This encodes each part (the table name, then each column name)
435
+ * length-prefixed (`<len>_<part>`) so the boundary between parts is always
436
+ * unambiguous, however the names themselves are punctuated.
437
+ *
438
+ * @param table - The table name
439
+ * @param columns - The index's column names, in order
440
+ * @returns The deterministic, collision-free index identifier (unquoted)
441
+ *
442
+ * @example
443
+ * ```ts
444
+ * indexName('users', ['name']) // 'idx_5_users_4_name'
445
+ * indexName('a_b', ['c']) // 'idx_3_a_b_1_c'
446
+ * indexName('a', ['b', 'c']) // 'idx_1_a_1_b_1_c'
447
+ * ```
448
+ */
449
+ function indexName(table, columns) {
450
+ return "idx_" + [table, ...columns].map((part) => String(part.length) + "_" + part).join("_");
451
+ }
452
+ /**
365
453
  * Project a {@link TableSchema} to the `CREATE INDEX IF NOT EXISTS` statements a
366
454
  * SQLite driver's `open` issues for its declared indexes.
367
455
  *
368
456
  * @remarks
369
- * One statement per index group; the index name is `idx_<table>_<columns joined
370
- * by _>`, matching the driver's naming so a repeated `open` is idempotent.
457
+ * One statement per index group; the index name is built by {@link indexName}
458
+ * (collision-free and deterministic), matching the driver's naming so a
459
+ * repeated `open` is idempotent.
371
460
  *
372
461
  * @param schema - The table's schema
373
462
  * @returns One `CREATE INDEX IF NOT EXISTS …` statement per declared index
@@ -375,15 +464,114 @@ function schemaToTable(schema) {
375
464
  * @example
376
465
  * ```ts
377
466
  * schemaToIndexes(schema)
378
- * // ['CREATE INDEX IF NOT EXISTS "idx_users_name" ON "users" ("name")']
467
+ * // ['CREATE INDEX IF NOT EXISTS "idx_5_users_4_name" ON "users" ("name")']
379
468
  * ```
380
469
  */
381
470
  function schemaToIndexes(schema) {
382
- return schema.indexes.map((group) => "CREATE INDEX IF NOT EXISTS " + quote("idx_" + schema.name + "_" + group.join("_")) + " ON " + quote(schema.name) + " (" + group.map(quote).join(", ") + ")");
471
+ return schema.indexes.map((group) => "CREATE INDEX IF NOT EXISTS " + quote(indexName(schema.name, group)) + " ON " + quote(schema.name) + " (" + group.map(quote).join(", ") + ")");
472
+ }
473
+ /**
474
+ * Project one {@link MigrationStep} to the DDL statement(s) a SQLite driver's
475
+ * `migrate` executes for it.
476
+ *
477
+ * @remarks
478
+ * `table.add` emits the `CREATE TABLE` plus one `CREATE INDEX` per declared
479
+ * index (via {@link schemaToTable} / {@link schemaToIndexes}); `table.remove`
480
+ * emits `DROP TABLE IF EXISTS`; `column.add` / `column.remove` emit `ALTER
481
+ * TABLE … ADD COLUMN` / `… DROP COLUMN`; `index.add` / `index.remove` emit
482
+ * `CREATE INDEX IF NOT EXISTS` / `DROP INDEX IF EXISTS`, naming the index the
483
+ * same way `schemaToIndexes` does (`idx_<table>_<columns joined by _>`) so a
484
+ * plan-built index matches one `open` would have created. Whether the named
485
+ * table actually exists is the caller's concern (a driver's `migrate` checks
486
+ * its own declared schema before running these statements) — this projection
487
+ * is pure and never inspects live state.
488
+ *
489
+ * @param step - The migration step to project
490
+ * @returns The DDL statement(s) that apply the step
491
+ *
492
+ * @example
493
+ * ```ts
494
+ * stepToSQL({ operation: 'column.remove', table: 'users', column: 'legacy' })
495
+ * // ['ALTER TABLE "users" DROP COLUMN "legacy"']
496
+ * ```
497
+ */
498
+ function stepToSQL(step) {
499
+ switch (step.operation) {
500
+ case "table.add": return [schemaToTable(step.table), ...schemaToIndexes(step.table)];
501
+ case "table.remove": return ["DROP TABLE IF EXISTS " + quote(step.table)];
502
+ case "column.add": return ["ALTER TABLE " + quote(step.table) + " ADD COLUMN " + quote(step.column.name) + " " + columnSQL(step.column.type)];
503
+ case "column.remove": return ["ALTER TABLE " + quote(step.table) + " DROP COLUMN " + quote(step.column)];
504
+ case "index.add": return ["CREATE INDEX IF NOT EXISTS " + quote(indexName(step.table, step.index)) + " ON " + quote(step.table) + " (" + step.index.map(quote).join(", ") + ")"];
505
+ case "index.remove": return ["DROP INDEX IF EXISTS " + quote(indexName(step.table, step.index))];
506
+ }
507
+ }
508
+ /**
509
+ * Project one {@link MigrationStep} onto its table's declared {@link TableSchema}
510
+ * — the bookkeeping counterpart to {@link stepToSQL} (which projects the DDL a
511
+ * driver's `migrate` runs against the live database).
512
+ *
513
+ * @remarks
514
+ * `column.add` / `column.remove` add / filter the named column;
515
+ * `index.add` / `index.remove` add / filter the matching index group (an exact
516
+ * ordered match on `index`). `table.add` / `table.remove` act on a WHOLE
517
+ * schema map rather than one table's shape, so they are the caller's concern
518
+ * (a driver's `migrate` applies them directly against its table map) — passed
519
+ * here, they return `schema` unchanged.
520
+ *
521
+ * @param schema - The table's current declared schema
522
+ * @param step - The migration step to project onto it
523
+ * @returns The table's schema after the step
524
+ *
525
+ * @example
526
+ * ```ts
527
+ * stepToSchema(schema, { operation: 'column.remove', table: 'users', column: 'legacy' })
528
+ * // schema with the 'legacy' column dropped from `columns`
529
+ * ```
530
+ */
531
+ function stepToSchema(schema, step) {
532
+ switch (step.operation) {
533
+ case "column.add": return {
534
+ ...schema,
535
+ columns: [...schema.columns, step.column]
536
+ };
537
+ case "column.remove": return {
538
+ ...schema,
539
+ columns: schema.columns.filter((column) => column.name !== step.column)
540
+ };
541
+ case "index.add": return {
542
+ ...schema,
543
+ indexes: [...schema.indexes, step.index]
544
+ };
545
+ case "index.remove": return {
546
+ ...schema,
547
+ indexes: schema.indexes.filter((group) => !(group.length === step.index.length && group.every((name, position) => name === step.index[position])))
548
+ };
549
+ case "table.add":
550
+ case "table.remove": return schema;
551
+ }
383
552
  }
384
553
  //#endregion
385
554
  //#region src/server/compilers.ts
386
555
  /**
556
+ * Compile a NESTED {@link FieldPath} to the `json_type(<col>, <path>)` SQL
557
+ * expression — the {@link fieldColumn} `json_extract` sibling used to tell a
558
+ * PRESENT JSON `null` apart from an ABSENT path (both read back as SQL `NULL`
559
+ * through `json_extract`, but `json_type` reports `'null'` for the former and
560
+ * SQL `NULL` for the latter).
561
+ *
562
+ * @param path - The nested field path (a column plus its JSON keys)
563
+ * @returns The SQL expression reading the value's JSON type
564
+ *
565
+ * @example
566
+ * ```ts
567
+ * jsonTypeColumn(['payload', 'user', 'id']) // "json_type(\"payload\", '$.user.id')"
568
+ * ```
569
+ */
570
+ function jsonTypeColumn(path) {
571
+ const rest = path.slice(1).map((key) => "." + key.replaceAll("'", "''")).join("");
572
+ return "json_type(" + quote(path[0]) + ", '$" + rest + "')";
573
+ }
574
+ /**
387
575
  * Escape `\`, `%`, and `_` (each with a leading `\`) so a `starts` / `ends`
388
576
  * operand is matched literally under the `LIKE … ESCAPE '\'` clause.
389
577
  *
@@ -443,7 +631,7 @@ function valueType(value) {
443
631
  }
444
632
  /**
445
633
  * Compile one condition to its `<column> <operator>` SQL fragment and the params
446
- * it binds.
634
+ * it binds — engine-exact under SQL's three-valued NULL logic.
447
635
  *
448
636
  * @remarks
449
637
  * Every operand is run through `encodeValue`, so a bound value matches the SQL
@@ -452,10 +640,53 @@ function valueType(value) {
452
640
  * encodes each operand as the NATIVE scalar `json_extract` returns, derived from
453
641
  * the operand's runtime type (per-operand, since `between` / `any` / `none` can
454
642
  * mix types). `any` / `none` collapse an empty list to a constant (`0` matches
455
- * nothing, `1` matches all) with no params. A nested field with a null/undefined
456
- * operand under `equals` / `not` compiles to `IS NULL` / `IS NOT NULL` (no bound
457
- * param) instead of `= ?` / `!= ?`, matching the engine's treatment of a
458
- * present-but-null nested value.
643
+ * nothing, `1` matches all) with no params.
644
+ *
645
+ * The core engine's total order ranks `undefined` (rank 0) BELOW `null`
646
+ * (rank 1) (see `compareValues`), so a MISSING/`NULL` column MATCHES
647
+ * `below` / `to` / a scalar `not` / `none` — the opposite of raw SQL, where a
648
+ * comparison against `NULL` is `NULL` (excluded). This fragment replicates the
649
+ * engine exactly. Truth table (`value` = the engine's decoded field read; a
650
+ * FLAT column's stored `NULL` decodes to `undefined` per `decodeRow`, so a
651
+ * flat `value` is NEVER a present `null` — only a NESTED path can be
652
+ * present-but-`null`):
653
+ *
654
+ * ```text
655
+ * operator | value=undefined (absent) | value=null (nested only) | value=scalar
656
+ * --------------------|--------------------------------|---------------------------|-------------
657
+ * equals, first=null | no match | MATCH | no match
658
+ * equals, first=X | no match | no match | value===X
659
+ * not, first=null | MATCH (flat: unconditionally; | no match | MATCH
660
+ * | nested: absent still matches)| |
661
+ * not, first=X | MATCH | MATCH | value!==X
662
+ * below/to, first=X | MATCH (rank 0 < rank(X)) | MATCH (rank 1 < rank(X)) | rank compare
663
+ * none, list=[…] | MATCH (no scalar rank-equal) | MATCH | not-in-list
664
+ * any, list=[…] | no match | no match | in-list
665
+ * above/from/between | no match | no match | rank compare
666
+ * like/glob/starts/… | no match (not a string) | no match | string test
667
+ * present | false | false | true
668
+ * absent | true | true | false
669
+ * ```
670
+ *
671
+ * Because a flat column's `NULL` always decodes to `undefined`, `equals`
672
+ * against a `null` operand needs no special flat compilation (`col = ?`
673
+ * binding a `NULL` param is already always-false in SQL, matching "no match"
674
+ * above) — but flat `not` against `null` must match EVERY row (both the
675
+ * absent and the scalar rows), which `col != ? OR col IS NULL` cannot express
676
+ * (it only catches the `IS NULL` row), so a flat `not`-with-`null`-operand
677
+ * compiles to the constant `1`.
678
+ *
679
+ * A NESTED path can be present-but-`null` (a stored JSON `null`), which
680
+ * `json_extract` reads back as SQL `NULL` — indistinguishable from an ABSENT
681
+ * path. `json_type(col, path)` disambiguates them (`'null'` for present-null,
682
+ * SQL `NULL` for absent), so nested `equals` / `not` against a `null` operand
683
+ * compile through `json_type` instead of `IS NULL` / `IS NOT NULL`.
684
+ *
685
+ * Every other MATCH-on-null-or-absent row is expressed uniformly (flat and
686
+ * nested alike) as `(<column> <op> ? OR <column> IS NULL)` — for a nested
687
+ * path, `json_extract` already collapses BOTH absent and present-null to SQL
688
+ * `NULL`, so `IS NULL` catches both in one clause; for a flat column there is
689
+ * only the absent case to catch.
459
690
  *
460
691
  * @param condition - The condition to compile
461
692
  * @param schema - The table's schema (for declared column types)
@@ -465,20 +696,23 @@ function valueType(value) {
465
696
  * ```ts
466
697
  * fragment({ column: 'age', operator: 'above', values: [18], connector: 'and' }, schema)
467
698
  * // { sql: '"age" > ?', params: [18] }
699
+ * fragment({ column: 'age', operator: 'below', values: [18], connector: 'and' }, schema)
700
+ * // { sql: '("age" < ? OR "age" IS NULL)', params: [18] }
468
701
  * ```
469
702
  */
470
703
  function fragment(condition, schema) {
471
704
  const column = fieldColumn(condition.column);
472
- const nested = !isString(condition.column);
473
- const declared = isString(condition.column) ? declaredType(condition.column, schema) : void 0;
705
+ const nested = !(0, _orkestrel_contract.isString)(condition.column);
706
+ const declared = (0, _orkestrel_contract.isString)(condition.column) ? declaredType(condition.column, schema) : void 0;
474
707
  const encode = (value) => encodeValue(value, nested ? valueType(value) : declared ?? "json");
475
708
  const first = condition.values[0];
476
709
  const second = condition.values[1];
477
- const nullOperand = nested && (first === null || first === void 0);
710
+ const nullOperand = first === null || first === void 0;
711
+ const jsonType = !(0, _orkestrel_contract.isString)(condition.column) ? jsonTypeColumn(condition.column) : "";
478
712
  switch (condition.operator) {
479
713
  case "equals":
480
- if (nullOperand) return {
481
- sql: column + " IS NULL",
714
+ if (nullOperand && nested) return {
715
+ sql: jsonType + " = 'null'",
482
716
  params: []
483
717
  };
484
718
  return {
@@ -486,12 +720,18 @@ function fragment(condition, schema) {
486
720
  params: [encode(first)]
487
721
  };
488
722
  case "not":
489
- if (nullOperand) return {
490
- sql: column + " IS NOT NULL",
491
- params: []
492
- };
723
+ if (nullOperand) {
724
+ if (nested) return {
725
+ sql: "(" + jsonType + " IS NULL OR " + jsonType + " != 'null')",
726
+ params: []
727
+ };
728
+ return {
729
+ sql: "1",
730
+ params: []
731
+ };
732
+ }
493
733
  return {
494
- sql: column + " != ?",
734
+ sql: "(" + column + " != ? OR " + column + " IS NULL)",
495
735
  params: [encode(first)]
496
736
  };
497
737
  case "above": return {
@@ -499,7 +739,7 @@ function fragment(condition, schema) {
499
739
  params: [encode(first)]
500
740
  };
501
741
  case "below": return {
502
- sql: column + " < ?",
742
+ sql: "(" + column + " < ? OR " + column + " IS NULL)",
503
743
  params: [encode(first)]
504
744
  };
505
745
  case "from": return {
@@ -507,7 +747,7 @@ function fragment(condition, schema) {
507
747
  params: [encode(first)]
508
748
  };
509
749
  case "to": return {
510
- sql: column + " <= ?",
750
+ sql: "(" + column + " <= ? OR " + column + " IS NULL)",
511
751
  params: [encode(first)]
512
752
  };
513
753
  case "between": return {
@@ -522,14 +762,30 @@ function fragment(condition, schema) {
522
762
  sql: column + " GLOB ?",
523
763
  params: [encode(first)]
524
764
  };
525
- case "starts": return {
526
- sql: column + " LIKE ? ESCAPE '\\'",
527
- params: [(isString(first) ? escapeLike(first) : "") + "%"]
528
- };
529
- case "ends": return {
530
- sql: column + " LIKE ? ESCAPE '\\'",
531
- params: ["%" + (isString(first) ? escapeLike(first) : "")]
532
- };
765
+ case "starts": {
766
+ const text = (0, _orkestrel_contract.isString)(first) ? first : "";
767
+ if (text === "") return {
768
+ sql: "typeof(" + column + ") = 'text'",
769
+ params: []
770
+ };
771
+ const length = Array.from(text).length;
772
+ return {
773
+ sql: "(typeof(" + column + ") = 'text' AND substr(" + column + ", 1, " + length + ") = ?)",
774
+ params: [encode(first)]
775
+ };
776
+ }
777
+ case "ends": {
778
+ const text = (0, _orkestrel_contract.isString)(first) ? first : "";
779
+ if (text === "") return {
780
+ sql: "typeof(" + column + ") = 'text'",
781
+ params: []
782
+ };
783
+ const length = Array.from(text).length;
784
+ return {
785
+ sql: "(typeof(" + column + ") = 'text' AND substr(" + column + ", -" + length + ") = ?)",
786
+ params: [encode(first)]
787
+ };
788
+ }
533
789
  case "any":
534
790
  if (condition.values.length === 0) return {
535
791
  sql: "0",
@@ -545,7 +801,7 @@ function fragment(condition, schema) {
545
801
  params: []
546
802
  };
547
803
  return {
548
- sql: column + " NOT IN (" + condition.values.map(() => "?").join(", ") + ")",
804
+ sql: "(" + column + " NOT IN (" + condition.values.map(() => "?").join(", ") + ") OR " + column + " IS NULL)",
549
805
  params: condition.values.map(encode)
550
806
  };
551
807
  case "absent": return {
@@ -564,6 +820,10 @@ function fragment(condition, schema) {
564
820
  *
565
821
  * @remarks
566
822
  * The first condition's connector is ignored, per the {@link Condition} types.
823
+ * Every fragment (see {@link fragment}'s truth table) replicates the core
824
+ * engine's total order EXACTLY under SQL's three-valued NULL logic, so this
825
+ * clause matches `applyCriteria` row-for-row over the same table — a native
826
+ * `records` / `count` read never disagrees with a scan-and-filter fallback.
567
827
  *
568
828
  * @param conditions - The conditions to fold
569
829
  * @param schema - The table's schema
@@ -622,7 +882,7 @@ function compileWhere(conditions, schema) {
622
882
  */
623
883
  function compileOrder(order, schema) {
624
884
  const terms = (order ?? []).map((term) => fieldColumn(term.column) + (term.direction === "descending" ? " DESC" : " ASC"));
625
- if (!(order ?? []).some((term) => isString(term.column) && term.column === schema.primary)) terms.push(quote(schema.primary));
885
+ if (!(order ?? []).some((term) => (0, _orkestrel_contract.isString)(term.column) && term.column === schema.primary)) terms.push(quote(schema.primary));
626
886
  return terms.length === 0 ? "" : "ORDER BY " + terms.join(", ");
627
887
  }
628
888
  /**
@@ -702,6 +962,18 @@ function compileCriteria(criteria, schema) {
702
962
  };
703
963
  }
704
964
  //#endregion
965
+ //#region src/server/constants.ts
966
+ /**
967
+ * The reserved metadata table the {@link SQLiteDriver} creates on `open` to
968
+ * persist its stamped `DriverMeta` (`version` + declared schema JSON) — the
969
+ * SQLite realization of the `meta` / `stamp` driver hooks.
970
+ *
971
+ * @remarks
972
+ * A single-row table (`id = 1`). A user table named `_meta` collides with the
973
+ * reservation — the caller's concern to avoid, documented on the driver class.
974
+ */
975
+ var META_TABLE = "_meta";
976
+ //#endregion
705
977
  //#region src/server/drivers/JSONDriver.ts
706
978
  /**
707
979
  * A persistent {@link DriverInterface} backed by a single JSON file — the
@@ -890,38 +1162,19 @@ var JSONDriver = class {
890
1162
  } catch {
891
1163
  return;
892
1164
  }
893
- if (!isRecord(parsed) || !isRecord(parsed.tables)) return;
1165
+ if (!(0, _orkestrel_contract.isRecord)(parsed) || !(0, _orkestrel_contract.isRecord)(parsed.tables)) return;
894
1166
  const tables = parsed.tables;
895
1167
  for (const table of this.#schema) {
896
1168
  const rows = tables[table.name];
897
1169
  if (!Array.isArray(rows)) continue;
898
1170
  for (const entry of rows) {
899
- if (!isRecord(entry)) continue;
1171
+ if (!(0, _orkestrel_contract.isRecord)(entry)) continue;
900
1172
  const key = (0, _src_core.extractKey)(entry, table.primary);
901
1173
  if (key === void 0) continue;
902
1174
  await this.#memory.write(table.name, key, entry);
903
1175
  }
904
1176
  }
905
- const COLUMN_TYPES = [
906
- "text",
907
- "integer",
908
- "real",
909
- "boolean",
910
- "json",
911
- "blob"
912
- ];
913
- const isColumnType = (value) => isString(value) && COLUMN_TYPES.some((type) => type === value);
914
- const isColumnSchema = (value) => isRecord(value) && isString(value.name) && isColumnType(value.type) && isBoolean(value.nullable);
915
- const isIndexGroup = (value) => isArray(value) && value.every(isString);
916
- const isTableSchema = (value) => isRecord(value) && isString(value.name) && isString(value.primary) && isArray(value.columns) && value.columns.every(isColumnSchema) && isArray(value.indexes) && value.indexes.every(isIndexGroup);
917
- if (isRecord(parsed.meta)) {
918
- const version = parsed.meta.version;
919
- const schema = parsed.meta.schema;
920
- if (typeof version === "number" && Number.isFinite(version) && isArray(schema) && schema.every(isTableSchema)) this.#meta = {
921
- version,
922
- schema
923
- };
924
- }
1177
+ if ((0, _src_core.isDriverMeta)(parsed.meta)) this.#meta = parsed.meta;
925
1178
  }
926
1179
  async #flush() {
927
1180
  const next = this.#chain.then(() => this.#serialize());
@@ -955,12 +1208,390 @@ var JSONDriver = class {
955
1208
  }
956
1209
  };
957
1210
  //#endregion
1211
+ //#region src/server/drivers/SQLiteDriver.ts
1212
+ /**
1213
+ * The SQLite {@link DriverInterface} — the server-native, trusted-mode backend
1214
+ * built on the published `@orkestrel/sqlite` synchronous wrapper.
1215
+ *
1216
+ * @remarks
1217
+ * A thin adapter: it implements the storage primitives the core database layer
1218
+ * needs by delegating to the wrapper's prepared statements — it never touches
1219
+ * raw `node:sqlite`. `open` issues `CREATE TABLE IF NOT EXISTS` with real typed
1220
+ * columns (mapped from each {@link TableSchema}'s portable column types) and a
1221
+ * `PRIMARY KEY`, plus a `CREATE INDEX IF NOT EXISTS` per declared index (both
1222
+ * reopen-safe), and readies a reserved `_meta` single-row table `meta()` /
1223
+ * `stamp()` read and write — **a user table named `_meta` collides with it**;
1224
+ * avoid the name. Rows cross the boundary through the codecs in `helpers.ts`
1225
+ * (`json` columns store / parse JSON text, a `boolean` stores `1` / `0`), so the
1226
+ * typed layer above imposes the exact shape (AGENTS §14). `write` is an
1227
+ * `INSERT OR REPLACE` upsert — the `Table` layer detects a `CONFLICT` via a
1228
+ * prior `has`, so this never translates a constraint error; a backend
1229
+ * `SQLiteError` otherwise propagates unchanged. Querying, ordering, paging, and
1230
+ * aggregation are native: `records` / `count` / `stream` compile a `Criteria`
1231
+ * to SQL with `compileCriteria`, and `aggregate` runs a SQL
1232
+ * `COUNT`/`SUM`/`AVG`/`MIN`/`MAX` (via `aggregateSQL`) over the same compiled
1233
+ * WHERE. `transaction` wraps native `BEGIN` / `COMMIT` / `ROLLBACK` with
1234
+ * double-settle guards. `migrate` runs the plan's projected DDL
1235
+ * ({@link import('../helpers.js').stepToSQL}) inside whichever native
1236
+ * transaction is active: joined into an already-open `transaction()` handle
1237
+ * when one exists (the core's versioned reconcile path wraps migrate + stamp
1238
+ * in one native `BEGIN`, and node:sqlite rejects a nested `BEGIN`), or inside
1239
+ * its own `database.transaction` otherwise — a mid-plan failure rolls back
1240
+ * atomically either way, an improvement over the non-atomic `MemoryDriver` /
1241
+ * `JSONDriver` migrate; a step referencing an undeclared table throws
1242
+ * `DatabaseError` `MIGRATION` before any DDL for that step runs. `snapshot` is
1243
+ * capture-replay (SELECT the
1244
+ * named tables' rows, replay via DELETE + INSERT OR REPLACE inside a native
1245
+ * transaction on rollback) rather than a SQL `SAVEPOINT`, since the core
1246
+ * `transaction` calls the rollback thunk only on failure with no commit-on-
1247
+ * success signal — a long-lived `SAVEPOINT` would leave the connection
1248
+ * uncommitted (lost on close). Every backend interaction runs through `#guard`,
1249
+ * which maps a thrown backend `SQLiteError` (or any unexpected non-`SQLiteError`
1250
+ * throw) to a typed {@link DatabaseError} — never a raw backend error escapes
1251
+ * `DriverInterface`: `CONSTRAINT` → `CONFLICT`, the wrapper's own `CLOSED` →
1252
+ * `CLOSED`, `BUSY` (a locked database that outlasted the configured `timeout`)
1253
+ * → a retryable `DRIVER` (`context.retryable` is `true`), and `UNKNOWN` / any
1254
+ * other throw → `DRIVER`. The original error is preserved as `context.cause`.
1255
+ * A `DatabaseError` this driver throws directly (`CLOSED` from the `#require`
1256
+ * gate, `NOT_FOUND` from `#table`, `MIGRATION` from a migration-plan fault)
1257
+ * passes through `#guard` unchanged, never re-wrapped.
1258
+ */
1259
+ var SQLiteDriver = class {
1260
+ #path;
1261
+ #options;
1262
+ #database;
1263
+ #schema = /* @__PURE__ */ new Map();
1264
+ #transacting = false;
1265
+ constructor(path, options) {
1266
+ this.#path = path;
1267
+ this.#options = options ?? {};
1268
+ }
1269
+ async open(schema) {
1270
+ if (schema.some((table) => table.name === "_meta")) throw new _src_core.DatabaseError("VALIDATION", `A declared table cannot be named '${META_TABLE}' — it is reserved for driver metadata`, { table: META_TABLE });
1271
+ this.#guard(() => {
1272
+ this.#database?.close();
1273
+ const database = (0, _orkestrel_sqlite.createSQLiteDatabase)({
1274
+ path: this.#path,
1275
+ readonly: this.#options.readonly,
1276
+ timeout: this.#options.timeout,
1277
+ foreignKeys: this.#options.foreignKeys
1278
+ });
1279
+ database.connect();
1280
+ for (const [name, value] of Object.entries(this.#options.pragmas ?? {})) database.pragma(name, value);
1281
+ const map = /* @__PURE__ */ new Map();
1282
+ for (const table of schema) {
1283
+ map.set(table.name, table);
1284
+ database.exec(schemaToTable(table));
1285
+ for (const sql of schemaToIndexes(table)) database.exec(sql);
1286
+ }
1287
+ database.exec("CREATE TABLE IF NOT EXISTS " + quote(META_TABLE) + " (\"id\" INTEGER, \"version\" INTEGER, \"schema\" TEXT, PRIMARY KEY (\"id\"))");
1288
+ this.#schema = map;
1289
+ this.#database = database;
1290
+ });
1291
+ }
1292
+ async close() {
1293
+ this.#database?.close();
1294
+ this.#database = void 0;
1295
+ }
1296
+ async read(table, key) {
1297
+ const schema = this.#table(table);
1298
+ return this.#guard(() => {
1299
+ const row = this.#require().prepare("SELECT * FROM " + quote(table) + " WHERE " + quote(schema.primary) + " = ?").get([this.#key(key, schema)]);
1300
+ return row === void 0 ? void 0 : decodeRow(row, schema);
1301
+ });
1302
+ }
1303
+ async write(table, key, row) {
1304
+ const schema = this.#table(table);
1305
+ this.#guard(() => {
1306
+ const encoded = encodeRow({
1307
+ ...row,
1308
+ [schema.primary]: key
1309
+ }, schema);
1310
+ const names = schema.columns.map((column) => column.name);
1311
+ const values = names.map((name) => encoded[name]);
1312
+ this.#require().prepare("INSERT OR REPLACE INTO " + quote(table) + " (" + names.map(quote).join(", ") + ") VALUES (" + names.map(() => "?").join(", ") + ")").run(values);
1313
+ });
1314
+ }
1315
+ async delete(table, key) {
1316
+ const schema = this.#table(table);
1317
+ return this.#guard(() => {
1318
+ return this.#require().prepare("DELETE FROM " + quote(table) + " WHERE " + quote(schema.primary) + " = ?").run([this.#key(key, schema)]).changes > 0;
1319
+ });
1320
+ }
1321
+ async keys(table) {
1322
+ const schema = this.#table(table);
1323
+ return this.#guard(() => {
1324
+ const primary = quote(schema.primary);
1325
+ const rows = this.#require().prepare("SELECT " + primary + " FROM " + quote(table) + " ORDER BY " + primary).all();
1326
+ const keys = [];
1327
+ for (const row of rows) {
1328
+ const value = row[schema.primary];
1329
+ if (typeof value === "string" || typeof value === "number") keys.push(value);
1330
+ }
1331
+ return keys;
1332
+ });
1333
+ }
1334
+ async *scan(table) {
1335
+ const schema = this.#table(table);
1336
+ const iterator = this.#guard(() => this.#require().prepare("SELECT * FROM " + quote(table) + " ORDER BY " + quote(schema.primary)).iterate())[Symbol.iterator]();
1337
+ while (true) {
1338
+ const step = this.#guard(() => iterator.next());
1339
+ if (step.done === true) return;
1340
+ yield this.#guard(() => decodeRow(step.value, schema));
1341
+ }
1342
+ }
1343
+ async clear(table) {
1344
+ this.#table(table);
1345
+ this.#guard(() => {
1346
+ this.#require().prepare("DELETE FROM " + quote(table)).run();
1347
+ });
1348
+ }
1349
+ async records(table, criteria) {
1350
+ const schema = this.#table(table);
1351
+ if (isExactCriteria(criteria, schema)) return this.#guard(() => {
1352
+ const { sql, params } = compileCriteria(criteria, schema);
1353
+ return this.#require().prepare("SELECT * FROM " + quote(table) + (sql === "" ? "" : " " + sql)).all(params).map((row) => decodeRow(row, schema));
1354
+ });
1355
+ const rows = [];
1356
+ for await (const row of this.scan(table)) rows.push(row);
1357
+ return (0, _src_core.applyCriteria)(rows, criteria);
1358
+ }
1359
+ async count(table, criteria) {
1360
+ const schema = this.#table(table);
1361
+ const conditions = criteria.conditions ?? [];
1362
+ if (conditions.every((condition) => isExactCondition(condition, schema))) return this.#guard(() => {
1363
+ const { sql, params } = compileWhere(conditions, schema);
1364
+ const value = this.#require().prepare("SELECT COUNT(*) AS count FROM " + quote(table) + (sql === "" ? "" : " " + sql)).get(params)?.count;
1365
+ return typeof value === "number" || typeof value === "bigint" ? Number(value) : 0;
1366
+ });
1367
+ const rows = [];
1368
+ for await (const row of this.scan(table)) rows.push(row);
1369
+ return (0, _src_core.filterRows)(rows, conditions).length;
1370
+ }
1371
+ async aggregate(table, operation, column, criteria) {
1372
+ const schema = this.#table(table);
1373
+ const conditions = criteria.conditions ?? [];
1374
+ const conditionsExact = conditions.every((condition) => isExactCondition(condition, schema));
1375
+ const columnExact = operation === "count" || (0, _orkestrel_contract.isString)(column) && schema.columns.some((candidate) => candidate.name === column && (candidate.type === "integer" || candidate.type === "real"));
1376
+ if (conditionsExact && columnExact) return this.#guard(() => {
1377
+ const { sql, params } = compileWhere(conditions, schema);
1378
+ const value = this.#require().prepare("SELECT " + aggregateSQL(operation, column) + " AS value FROM " + quote(table) + (sql === "" ? "" : " " + sql)).get(params)?.value;
1379
+ return value === null || value === void 0 ? void 0 : Number(value);
1380
+ });
1381
+ const rows = [];
1382
+ for await (const row of this.scan(table)) rows.push(row);
1383
+ return (0, _src_core.computeAggregate)((0, _src_core.filterRows)(rows, conditions), operation, column);
1384
+ }
1385
+ async *stream(table, criteria) {
1386
+ const schema = this.#table(table);
1387
+ const conditions = criteria.conditions ?? [];
1388
+ if (conditions.every((condition) => isExactCondition(condition, schema))) {
1389
+ const compiled = compileCriteria({
1390
+ conditions,
1391
+ limit: criteria.limit,
1392
+ offset: criteria.offset
1393
+ }, schema);
1394
+ for (const row of this.#require().prepare("SELECT * FROM " + quote(table) + (compiled.sql === "" ? "" : " " + compiled.sql)).iterate(compiled.params)) yield decodeRow(row, schema);
1395
+ return;
1396
+ }
1397
+ const offset = criteria.offset ?? 0;
1398
+ const limit = criteria.limit;
1399
+ let skipped = 0;
1400
+ let yielded = 0;
1401
+ for await (const row of this.scan(table)) {
1402
+ if (limit !== void 0 && yielded >= limit) return;
1403
+ if (conditions.length > 0 && !(0, _src_core.matchesCriteria)(row, conditions)) continue;
1404
+ if (skipped < offset) {
1405
+ skipped += 1;
1406
+ continue;
1407
+ }
1408
+ yield row;
1409
+ yielded += 1;
1410
+ }
1411
+ }
1412
+ /**
1413
+ * Begin a native transaction — real `BEGIN`, `COMMIT`, `ROLLBACK`.
1414
+ *
1415
+ * @remarks
1416
+ * Calling `commit` or `rollback` a second time (on either method, in either
1417
+ * order) throws `DatabaseError` `CONFLICT`.
1418
+ *
1419
+ * @returns A {@link TransactionInterface} handle to `commit` or `rollback`
1420
+ */
1421
+ async transaction() {
1422
+ const database = this.#require();
1423
+ this.#guard(() => database.exec("BEGIN"));
1424
+ let settled = false;
1425
+ this.#transacting = true;
1426
+ return {
1427
+ commit: async () => {
1428
+ if (settled) throw new _src_core.DatabaseError("CONFLICT", "Transaction already settled", {});
1429
+ settled = true;
1430
+ this.#transacting = false;
1431
+ this.#guard(() => database.exec("COMMIT"));
1432
+ },
1433
+ rollback: async () => {
1434
+ if (settled) throw new _src_core.DatabaseError("CONFLICT", "Transaction already settled", {});
1435
+ settled = true;
1436
+ this.#transacting = false;
1437
+ this.#guard(() => database.exec("ROLLBACK"));
1438
+ }
1439
+ };
1440
+ }
1441
+ /**
1442
+ * Apply a {@link Migration} plan by executing each step's projected DDL
1443
+ * ({@link import('../helpers.js').stepToSQL}).
1444
+ *
1445
+ * @remarks
1446
+ * Atomicity is provided by whichever native transaction is active: when
1447
+ * this driver's own `transaction()` hook already has a handle open (the
1448
+ * core's versioned reconcile / migrate path joins migrate + stamp under
1449
+ * one native `BEGIN`), the plan's DDL runs directly inside that enclosing
1450
+ * transaction — a mid-plan failure propagates out and the CALLER's
1451
+ * `commit`/`rollback` provides atomicity. node:sqlite (and SQLite
1452
+ * generally) rejects a nested `BEGIN`, so this driver must never open a
1453
+ * second native transaction while one is already open. Otherwise (no
1454
+ * enclosing transaction), `migrate` wraps the plan in its own native
1455
+ * `database.transaction` — atomic on its own: a mid-plan failure rolls
1456
+ * back every DDL statement already applied by the plan. A step
1457
+ * referencing a table not in this driver's declared schema (and that is
1458
+ * not itself a `table.add`) throws `DatabaseError` `MIGRATION` before any
1459
+ * DDL for that step runs, propagating out of whichever transaction is
1460
+ * active (which rolls back on a throw).
1461
+ *
1462
+ * @param plan - The migration plan to apply
1463
+ */
1464
+ async migrate(plan) {
1465
+ const database = this.#require();
1466
+ const schema = new Map(this.#schema);
1467
+ this.#guard(() => {
1468
+ if (this.#transacting) this.#applyPlan(database, plan, schema);
1469
+ else database.transaction(() => this.#applyPlan(database, plan, schema));
1470
+ });
1471
+ this.#schema = schema;
1472
+ }
1473
+ /**
1474
+ * Read the persisted {@link DriverMeta} from the reserved `_meta` table.
1475
+ *
1476
+ * @returns The last-stamped `DriverMeta`, or `undefined` when never stamped
1477
+ * (or the stored row is malformed)
1478
+ */
1479
+ async meta() {
1480
+ const row = this.#guard(() => this.#require().prepare("SELECT \"version\", \"schema\" FROM " + quote(META_TABLE) + " WHERE \"id\" = 1").get());
1481
+ if (row === void 0) return void 0;
1482
+ const version = row.version;
1483
+ const text = row.schema;
1484
+ if (typeof text !== "string") return void 0;
1485
+ if (typeof version !== "number" && typeof version !== "bigint") return void 0;
1486
+ let parsed;
1487
+ try {
1488
+ parsed = JSON.parse(text);
1489
+ } catch {
1490
+ return;
1491
+ }
1492
+ const candidate = {
1493
+ version: Number(version),
1494
+ schema: parsed
1495
+ };
1496
+ if (!(0, _src_core.isDriverMeta)(candidate)) return void 0;
1497
+ return candidate;
1498
+ }
1499
+ /**
1500
+ * Persist `meta` verbatim (as JSON) into the reserved `_meta` table's single
1501
+ * row.
1502
+ *
1503
+ * @param meta - The {@link DriverMeta} to persist
1504
+ */
1505
+ async stamp(meta) {
1506
+ this.#guard(() => {
1507
+ this.#require().prepare("INSERT OR REPLACE INTO " + quote(META_TABLE) + " (\"id\", \"version\", \"schema\") VALUES (1, ?, ?)").run([meta.version, JSON.stringify(meta.schema)]);
1508
+ });
1509
+ }
1510
+ async snapshot(tables) {
1511
+ const database = this.#require();
1512
+ const names = tables ?? [...this.#schema.keys()];
1513
+ const captured = /* @__PURE__ */ new Map();
1514
+ for (const name of names) {
1515
+ const schema = this.#schema.get(name);
1516
+ if (schema === void 0) continue;
1517
+ captured.set(name, {
1518
+ names: schema.columns.map((column) => column.name),
1519
+ rows: database.prepare("SELECT * FROM " + quote(name)).all()
1520
+ });
1521
+ }
1522
+ return async () => {
1523
+ const current = this.#require();
1524
+ current.transaction(() => {
1525
+ for (const [name, snapshot] of captured) {
1526
+ current.exec("DELETE FROM " + quote(name));
1527
+ const statement = current.prepare("INSERT OR REPLACE INTO " + quote(name) + " (" + snapshot.names.map(quote).join(", ") + ") VALUES (" + snapshot.names.map(() => "?").join(", ") + ")");
1528
+ for (const row of snapshot.rows) statement.run(snapshot.names.map((column) => row[column]));
1529
+ }
1530
+ });
1531
+ };
1532
+ }
1533
+ #guard(run) {
1534
+ try {
1535
+ return run();
1536
+ } catch (error) {
1537
+ if (error instanceof _src_core.DatabaseError) throw error;
1538
+ if ((0, _orkestrel_sqlite.isSQLiteError)(error)) {
1539
+ if (error.code === "CONSTRAINT") throw new _src_core.DatabaseError("CONFLICT", error.message, {
1540
+ cause: error,
1541
+ code: error.code
1542
+ });
1543
+ if (error.code === "CLOSED") throw new _src_core.DatabaseError("CLOSED", error.message, {
1544
+ cause: error,
1545
+ code: error.code
1546
+ });
1547
+ if (error.code === "BUSY") throw new _src_core.DatabaseError("DRIVER", error.message, {
1548
+ cause: error,
1549
+ code: error.code,
1550
+ retryable: true
1551
+ });
1552
+ throw new _src_core.DatabaseError("DRIVER", error.message, {
1553
+ cause: error,
1554
+ code: error.code
1555
+ });
1556
+ }
1557
+ throw new _src_core.DatabaseError("DRIVER", error instanceof Error ? error.message : String(error), { cause: error });
1558
+ }
1559
+ }
1560
+ #require() {
1561
+ if (this.#database === void 0) throw new _src_core.DatabaseError("CLOSED", `SQLite database '${this.#path}' is not open`, { path: this.#path });
1562
+ return this.#database;
1563
+ }
1564
+ #table(name) {
1565
+ this.#require();
1566
+ const schema = this.#schema.get(name);
1567
+ if (schema === void 0) throw new _src_core.DatabaseError("NOT_FOUND", `Table '${name}' is not in the schema`, { table: name });
1568
+ return schema;
1569
+ }
1570
+ #key(key, schema) {
1571
+ const primary = schema.columns.find((column) => column.name === schema.primary);
1572
+ return encodeValue(key, primary === void 0 ? "text" : primary.type);
1573
+ }
1574
+ #applyPlan(database, plan, schema) {
1575
+ for (const step of plan.steps) {
1576
+ const table = step.operation === "table.add" ? step.table.name : step.table;
1577
+ if (step.operation !== "table.add" && !schema.has(table)) throw new _src_core.DatabaseError("MIGRATION", `migrate: unknown table '${table}'`, { table });
1578
+ for (const sql of stepToSQL(step)) database.exec(sql);
1579
+ if (step.operation === "table.add") schema.set(step.table.name, step.table);
1580
+ else if (step.operation === "table.remove") schema.delete(step.table);
1581
+ else {
1582
+ const existing = schema.get(table);
1583
+ if (existing !== void 0) schema.set(table, stepToSchema(existing, step));
1584
+ }
1585
+ }
1586
+ }
1587
+ };
1588
+ //#endregion
958
1589
  //#region src/server/factories.ts
959
1590
  /**
960
1591
  * Create a persistent JSON-file {@link DriverInterface} for the core database layer.
961
1592
  *
962
1593
  * @remarks
963
- * Pass it to `createDatabase` from `@src/core` to run the whole typed database +
1594
+ * Pass it to `createDatabase` from `@orkestrel/database` to run the whole typed database +
964
1595
  * relations stack against a single JSON file instead of memory — the `Database` /
965
1596
  * `Table` / `Query` / relations API is unchanged; only where the bytes live changes.
966
1597
  * The driver is the reference `MemoryDriver` plus JSON-file persistence: `open` loads
@@ -988,8 +1619,51 @@ var JSONDriver = class {
988
1619
  function createJSONDriver(path) {
989
1620
  return new JSONDriver(path);
990
1621
  }
1622
+ /**
1623
+ * Create a trusted-mode SQLite {@link DriverInterface} for the core database layer.
1624
+ *
1625
+ * @remarks
1626
+ * Pass it to `createDatabase` from `@orkestrel/database` to run the whole typed database +
1627
+ * relations stack against a real SQLite database — the `Database` / `Table` /
1628
+ * `Query` / relations API is unchanged; only where the bytes live changes. Built
1629
+ * on the published `@orkestrel/sqlite` synchronous wrapper: `open` issues real
1630
+ * typed `CREATE TABLE` / `CREATE INDEX` statements (reopen-safe) plus a reserved
1631
+ * `_meta` table for `meta()` / `stamp()` — avoid naming a table `_meta`.
1632
+ * Querying, paging, and aggregation run natively (`records` / `count` /
1633
+ * `aggregate` / `stream`); `transaction` and `migrate` use real `BEGIN` /
1634
+ * `COMMIT` / `ROLLBACK`, so `migrate` is atomic even mid-plan.
1635
+ *
1636
+ * @param options - A bare database file path (`':memory:'` by default, for
1637
+ * back-compat), or a full {@link SQLiteDriverOptions} bag (`path`,
1638
+ * `readonly`, `timeout`, `foreignKeys`, `pragmas`)
1639
+ * @returns A {@link DriverInterface} backed by SQLite
1640
+ *
1641
+ * @example
1642
+ * ```ts
1643
+ * import { createDatabase } from '@orkestrel/database'
1644
+ * import { stringShape } from '@orkestrel/contract'
1645
+ * import { createSQLiteDriver } from '@orkestrel/database/server'
1646
+ *
1647
+ * const db = createDatabase({
1648
+ * driver: createSQLiteDriver('data/app.sqlite'),
1649
+ * tables: { users: { id: stringShape(), name: stringShape() } },
1650
+ * })
1651
+ * await db.table('users').set({ id: 'u1', name: 'Ada' }) // persisted to app.sqlite
1652
+ *
1653
+ * // Or with options:
1654
+ * createSQLiteDriver({ path: 'data/app.sqlite', pragmas: { journal_mode: 'WAL' } })
1655
+ * ```
1656
+ */
1657
+ function createSQLiteDriver(options = ":memory:") {
1658
+ const resolved = (0, _orkestrel_contract.isString)(options) ? { path: options } : options;
1659
+ return new SQLiteDriver(resolved.path ?? ":memory:", resolved);
1660
+ }
991
1661
  //#endregion
1662
+ exports.EXACT_COLUMN_TYPES = EXACT_COLUMN_TYPES;
1663
+ exports.EXACT_RANGE_COLUMN_TYPES = EXACT_RANGE_COLUMN_TYPES;
992
1664
  exports.JSONDriver = JSONDriver;
1665
+ exports.META_TABLE = META_TABLE;
1666
+ exports.SQLiteDriver = SQLiteDriver;
993
1667
  exports.aggregateSQL = aggregateSQL;
994
1668
  exports.columnSQL = columnSQL;
995
1669
  exports.compileCriteria = compileCriteria;
@@ -997,6 +1671,7 @@ exports.compileOrder = compileOrder;
997
1671
  exports.compilePage = compilePage;
998
1672
  exports.compileWhere = compileWhere;
999
1673
  exports.createJSONDriver = createJSONDriver;
1674
+ exports.createSQLiteDriver = createSQLiteDriver;
1000
1675
  exports.declaredType = declaredType;
1001
1676
  exports.decodeRow = decodeRow;
1002
1677
  exports.decodeValue = decodeValue;
@@ -1006,9 +1681,17 @@ exports.escapeLike = escapeLike;
1006
1681
  exports.fieldColumn = fieldColumn;
1007
1682
  exports.fragment = fragment;
1008
1683
  exports.generateKey = generateKey;
1684
+ exports.indexName = indexName;
1685
+ exports.isExactCondition = isExactCondition;
1686
+ exports.isExactCriteria = isExactCriteria;
1687
+ exports.isExactOrder = isExactOrder;
1688
+ exports.jsonTypeColumn = jsonTypeColumn;
1689
+ exports.matchesDeclaredType = matchesDeclaredType;
1009
1690
  exports.quote = quote;
1010
1691
  exports.schemaToIndexes = schemaToIndexes;
1011
1692
  exports.schemaToTable = schemaToTable;
1693
+ exports.stepToSQL = stepToSQL;
1694
+ exports.stepToSchema = stepToSchema;
1012
1695
  exports.valueType = valueType;
1013
1696
 
1014
1697
  //# sourceMappingURL=index.cjs.map