@orkestrel/database 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,31 +1,12 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- let _orkestrel_contract = require("@orkestrel/contract");
3
- let node_crypto = require("node:crypto");
4
2
  let _src_core = require("../core/index.cjs");
3
+ let _orkestrel_contract = require("@orkestrel/contract");
5
4
  let node_fs_promises = require("node:fs/promises");
6
5
  let node_path = require("node:path");
7
6
  let _orkestrel_sqlite = require("@orkestrel/sqlite");
8
- //#region src/server/helpers.ts
9
- /**
10
- * Generate a fresh unique key — a v4 UUID string, backed by `node:crypto`.
11
- *
12
- * @remarks
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
- * ```
23
- */
24
- function generateKey() {
25
- return (0, node_crypto.randomUUID)();
26
- }
7
+ //#region src/server/constants.ts
27
8
  /**
28
- * The declared {@link ColumnType}s whose SQL EQUALITY comparisons (`equals` /
9
+ * The declared {@link ColumnStorage}s whose SQL EQUALITY comparisons (`equals` /
29
10
  * `not` / `any` / `none`) and `starts` / `ends` compiles are provably
30
11
  * engine-exact under declared-type trust — `text` / `integer` / `real` /
31
12
  * `boolean`; a `json` or `blob` column always refines instead.
@@ -40,38 +21,47 @@ function generateKey() {
40
21
  * compares UTF-16 CODE-UNIT order. The two orders diverge for supplementary-
41
22
  * plane characters (code points ≥ U+10000, e.g. many emoji): a lead surrogate
42
23
  * (`\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.
24
+ * its code point sorts ABOVE them. So `matchesConditionExactly`'s range family and
25
+ * `matchesOrderExactly` exclude `text`, refining through the core engine instead.
48
26
  */
49
- var EXACT_COLUMN_TYPES = [
27
+ var EXACT_COLUMN_STORAGE = Object.freeze([
50
28
  "text",
51
29
  "integer",
52
30
  "real",
53
31
  "boolean"
54
- ];
32
+ ]);
55
33
  /**
56
- * The declared {@link ColumnType}s whose SQL RANGE comparisons
34
+ * The declared {@link ColumnStorage}s whose SQL RANGE comparisons
57
35
  * (`above` / `below` / `from` / `to` / `between`) and `ORDER BY` compiles are
58
36
  * provably engine-exact — `integer` / `real` / `boolean` only. `text` is
59
- * excluded: see {@link EXACT_COLUMN_TYPES}'s remarks for the BINARY-collation
37
+ * excluded: see {@link EXACT_COLUMN_STORAGE}'s remarks for the BINARY-collation
60
38
  * (code-point) vs. JS `<` (code-unit) divergence on supplementary-plane
61
39
  * characters.
62
40
  */
63
- var EXACT_RANGE_COLUMN_TYPES = [
41
+ var EXACT_RANGE_COLUMN_STORAGE = Object.freeze([
64
42
  "integer",
65
43
  "real",
66
44
  "boolean"
67
- ];
45
+ ]);
46
+ /**
47
+ * The reserved metadata table the {@link SQLiteDriver} creates on `open` to
48
+ * persist its stamped `DriverMetadata` (`version` + declared schema JSON) — the
49
+ * SQLite realization of the `metadata` / `stamp` driver hooks.
50
+ *
51
+ * @remarks
52
+ * A single-row table (`id = 1`). A user table named `_metadata` collides with the
53
+ * reservation — the caller's concern to avoid, documented on the driver class.
54
+ */
55
+ var METADATA_TABLE = "_metadata";
56
+ //#endregion
57
+ //#region src/server/helpers.ts
68
58
  /**
69
59
  * Whether a value's runtime type matches a column's declared exact type —
70
60
  * the operand side of the declared-type-trust proof.
71
61
  *
72
62
  * @remarks
73
63
  * `text` ↔ string, `integer` / `real` ↔ FINITE number (`NaN` / `±Infinity`
74
- * fail), `boolean` ↔ boolean. Backs {@link isExactCondition}'s operand checks.
64
+ * fail), `boolean` ↔ boolean. Backs {@link matchesConditionExactly}'s operand checks.
75
65
  *
76
66
  * @param value - The condition operand to test
77
67
  * @param type - The column's declared portable type
@@ -83,9 +73,9 @@ var EXACT_RANGE_COLUMN_TYPES = [
83
73
  * matchesDeclaredType(Number.NaN, 'integer') // false — only finite numbers
84
74
  * ```
85
75
  */
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);
76
+ function matchesDeclaredStorage(value, storage) {
77
+ if (storage === "text") return (0, _orkestrel_contract.isString)(value);
78
+ if (storage === "boolean") return (0, _orkestrel_contract.isBoolean)(value);
89
79
  return (0, _orkestrel_contract.isFiniteNumber)(value);
90
80
  }
91
81
  /**
@@ -94,28 +84,26 @@ function matchesDeclaredType(value, type) {
94
84
  * type can store.
95
85
  *
96
86
  * @remarks
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` /
87
+ * `false` for a nested `FieldPath` (an array) or a column absent from `schema`.
88
+ * `absent` / `present` are exact unless a column is both optional and nullable.
89
+ * In that combined case the storage sentinel for explicit `null` is not SQL
90
+ * `NULL`, while the core treats both absence and explicit `null` as absent.
91
+ * Every scalar operator refines when the column is optional
92
+ * OR nullable, because SQL null semantics and the core total order differ.
93
+ * Required non-null `equals` / `not` require an operand matching the declared
94
+ * storage and exclude `json` / `blob`. `above` / `below` / `from` / `to` /
95
+ * `between` are exact only for {@link EXACT_RANGE_COLUMN_STORAGE} (`integer` /
108
96
  * `real` / `boolean`) — a `text` column's range conditions REFINE, because
109
97
  * SQLite's default BINARY collation orders TEXT by Unicode CODE POINT while
110
98
  * the core engine's `compareValues` orders JS strings by UTF-16 CODE UNIT,
111
99
  * and the two diverge for supplementary-plane characters (see
112
- * {@link EXACT_COLUMN_TYPES}'s remarks for the full rationale).
100
+ * {@link EXACT_COLUMN_STORAGE}'s remarks for the full rationale).
113
101
  * `any` / `none` require a NON-EMPTY list where every element matches (an empty
114
102
  * list is exact under neither: the engine's `any([])` matches nothing while
115
103
  * `none([])` matches everything, and SQL `IN ()` is a syntax error) — these
116
104
  * stay exact on `text` (byte equality is collation-independent and engine-
117
105
  * identical). `starts` / `ends` are exact only on a `text` column with a
118
- * string operand (case-sensitive `substr` compile, see {@link fragment}) —
106
+ * string operand (case-sensitive `substr` compile, see {@link compileConditionSQL}) —
119
107
  * likewise collation-independent. `like` / `glob` are NEVER exact — SQLite
120
108
  * `LIKE` folds case ASCII-only against the engine's Unicode fold, and `GLOB`
121
109
  * has character classes the engine treats literally.
@@ -124,26 +112,27 @@ function matchesDeclaredType(value, type) {
124
112
  * @param schema - The table's schema
125
113
  * @returns Whether `condition` is exact
126
114
  */
127
- function isExactCondition(condition, schema) {
115
+ function matchesConditionExactly(condition, schema) {
128
116
  if (!(0, _orkestrel_contract.isString)(condition.column)) return false;
129
117
  const column = schema.columns.find((candidate) => candidate.name === condition.column);
130
118
  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;
119
+ if (condition.operator === "absent" || condition.operator === "present") return !(column.optional && column.nullable);
120
+ if (column.optional || column.nullable) return false;
121
+ if (!EXACT_COLUMN_STORAGE.some((storage) => storage === column.storage)) return false;
133
122
  const first = condition.values[0];
134
123
  const second = condition.values[1];
135
124
  switch (condition.operator) {
136
125
  case "equals":
137
- case "not": return matchesDeclaredType(first, column.type);
126
+ case "not": return matchesDeclaredStorage(first, column.storage);
138
127
  case "above":
139
128
  case "below":
140
129
  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);
130
+ case "to": return EXACT_RANGE_COLUMN_STORAGE.some((storage) => storage === column.storage) && matchesDeclaredStorage(first, column.storage);
131
+ case "between": return EXACT_RANGE_COLUMN_STORAGE.some((storage) => storage === column.storage) && matchesDeclaredStorage(first, column.storage) && matchesDeclaredStorage(second, column.storage);
143
132
  case "any":
144
- case "none": return condition.values.length > 0 && condition.values.every((value) => matchesDeclaredType(value, column.type));
133
+ case "none": return condition.values.length > 0 && condition.values.every((value) => matchesDeclaredStorage(value, column.storage));
145
134
  case "starts":
146
- case "ends": return column.type === "text" && (0, _orkestrel_contract.isString)(first);
135
+ case "ends": return column.storage === "text" && (0, _orkestrel_contract.isString)(first);
147
136
  case "like":
148
137
  case "glob": return false;
149
138
  }
@@ -154,65 +143,71 @@ function isExactCondition(condition, schema) {
154
143
  *
155
144
  * @remarks
156
145
  * `false` for a nested `FieldPath`, a column absent from `schema`, or a
157
- * declared type outside {@link EXACT_RANGE_COLUMN_TYPES} (`integer` / `real` /
146
+ * declared type outside {@link EXACT_RANGE_COLUMN_STORAGE} (`integer` / `real` /
158
147
  * `boolean`). `text` is NOT exact here: SQLite's default BINARY collation
159
148
  * orders TEXT by Unicode code point while the core engine's `compareValues`
160
149
  * orders JS strings by UTF-16 code unit, and the two diverge for
161
- * supplementary-plane characters (see {@link EXACT_COLUMN_TYPES}'s remarks) —
150
+ * supplementary-plane characters (see {@link EXACT_COLUMN_STORAGE}'s remarks) —
162
151
  * a `text` order term REFINES through the core engine instead.
163
152
  *
164
153
  * @param order - The order term to test
165
154
  * @param schema - The table's schema
166
155
  * @returns Whether `order` is exact
167
156
  */
168
- function isExactOrder(order, schema) {
157
+ function matchesOrderExactly(order, schema) {
169
158
  if (!(0, _orkestrel_contract.isString)(order.column)) return false;
170
159
  const column = schema.columns.find((candidate) => candidate.name === order.column);
171
160
  if (column === void 0) return false;
172
- return EXACT_RANGE_COLUMN_TYPES.some((type) => type === column.type);
161
+ return !column.optional && !column.nullable && EXACT_RANGE_COLUMN_STORAGE.some((storage) => storage === column.storage);
173
162
  }
174
163
  /**
175
- * Whether a whole {@link Criteria} is exact — every condition and every order
164
+ * Whether a whole {@link QueryInput} is exact — every condition and every order
176
165
  * term is exact. `limit` / `offset` never affect exactness (SQL `LIMIT` /
177
166
  * `OFFSET` are always engine-identical).
178
167
  *
179
- * @param criteria - The criteria to test
168
+ * @param input - The query input to test
180
169
  * @param schema - The table's schema
181
- * @returns Whether every part of `criteria` is exact
170
+ * @returns Whether every part of `input` is exact
182
171
  */
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));
172
+ function matchesQueryExactly(input, schema) {
173
+ const conditions = input.conditions ?? [];
174
+ const order = input.order ?? [];
175
+ return conditions.every((condition) => matchesConditionExactly(condition, schema)) && order.every((term) => matchesOrderExactly(term, schema));
187
176
  }
188
177
  /**
189
- * Map a portable {@link ColumnType} to its SQLite column type.
190
- *
191
- * @remarks
192
- * `text` / `json` → `TEXT` (JSON is stored as text and read back with
193
- * `json_extract` for nested-field queries); `integer` / `boolean` → `INTEGER`
194
- * (a boolean stores `1` / `0`); `real` → `REAL`; `blob` → `BLOB`. No `NOT NULL`
195
- * is ever emitted — the contract validates required-ness; the database is just
196
- * storage (AGENTS §14, the typed layer above imposes the shape).
178
+ * Determine whether SQLite can execute an aggregate exactly like the core engine.
197
179
  *
198
- * @param type - The portable column type
199
- * @returns The SQLite column type keyword
180
+ * @param operation - Aggregate operation
181
+ * @param column - Aggregate field
182
+ * @param schema - Current table schema
183
+ * @returns Whether native aggregation is exact
184
+ */
185
+ function matchesAggregateExactly(operation, column, schema) {
186
+ if (operation === "count") return true;
187
+ if (operation === "sum" || operation === "average" || !(0, _orkestrel_contract.isString)(column)) return false;
188
+ const declared = schema.columns.find((candidate) => candidate.name === column);
189
+ return declared !== void 0 && (declared.storage === "integer" || declared.storage === "real") && !(declared.optional && declared.nullable);
190
+ }
191
+ /**
192
+ * Test a declared SQLite type against a portable storage affinity.
200
193
  *
201
- * @example
202
- * ```ts
203
- * columnSQL('integer') // 'INTEGER'
204
- * columnSQL('json') // 'TEXT'
205
- * ```
194
+ * @param declared - Native declared type
195
+ * @param storage - Portable column storage
196
+ * @returns Whether SQLite's official affinity rules yield the expected affinity
206
197
  */
207
- function columnSQL(type) {
208
- switch (type) {
209
- case "text":
210
- case "json": return "TEXT";
211
- case "integer":
212
- case "boolean": return "INTEGER";
213
- case "real": return "REAL";
214
- case "blob": return "BLOB";
215
- }
198
+ function matchesSQLiteAffinity(declared, storage) {
199
+ if (!(0, _orkestrel_contract.isString)(declared)) return false;
200
+ const type = declared.toUpperCase();
201
+ let affinity;
202
+ if (type.includes("INT")) affinity = "INTEGER";
203
+ else if (type.includes("CHAR") || type.includes("CLOB") || type.includes("TEXT")) affinity = "TEXT";
204
+ else if (type === "" || type.includes("BLOB")) affinity = "BLOB";
205
+ else if (type.includes("REAL") || type.includes("FLOA") || type.includes("DOUB")) affinity = "REAL";
206
+ else affinity = "NUMERIC";
207
+ if (storage === "integer" || storage === "boolean") return affinity === "INTEGER";
208
+ if (storage === "text" || storage === "json") return affinity === "TEXT";
209
+ if (storage === "blob") return affinity === "BLOB";
210
+ return affinity === "REAL";
216
211
  }
217
212
  /**
218
213
  * Quote a SQL identifier (a table or column name) so any characters are literal.
@@ -227,127 +222,90 @@ function columnSQL(type) {
227
222
  *
228
223
  * @example
229
224
  * ```ts
230
- * quote('order') // '"order"'
225
+ * quoteIdentifier('order') // '"order"'
231
226
  * ```
232
227
  */
233
- function quote(identifier) {
228
+ function quoteIdentifier(identifier) {
234
229
  return "\"" + identifier.replaceAll("\"", "\"\"") + "\"";
235
230
  }
236
231
  /**
237
- * Compile a {@link FieldPath} to the SQL expression that reads it.
238
- *
239
- * @remarks
240
- * A single string is ONE column — `quote(path)`. An array descends a JSON column:
241
- * the first element is the (quoted) column, the rest a `json_extract` path
242
- * (`json_extract("payload", '$.user.id')`), matching the guide's nested-field
243
- * examples (simple identifier keys). The string's value is never split on `.`
244
- * (AGENTS — `FieldPath` semantics): a dotted string is one column literally.
245
- *
246
- * @param path - The field path (a column, or a column + nested keys)
247
- * @returns The SQL expression selecting the value
248
- *
249
- * @example
250
- * ```ts
251
- * fieldColumn('payload') // '"payload"'
252
- * fieldColumn(['payload', 'user', 'id']) // 'json_extract("payload", \'$.user.id\')'
253
- * ```
254
- */
255
- function fieldColumn(path) {
256
- if ((0, _orkestrel_contract.isString)(path)) return quote(path);
257
- const rest = path.slice(1).map((key) => "." + key.replaceAll("'", "''")).join("");
258
- return "json_extract(" + quote(path[0]) + ", '$" + rest + "')";
259
- }
260
- /**
261
- * Compile an {@link AggregateFunction} over a {@link FieldPath} to its SQL
262
- * aggregate expression — the SELECT body the SQLite driver's native `aggregate`
263
- * runs.
264
- *
265
- * @remarks
266
- * `count` → `COUNT(*)` (counting all matched ROWS, not non-null column values —
267
- * mirroring the engine's `computeAggregate('count')`, which is `rows.length`); the
268
- * numeric aggregates wrap the column's read expression (a flat column, or a nested
269
- * `json_extract` path) in `SUM` / `AVG` / `MIN` / `MAX`. Over zero matched rows
270
- * `COUNT(*)` is `0` and the numeric aggregates are SQL `NULL` (→ `undefined`),
271
- * matching the engine.
272
- *
273
- * @param operation - The aggregate to compute
274
- * @param column - The column (or nested path) to aggregate
275
- * @returns The SQL aggregate expression
276
- *
277
- * @example
278
- * ```ts
279
- * aggregateSQL('count', 'age') // 'COUNT(*)'
280
- * aggregateSQL('sum', 'age') // 'SUM("age")'
281
- * aggregateSQL('average', ['payload', 'score']) // 'AVG(json_extract("payload", \'$.score\'))'
282
- * ```
283
- */
284
- function aggregateSQL(operation, column) {
285
- switch (operation) {
286
- case "count": return "COUNT(*)";
287
- case "sum": return "SUM(" + fieldColumn(column) + ")";
288
- case "average": return "AVG(" + fieldColumn(column) + ")";
289
- case "minimum": return "MIN(" + fieldColumn(column) + ")";
290
- case "maximum": return "MAX(" + fieldColumn(column) + ")";
291
- }
292
- }
293
- /**
294
- * Encode a JS value to its stored {@link SQLiteValue} for a column's type.
232
+ * Encode a JS value to its stored {@link SQLiteValue} for a declared column.
295
233
  *
296
234
  * @remarks
297
- * The forward half of the bridge, total (AGENTS §14): a value that does not fit
298
- * its column's storage type encodes to `null` rather than throwing. A `boolean`
299
- * column stores `1` / `0` (and `null` / `undefined` `null`); a `json` column
300
- * stores `JSON.stringify` (or `null` for `null` / `undefined`); `integer` /
301
- * `real` keep a `number` / `bigint`, else `null`; `text` keeps a `string`, else
302
- * `null`; `blob` keeps a `Uint8Array`, else `null`. Narrowed with `typeof` /
303
- * `instanceof`, never `as`.
235
+ * The codec is total: a malformed value encodes to SQL `NULL`. Absence always
236
+ * uses SQL `NULL`. A nullable-only column also uses SQL `NULL` for explicit
237
+ * `null`; an optional-and-nullable column uses a storage-class sentinel so
238
+ * absence and explicit `null` remain distinct.
304
239
  *
305
240
  * @param value - The JS value to store
306
- * @param type - The column's portable storage type
241
+ * @param column - The declared storage and absence/null contract
307
242
  * @returns The value SQLite stores
308
243
  *
309
244
  * @example
310
245
  * ```ts
311
- * encodeValue(true, 'boolean') // 1
312
- * encodeValue({ a: 1 }, 'json') // '{"a":1}'
246
+ * encodeValue(true, booleanColumn) // 1
247
+ * encodeValue({ a: 1 }, jsonColumn) // '{"a":1}'
313
248
  * ```
314
249
  */
315
- function encodeValue(value, type) {
316
- switch (type) {
317
- case "boolean": return value === void 0 || value === null ? null : value === true ? 1 : 0;
318
- case "json": return value === void 0 || value === null ? null : JSON.stringify(value);
319
- case "integer":
320
- case "real": return typeof value === "number" || typeof value === "bigint" ? value : null;
250
+ function encodeValue(value, column) {
251
+ if (value === void 0) return null;
252
+ if (value === null) {
253
+ if (!column.nullable) return null;
254
+ if (!column.optional) return null;
255
+ return column.storage === "text" || column.storage === "json" ? /* @__PURE__ */ new Uint8Array() : String(null);
256
+ }
257
+ switch (column.storage) {
258
+ case "boolean": return typeof value === "boolean" ? value ? 1 : 0 : null;
259
+ case "json": try {
260
+ return JSON.stringify((0, _orkestrel_contract.cloneJSONValue)(value));
261
+ } catch {
262
+ return null;
263
+ }
264
+ case "integer": return typeof value === "bigint" || typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) ? value : null;
265
+ case "real": return typeof value === "bigint" || typeof value === "number" && Number.isFinite(value) ? value : null;
321
266
  case "text": return typeof value === "string" ? value : null;
322
267
  case "blob": return value instanceof Uint8Array ? value : null;
323
268
  }
324
269
  }
325
270
  /**
326
- * Decode a stored {@link SQLiteValue} back to its JS value for a column's type
271
+ * Decode a stored {@link SQLiteValue} back to its JS value for a declared column —
327
272
  * the exact inverse of {@link encodeValue}.
328
273
  *
329
274
  * @remarks
330
- * A `boolean` column reads `1` / `0` back to `true` / `false` (a stored `null`
331
- * `undefined`); a `json` column `JSON.parse`s a string (anything else →
332
- * `undefined`); every other type passes the value through, mapping a stored
333
- * `NULL` to `undefined`. NULL decodes to `undefined` so {@link decodeRow} can
334
- * omit absent columns.
275
+ * Stored values must use the declared SQLite storage class. SQL `NULL` decodes
276
+ * to explicit `null` only for nullable-only columns and otherwise to absence.
277
+ * Optional-and-nullable sentinels decode to explicit `null`; malformed values
278
+ * decode to `undefined` so {@link decodeRow} omits them.
335
279
  *
336
280
  * @param value - The stored SQLite value
337
- * @param type - The column's portable storage type
338
- * @returns The decoded JS value (`undefined` for a stored `NULL`)
281
+ * @param column - The declared storage and absence/null contract
282
+ * @returns The decoded JS value, or `undefined` for absence/malformed storage
339
283
  *
340
284
  * @example
341
285
  * ```ts
342
- * decodeValue(1, 'boolean') // true
343
- * decodeValue('{"a":1}', 'json') // { a: 1 }
286
+ * decodeValue(1, booleanColumn) // true
287
+ * decodeValue('{"a":1}', jsonColumn) // { a: 1 }
344
288
  * ```
345
289
  */
346
- function decodeValue(value, type) {
347
- switch (type) {
348
- case "boolean": return value === null ? void 0 : value !== 0;
349
- case "json": return typeof value === "string" ? JSON.parse(value) : void 0;
350
- default: return value === null ? void 0 : value;
290
+ function decodeValue(value, column) {
291
+ if (value === null) return column.nullable && !column.optional ? null : void 0;
292
+ if (column.optional && column.nullable && (column.storage === "text" || column.storage === "json" ? value instanceof Uint8Array && value.byteLength === 0 : value === String(null))) return null;
293
+ switch (column.storage) {
294
+ case "boolean":
295
+ if (value === 0 || value === 0n) return false;
296
+ if (value === 1 || value === 1n) return true;
297
+ return;
298
+ case "json":
299
+ if (typeof value !== "string") return void 0;
300
+ try {
301
+ return structuredClone((0, _orkestrel_contract.cloneJSONValue)(JSON.parse(value)));
302
+ } catch {
303
+ return;
304
+ }
305
+ case "integer": return typeof value === "bigint" || typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) ? value : void 0;
306
+ case "real": return typeof value === "bigint" || typeof value === "number" && Number.isFinite(value) ? value : void 0;
307
+ case "text": return typeof value === "string" ? value : void 0;
308
+ case "blob": return value instanceof Uint8Array ? value : void 0;
351
309
  }
352
310
  }
353
311
  /**
@@ -369,19 +327,49 @@ function decodeValue(value, type) {
369
327
  */
370
328
  function encodeRow(row, schema) {
371
329
  const result = {};
372
- for (const column of schema.columns) result[column.name] = encodeValue(row[column.name], column.type);
330
+ for (const column of schema.columns) result[column.name] = encodeValue(row[column.name], column);
373
331
  return result;
374
332
  }
375
333
  /**
334
+ * Extract a stored row's values in a declared positional order.
335
+ *
336
+ * @remarks
337
+ * SQLite statements bind arrays positionally. Every requested column must be
338
+ * present in `row`; an incomplete backend row is a typed `DRIVER` fault carrying
339
+ * the table and missing column in its context.
340
+ *
341
+ * @param row - The stored SQLite row
342
+ * @param names - The column names in binding order
343
+ * @param table - The owning table name for fault context
344
+ * @returns The row values in the same order as `names`
345
+ * @throws A `DRIVER` {@link DatabaseError} when a requested column is missing
346
+ *
347
+ * @example
348
+ * ```ts
349
+ * extractValues({ id: 'u1', age: 36 }, ['age', 'id'], 'users') // [36, 'u1']
350
+ * ```
351
+ */
352
+ function extractValues(row, names, table) {
353
+ const values = [];
354
+ for (const name of names) {
355
+ const value = row[name];
356
+ if (value === void 0) throw new _src_core.DatabaseError("DRIVER", "SQLite row is missing a declared column", {
357
+ table,
358
+ column: name
359
+ });
360
+ values.push(value);
361
+ }
362
+ return values;
363
+ }
364
+ /**
376
365
  * Decode a stored {@link SQLiteRow} back to a {@link Row} by its table's schema.
377
366
  *
378
367
  * @remarks
379
368
  * Decodes each declared column with {@link decodeValue} and **omits** any column
380
369
  * whose decoded value is `undefined` — so an absent / `NULL` optional column does
381
370
  * not surface as `{ bio: undefined }`, matching how the contract's optional
382
- * columns expect absence. A known, documented edge: a non-optional `nullableShape`
383
- * column storing `null` round-trips to absent (a `null` cell decodes to
384
- * `undefined`, and an `undefined` value is omitted).
371
+ * columns expect absence. Nullable-only SQL `NULL` cells remain explicit
372
+ * `null`; optional-and-nullable columns use their storage-class sentinel.
385
373
  *
386
374
  * @param row - The stored SQLite row
387
375
  * @param schema - The table's schema
@@ -395,37 +383,16 @@ function encodeRow(row, schema) {
395
383
  function decodeRow(row, schema) {
396
384
  const result = {};
397
385
  for (const column of schema.columns) {
398
- const decoded = decodeValue(row[column.name], column.type);
386
+ const value = row[column.name];
387
+ if (value === void 0) continue;
388
+ const decoded = decodeValue(value, column);
399
389
  if (decoded !== void 0) result[column.name] = decoded;
400
390
  }
401
391
  return result;
402
392
  }
403
393
  /**
404
- * Project a {@link TableSchema} to the `CREATE TABLE IF NOT EXISTS` statement a
405
- * SQLite driver's `open` issues for it.
406
- *
407
- * @remarks
408
- * Each column compiles to `<quoted name> <columnSQL(type)>`; the statement ends
409
- * with `PRIMARY KEY (<quoted primary>)`. No `NOT NULL` is emitted — the contract
410
- * validates required-ness, the database is just storage (AGENTS §14).
411
- *
412
- * @param schema - The table's schema
413
- * @returns The `CREATE TABLE IF NOT EXISTS …` statement
414
- *
415
- * @example
416
- * ```ts
417
- * schemaToTable(schema)
418
- * // 'CREATE TABLE IF NOT EXISTS "users" ("id" TEXT, "age" INTEGER, PRIMARY KEY ("id"))'
419
- * ```
420
- */
421
- function schemaToTable(schema) {
422
- const columns = schema.columns.map((column) => quote(column.name) + " " + columnSQL(column.type));
423
- return "CREATE TABLE IF NOT EXISTS " + quote(schema.name) + " (" + columns.join(", ") + ", PRIMARY KEY (" + quote(schema.primary) + "))";
424
- }
425
- /**
426
394
  * 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),
395
+ * shared by the compiler module's `schemaToIndexes` and `stepToSQL`,
429
396
  * so a plan-built index name always matches one `open` would have created.
430
397
  *
431
398
  * @remarks
@@ -441,120 +408,64 @@ function schemaToTable(schema) {
441
408
  *
442
409
  * @example
443
410
  * ```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'
411
+ * deriveSQLiteIndexName('users', ['name']) // 'idx_5_users_4_name'
412
+ * deriveSQLiteIndexName('a_b', ['c']) // 'idx_3_a_b_1_c'
413
+ * deriveSQLiteIndexName('a', ['b', 'c']) // 'idx_1_a_1_b_1_c'
447
414
  * ```
448
415
  */
449
- function indexName(table, columns) {
416
+ function deriveSQLiteIndexName(table, columns) {
450
417
  return "idx_" + [table, ...columns].map((part) => String(part.length) + "_" + part).join("_");
451
418
  }
419
+ //#endregion
420
+ //#region src/server/compilers.ts
452
421
  /**
453
- * Project a {@link TableSchema} to the `CREATE INDEX IF NOT EXISTS` statements a
454
- * SQLite driver's `open` issues for its declared indexes.
455
- *
456
- * @remarks
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.
422
+ * Map a portable {@link ColumnStorage} to its SQLite column type.
460
423
  *
461
- * @param schema - The table's schema
462
- * @returns One `CREATE INDEX IF NOT EXISTS …` statement per declared index
463
- *
464
- * @example
465
- * ```ts
466
- * schemaToIndexes(schema)
467
- * // ['CREATE INDEX IF NOT EXISTS "idx_5_users_4_name" ON "users" ("name")']
468
- * ```
424
+ * @param storage - The portable column type
425
+ * @returns The SQLite column type keyword
469
426
  */
470
- function schemaToIndexes(schema) {
471
- return schema.indexes.map((group) => "CREATE INDEX IF NOT EXISTS " + quote(indexName(schema.name, group)) + " ON " + quote(schema.name) + " (" + group.map(quote).join(", ") + ")");
427
+ function compileColumnSQL(storage) {
428
+ switch (storage) {
429
+ case "text":
430
+ case "json": return "TEXT";
431
+ case "integer":
432
+ case "boolean": return "INTEGER";
433
+ case "real": return "REAL";
434
+ case "blob": return "BLOB";
435
+ }
472
436
  }
473
437
  /**
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
438
+ * Compile a {@link FieldPath} to the SQL expression that reads it.
491
439
  *
492
- * @example
493
- * ```ts
494
- * stepToSQL({ operation: 'column.remove', table: 'users', column: 'legacy' })
495
- * // ['ALTER TABLE "users" DROP COLUMN "legacy"']
496
- * ```
440
+ * @param path - The field path
441
+ * @returns The SQL expression selecting the value
497
442
  */
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
- }
443
+ function compileFieldSQL(path) {
444
+ if ((0, _orkestrel_contract.isString)(path)) return quoteIdentifier(path);
445
+ const [column, ...nested] = path;
446
+ if (column === void 0) throw new _src_core.DatabaseError("VALIDATION", "A field path must contain at least one column");
447
+ const rest = nested.map((key) => "." + key.replaceAll("'", "''")).join("");
448
+ return "json_extract(" + quoteIdentifier(column) + ", '$" + rest + "')";
507
449
  }
508
450
  /**
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
451
+ * Compile an {@link AggregateOperation} over a {@link FieldPath}.
524
452
  *
525
- * @example
526
- * ```ts
527
- * stepToSchema(schema, { operation: 'column.remove', table: 'users', column: 'legacy' })
528
- * // schema with the 'legacy' column dropped from `columns`
529
- * ```
453
+ * @param operation - The aggregate to compute
454
+ * @param column - The column or nested path to aggregate
455
+ * @returns The SQL aggregate expression
530
456
  */
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;
457
+ function compileAggregateSQL(operation, column) {
458
+ switch (operation) {
459
+ case "count": return "COUNT(*)";
460
+ case "sum": return "SUM(" + compileFieldSQL(column) + ")";
461
+ case "average": return "AVG(" + compileFieldSQL(column) + ")";
462
+ case "minimum": return "MIN(" + compileFieldSQL(column) + ")";
463
+ case "maximum": return "MAX(" + compileFieldSQL(column) + ")";
551
464
  }
552
465
  }
553
- //#endregion
554
- //#region src/server/compilers.ts
555
466
  /**
556
467
  * Compile a NESTED {@link FieldPath} to the `json_type(<col>, <path>)` SQL
557
- * expression — the {@link fieldColumn} `json_extract` sibling used to tell a
468
+ * expression — the {@link compileFieldSQL} `json_extract` sibling used to tell a
558
469
  * PRESENT JSON `null` apart from an ABSENT path (both read back as SQL `NULL`
559
470
  * through `json_extract`, but `json_type` reports `'null'` for the former and
560
471
  * SQL `NULL` for the latter).
@@ -564,12 +475,14 @@ function stepToSchema(schema, step) {
564
475
  *
565
476
  * @example
566
477
  * ```ts
567
- * jsonTypeColumn(['payload', 'user', 'id']) // "json_type(\"payload\", '$.user.id')"
478
+ * compileJSONTypeSQL(['payload', 'user', 'id']) // "json_type(\"payload\", '$.user.id')"
568
479
  * ```
569
480
  */
570
- function jsonTypeColumn(path) {
571
- const rest = path.slice(1).map((key) => "." + key.replaceAll("'", "''")).join("");
572
- return "json_type(" + quote(path[0]) + ", '$" + rest + "')";
481
+ function compileJSONTypeSQL(path) {
482
+ const [column, ...nested] = path;
483
+ if (column === void 0) throw new _src_core.DatabaseError("VALIDATION", "A field path must contain at least one column");
484
+ const rest = nested.map((key) => "." + key.replaceAll("'", "''")).join("");
485
+ return "json_type(" + quoteIdentifier(column) + ", '$" + rest + "')";
573
486
  }
574
487
  /**
575
488
  * Escape `\`, `%`, and `_` (each with a leading `\`) so a `starts` / `ends`
@@ -591,15 +504,15 @@ function escapeLike(text) {
591
504
  *
592
505
  * @param column - The column name
593
506
  * @param schema - The table's schema
594
- * @returns The column's {@link ColumnType}, or `undefined` if the schema does not carry it
507
+ * @returns The column's {@link ColumnStorage}, or `undefined` if the schema does not carry it
595
508
  *
596
509
  * @example
597
510
  * ```ts
598
- * declaredType('age', schema) // 'integer'
511
+ * findColumnStorage('age', schema) // 'integer'
599
512
  * ```
600
513
  */
601
- function declaredType(column, schema) {
602
- return schema.columns.find((candidate) => candidate.name === column)?.type;
514
+ function findColumnStorage(column, schema) {
515
+ return schema.columns.find((candidate) => candidate.name === column)?.storage;
603
516
  }
604
517
  /**
605
518
  * The storage type a nested (`json_extract`) operand encodes as, derived from its
@@ -614,15 +527,15 @@ function declaredType(column, schema) {
614
527
  * edge of comparing against a json subtree).
615
528
  *
616
529
  * @param value - The runtime operand value
617
- * @returns The {@link ColumnType} to encode it as
530
+ * @returns The {@link ColumnStorage} to encode it as
618
531
  *
619
532
  * @example
620
533
  * ```ts
621
- * valueType(true) // 'boolean'
622
- * valueType(9) // 'integer'
534
+ * inferValueStorage(true) // 'boolean'
535
+ * inferValueStorage(9) // 'integer'
623
536
  * ```
624
537
  */
625
- function valueType(value) {
538
+ function inferValueStorage(value) {
626
539
  if (typeof value === "boolean") return "boolean";
627
540
  if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
628
541
  if (typeof value === "bigint") return "integer";
@@ -630,7 +543,7 @@ function valueType(value) {
630
543
  return "text";
631
544
  }
632
545
  /**
633
- * Compile one condition to its `<column> <operator>` SQL fragment and the params
546
+ * Compile one condition to its `<column> <operator>` SQL fragment and the parameters
634
547
  * it binds — engine-exact under SQL's three-valued NULL logic.
635
548
  *
636
549
  * @remarks
@@ -640,7 +553,7 @@ function valueType(value) {
640
553
  * encodes each operand as the NATIVE scalar `json_extract` returns, derived from
641
554
  * the operand's runtime type (per-operand, since `between` / `any` / `none` can
642
555
  * mix types). `any` / `none` collapse an empty list to a constant (`0` matches
643
- * nothing, `1` matches all) with no params.
556
+ * nothing, `1` matches all) with no parameters.
644
557
  *
645
558
  * The core engine's total order ranks `undefined` (rank 0) BELOW `null`
646
559
  * (rank 1) (see `compareValues`), so a MISSING/`NULL` column MATCHES
@@ -668,13 +581,11 @@ function valueType(value) {
668
581
  * absent | true | true | false
669
582
  * ```
670
583
  *
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`.
584
+ * A flat SQL `NULL` represents absence or explicit null according to its
585
+ * {@link ColumnSchema}; optional-and-nullable columns use a storage-class
586
+ * sentinel to distinguish the two. The native exactness gate therefore
587
+ * refines every optional or nullable scalar comparison through the core engine.
588
+ * This compiler still emits a total SQL fragment for direct consumers.
678
589
  *
679
590
  * A NESTED path can be present-but-`null` (a stored JSON `null`), which
680
591
  * `json_extract` reads back as SQL `NULL` — indistinguishable from an ABSENT
@@ -694,135 +605,142 @@ function valueType(value) {
694
605
  *
695
606
  * @example
696
607
  * ```ts
697
- * fragment({ column: 'age', operator: 'above', values: [18], connector: 'and' }, schema)
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] }
608
+ * compileConditionSQL({ column: 'age', operator: 'above', values: [18], connector: 'and' }, schema)
609
+ * // { sql: '"age" > ?', parameters: [18] }
610
+ * compileConditionSQL({ column: 'age', operator: 'below', values: [18], connector: 'and' }, schema)
611
+ * // { sql: '("age" < ? OR "age" IS NULL)', parameters: [18] }
701
612
  * ```
702
613
  */
703
- function fragment(condition, schema) {
704
- const column = fieldColumn(condition.column);
614
+ function compileConditionSQL(condition, schema) {
615
+ const column = compileFieldSQL(condition.column);
705
616
  const nested = !(0, _orkestrel_contract.isString)(condition.column);
706
- const declared = (0, _orkestrel_contract.isString)(condition.column) ? declaredType(condition.column, schema) : void 0;
707
- const encode = (value) => encodeValue(value, nested ? valueType(value) : declared ?? "json");
617
+ const declared = (0, _orkestrel_contract.isString)(condition.column) ? schema.columns.find((candidate) => candidate.name === condition.column) : void 0;
708
618
  const first = condition.values[0];
709
619
  const second = condition.values[1];
710
620
  const nullOperand = first === null || first === void 0;
711
- const jsonType = !(0, _orkestrel_contract.isString)(condition.column) ? jsonTypeColumn(condition.column) : "";
621
+ const jsonType = !(0, _orkestrel_contract.isString)(condition.column) ? compileJSONTypeSQL(condition.column) : "";
622
+ let sql;
623
+ let values;
712
624
  switch (condition.operator) {
713
625
  case "equals":
714
626
  if (nullOperand && nested) return {
715
627
  sql: jsonType + " = 'null'",
716
- params: []
717
- };
718
- return {
719
- sql: column + " = ?",
720
- params: [encode(first)]
628
+ parameters: []
721
629
  };
630
+ sql = column + " = ?";
631
+ values = [first];
632
+ break;
722
633
  case "not":
723
634
  if (nullOperand) {
724
635
  if (nested) return {
725
636
  sql: "(" + jsonType + " IS NULL OR " + jsonType + " != 'null')",
726
- params: []
637
+ parameters: []
727
638
  };
728
639
  return {
729
640
  sql: "1",
730
- params: []
641
+ parameters: []
731
642
  };
732
643
  }
733
- return {
734
- sql: "(" + column + " != ? OR " + column + " IS NULL)",
735
- params: [encode(first)]
736
- };
737
- case "above": return {
738
- sql: column + " > ?",
739
- params: [encode(first)]
740
- };
741
- case "below": return {
742
- sql: "(" + column + " < ? OR " + column + " IS NULL)",
743
- params: [encode(first)]
744
- };
745
- case "from": return {
746
- sql: column + " >= ?",
747
- params: [encode(first)]
748
- };
749
- case "to": return {
750
- sql: "(" + column + " <= ? OR " + column + " IS NULL)",
751
- params: [encode(first)]
752
- };
753
- case "between": return {
754
- sql: column + " BETWEEN ? AND ?",
755
- params: [encode(first), encode(second)]
756
- };
757
- case "like": return {
758
- sql: column + " LIKE ?",
759
- params: [encode(first)]
760
- };
761
- case "glob": return {
762
- sql: column + " GLOB ?",
763
- params: [encode(first)]
764
- };
644
+ sql = "(" + column + " != ? OR " + column + " IS NULL)";
645
+ values = [first];
646
+ break;
647
+ case "above":
648
+ sql = column + " > ?";
649
+ values = [first];
650
+ break;
651
+ case "below":
652
+ sql = "(" + column + " < ? OR " + column + " IS NULL)";
653
+ values = [first];
654
+ break;
655
+ case "from":
656
+ sql = column + " >= ?";
657
+ values = [first];
658
+ break;
659
+ case "to":
660
+ sql = "(" + column + " <= ? OR " + column + " IS NULL)";
661
+ values = [first];
662
+ break;
663
+ case "between":
664
+ sql = column + " BETWEEN ? AND ?";
665
+ values = [first, second];
666
+ break;
667
+ case "like":
668
+ sql = column + " LIKE ?";
669
+ values = [first];
670
+ break;
671
+ case "glob":
672
+ sql = column + " GLOB ?";
673
+ values = [first];
674
+ break;
765
675
  case "starts": {
766
676
  const text = (0, _orkestrel_contract.isString)(first) ? first : "";
767
677
  if (text === "") return {
768
678
  sql: "typeof(" + column + ") = 'text'",
769
- params: []
679
+ parameters: []
770
680
  };
771
681
  const length = Array.from(text).length;
772
- return {
773
- sql: "(typeof(" + column + ") = 'text' AND substr(" + column + ", 1, " + length + ") = ?)",
774
- params: [encode(first)]
775
- };
682
+ sql = "(typeof(" + column + ") = 'text' AND substr(" + column + ", 1, " + length + ") = ?)";
683
+ values = [first];
684
+ break;
776
685
  }
777
686
  case "ends": {
778
687
  const text = (0, _orkestrel_contract.isString)(first) ? first : "";
779
688
  if (text === "") return {
780
689
  sql: "typeof(" + column + ") = 'text'",
781
- params: []
690
+ parameters: []
782
691
  };
783
692
  const length = Array.from(text).length;
784
- return {
785
- sql: "(typeof(" + column + ") = 'text' AND substr(" + column + ", -" + length + ") = ?)",
786
- params: [encode(first)]
787
- };
693
+ sql = "(typeof(" + column + ") = 'text' AND substr(" + column + ", -" + length + ") = ?)";
694
+ values = [first];
695
+ break;
788
696
  }
789
697
  case "any":
790
698
  if (condition.values.length === 0) return {
791
699
  sql: "0",
792
- params: []
793
- };
794
- return {
795
- sql: column + " IN (" + condition.values.map(() => "?").join(", ") + ")",
796
- params: condition.values.map(encode)
700
+ parameters: []
797
701
  };
702
+ sql = column + " IN (" + condition.values.map(() => "?").join(", ") + ")";
703
+ values = condition.values;
704
+ break;
798
705
  case "none":
799
706
  if (condition.values.length === 0) return {
800
707
  sql: "1",
801
- params: []
802
- };
803
- return {
804
- sql: "(" + column + " NOT IN (" + condition.values.map(() => "?").join(", ") + ") OR " + column + " IS NULL)",
805
- params: condition.values.map(encode)
708
+ parameters: []
806
709
  };
710
+ sql = "(" + column + " NOT IN (" + condition.values.map(() => "?").join(", ") + ") OR " + column + " IS NULL)";
711
+ values = condition.values;
712
+ break;
807
713
  case "absent": return {
808
714
  sql: column + " IS NULL",
809
- params: []
715
+ parameters: []
810
716
  };
811
717
  case "present": return {
812
718
  sql: column + " IS NOT NULL",
813
- params: []
719
+ parameters: []
814
720
  };
815
721
  }
722
+ return {
723
+ sql,
724
+ parameters: values.map((value) => {
725
+ const storage = nested ? inferValueStorage(value) : declared?.storage ?? "json";
726
+ return encodeValue(value, declared ?? {
727
+ name: "",
728
+ storage,
729
+ optional: false,
730
+ nullable: false
731
+ });
732
+ })
733
+ };
816
734
  }
817
735
  /**
818
736
  * Fold the conditions into one WHERE clause, parenthesizing progressively
819
- * left-to-right so the grouping matches the engine's `matchesCriteria` fold.
737
+ * left-to-right so the grouping matches the engine's `matchesQuery` fold.
820
738
  *
821
739
  * @remarks
822
740
  * The first condition's connector is ignored, per the {@link Condition} types.
823
- * Every fragment (see {@link fragment}'s truth table) replicates the core
741
+ * Every fragment (see {@link compileConditionSQL}'s truth table) replicates the core
824
742
  * 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
743
+ * clause matches `applyQuery` row-for-row over the same table — a native
826
744
  * `records` / `count` read never disagrees with a scan-and-filter fallback.
827
745
  *
828
746
  * @param conditions - The conditions to fold
@@ -832,26 +750,27 @@ function fragment(condition, schema) {
832
750
  * @example
833
751
  * ```ts
834
752
  * compileWhere([{ column: 'age', operator: 'from', values: [18], connector: 'and' }], schema)
835
- * // { sql: 'WHERE "age" >= ?', params: [18] }
753
+ * // { sql: 'WHERE "age" >= ?', parameters: [18] }
836
754
  * ```
837
755
  */
838
756
  function compileWhere(conditions, schema) {
839
- if (conditions.length === 0) return {
757
+ const [first, ...remaining] = conditions;
758
+ if (first === void 0) return {
840
759
  sql: "",
841
- params: []
760
+ parameters: []
842
761
  };
843
- const head = fragment(conditions[0], schema);
762
+ const head = compileConditionSQL(first, schema);
844
763
  let clause = head.sql;
845
- const params = [...head.params];
846
- for (let index = 1; index < conditions.length; index += 1) {
847
- const next = fragment(conditions[index], schema);
848
- const operator = conditions[index].connector === "or" ? "OR" : "AND";
764
+ const parameters = [...head.parameters];
765
+ for (const condition of remaining) {
766
+ const next = compileConditionSQL(condition, schema);
767
+ const operator = condition.connector === "or" ? "OR" : "AND";
849
768
  clause = "(" + clause + " " + operator + " " + next.sql + ")";
850
- params.push(...next.params);
769
+ parameters.push(...next.parameters);
851
770
  }
852
771
  return {
853
772
  sql: "WHERE " + clause,
854
- params
773
+ parameters
855
774
  };
856
775
  }
857
776
  /**
@@ -881,8 +800,8 @@ function compileWhere(conditions, schema) {
881
800
  * ```
882
801
  */
883
802
  function compileOrder(order, schema) {
884
- const terms = (order ?? []).map((term) => fieldColumn(term.column) + (term.direction === "descending" ? " DESC" : " ASC"));
885
- if (!(order ?? []).some((term) => (0, _orkestrel_contract.isString)(term.column) && term.column === schema.primary)) terms.push(quote(schema.primary));
803
+ const terms = (order ?? []).map((term) => compileFieldSQL(term.column) + (term.direction === "descending" ? " DESC" : " ASC"));
804
+ if (!(order ?? []).some((term) => (0, _orkestrel_contract.isString)(term.column) && term.column === schema.primary)) terms.push(quoteIdentifier(schema.primary));
886
805
  return terms.length === 0 ? "" : "ORDER BY " + terms.join(", ");
887
806
  }
888
807
  /**
@@ -898,29 +817,33 @@ function compileOrder(order, schema) {
898
817
  *
899
818
  * @example
900
819
  * ```ts
901
- * compilePage(undefined, 5) // { sql: 'LIMIT -1 OFFSET ?', params: [5] }
820
+ * compilePage(undefined, 5) // { sql: 'LIMIT -1 OFFSET ?', parameters: [5] }
902
821
  * ```
903
822
  */
904
823
  function compilePage(limit, offset) {
824
+ (0, _src_core.validatePage)({
825
+ ...limit === void 0 ? {} : { limit },
826
+ ...offset === void 0 ? {} : { offset }
827
+ });
905
828
  if (limit !== void 0 && offset !== void 0) return {
906
829
  sql: "LIMIT ? OFFSET ?",
907
- params: [limit, offset]
830
+ parameters: [limit, offset]
908
831
  };
909
832
  if (limit !== void 0) return {
910
833
  sql: "LIMIT ?",
911
- params: [limit]
834
+ parameters: [limit]
912
835
  };
913
836
  if (offset !== void 0) return {
914
837
  sql: "LIMIT -1 OFFSET ?",
915
- params: [offset]
838
+ parameters: [offset]
916
839
  };
917
840
  return {
918
841
  sql: "",
919
- params: []
842
+ parameters: []
920
843
  };
921
844
  }
922
845
  /**
923
- * Compile a {@link Criteria} into the SQL clause that follows a table name, with
846
+ * Compile a {@link QueryInput} into the SQL clause that follows a table name, with
924
847
  * its bound parameters in clause order.
925
848
  *
926
849
  * @remarks
@@ -928,51 +851,163 @@ function compilePage(limit, offset) {
928
851
  * `[where, orderBy, limitOffset]` (each possibly empty) into one clause so a
929
852
  * `SELECT * FROM <table> <clause>` runs the whole read in the engine instead of
930
853
  * over a JS `scan`. The WHERE fold is parenthesized **left-to-right** to mirror
931
- * the core engine's `matchesCriteria` (not SQL's native AND-over-OR precedence),
854
+ * the core engine's `matchesQuery` (not SQL's native AND-over-OR precedence),
932
855
  * so a native and an engine read return identical rows. Each operand is encoded
933
856
  * via `encodeValue`: a flat column uses its declared schema type, while a nested
934
857
  * `FieldPath` (a `json_extract` read) encodes each operand as the native scalar
935
858
  * the extract returns — derived from the operand's runtime type — so it compares.
936
859
  * The 15 operators map per the databases guide's operator table, with
937
860
  * `starts` / `ends` using `LIKE … ESCAPE '\'` and an empty `any` / `none` list
938
- * collapsing to a constant. A `undefined` criteria (or one with no parts)
861
+ * collapsing to a constant. An `undefined` input (or one with no parts)
939
862
  * compiles to an empty clause.
940
863
  *
941
- * @param criteria - The read specification, or `undefined` for all rows
864
+ * @param input - The read specification, or `undefined` for all rows
942
865
  * @param schema - The table's schema (column types for operand encoding)
943
866
  * @returns The SQL tail and its bound parameters
944
867
  *
945
868
  * @example
946
869
  * ```ts
947
- * compileCriteria({ conditions: [{ column: 'age', operator: 'from', values: [18], connector: 'and' }] }, schema)
948
- * // { sql: 'WHERE "age" >= ? ORDER BY "id"', params: [18] }
870
+ * compileQuerySQL({ conditions: [{ column: 'age', operator: 'from', values: [18], connector: 'and' }] }, schema)
871
+ * // { sql: 'WHERE "age" >= ? ORDER BY "id"', parameters: [18] }
949
872
  * ```
950
873
  */
951
- function compileCriteria(criteria, schema) {
952
- const where = compileWhere(criteria?.conditions ?? [], schema);
953
- const orderBy = compileOrder(criteria?.order, schema);
954
- const page = compilePage(criteria?.limit, criteria?.offset);
874
+ function compileQuerySQL(input, schema) {
875
+ (0, _src_core.validatePage)(input);
876
+ const where = compileWhere(input?.conditions ?? [], schema);
877
+ const orderBy = compileOrder(input?.order, schema);
878
+ const page = compilePage(input?.limit, input?.offset);
955
879
  return {
956
880
  sql: [
957
881
  where.sql,
958
882
  orderBy,
959
883
  page.sql
960
884
  ].filter((part) => part !== "").join(" "),
961
- params: [...where.params, ...page.params]
885
+ parameters: [...where.parameters, ...page.parameters]
962
886
  };
963
887
  }
888
+ /**
889
+ * Project a {@link TableSchema} to its `CREATE TABLE IF NOT EXISTS` statement.
890
+ *
891
+ * @param schema - The table schema
892
+ * @returns The complete table declaration
893
+ */
894
+ function schemaToTable(schema) {
895
+ const columns = schema.columns.map((column) => quoteIdentifier(column.name) + " " + compileColumnSQL(column.storage) + (column.optional || column.nullable ? "" : " NOT NULL"));
896
+ return "CREATE TABLE IF NOT EXISTS " + quoteIdentifier(schema.name) + " (" + columns.join(", ") + ", PRIMARY KEY (" + quoteIdentifier(schema.primary) + "))";
897
+ }
898
+ /**
899
+ * Project a {@link TableSchema} to its declared SQLite indexes.
900
+ *
901
+ * @param schema - The table schema
902
+ * @returns One statement per declared index
903
+ */
904
+ function schemaToIndexes(schema) {
905
+ return schema.indexes.map((group) => "CREATE INDEX IF NOT EXISTS " + quoteIdentifier(deriveSQLiteIndexName(schema.name, group)) + " ON " + quoteIdentifier(schema.name) + " (" + group.map(quoteIdentifier).join(", ") + ")");
906
+ }
907
+ /**
908
+ * Project one {@link MigrationStep} to SQLite DDL.
909
+ *
910
+ * @param step - The migration step
911
+ * @returns The statements that apply the step
912
+ */
913
+ function stepToSQL(step) {
914
+ switch (step.operation) {
915
+ case "table.add": return [schemaToTable(step.table), ...schemaToIndexes(step.table)];
916
+ case "table.remove": return ["DROP TABLE IF EXISTS " + quoteIdentifier(step.table)];
917
+ case "column.add": return ["ALTER TABLE " + quoteIdentifier(step.table) + " ADD COLUMN " + quoteIdentifier(step.column.name) + " " + compileColumnSQL(step.column.storage) + (step.column.optional || step.column.nullable ? "" : " NOT NULL")];
918
+ case "column.remove": return ["ALTER TABLE " + quoteIdentifier(step.table) + " DROP COLUMN " + quoteIdentifier(step.column)];
919
+ case "index.add": return ["CREATE INDEX IF NOT EXISTS " + quoteIdentifier(deriveSQLiteIndexName(step.table, step.index)) + " ON " + quoteIdentifier(step.table) + " (" + step.index.map(quoteIdentifier).join(", ") + ")"];
920
+ case "index.remove": return ["DROP INDEX IF EXISTS " + quoteIdentifier(deriveSQLiteIndexName(step.table, step.index))];
921
+ }
922
+ }
964
923
  //#endregion
965
- //#region src/server/constants.ts
924
+ //#region src/core/DriverIterator.ts
966
925
  /**
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.
926
+ * The internal continuation boundary for a root driver async iterator.
970
927
  *
971
928
  * @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.
929
+ * A driver transaction can begin while a caller holds an idle root iterator.
930
+ * Every `next` therefore checks the driver's root-state guard immediately
931
+ * before and after advancing the source. A failed continuation terminalizes the
932
+ * iterator, discards any row produced before the post-advance guard failed, and
933
+ * attempts source cleanup exactly once.
974
934
  */
975
- var META_TABLE = "_meta";
935
+ var DriverIterator = class {
936
+ #source;
937
+ #guard;
938
+ #terminal = false;
939
+ #cleaned = false;
940
+ constructor(source, guard) {
941
+ this.#source = source;
942
+ this.#guard = guard;
943
+ }
944
+ [Symbol.asyncIterator]() {
945
+ return this;
946
+ }
947
+ async next() {
948
+ if (this.#terminal) return {
949
+ done: true,
950
+ value: void 0
951
+ };
952
+ try {
953
+ this.#guard();
954
+ const result = await this.#source.next();
955
+ this.#guard();
956
+ if (result.done === true) {
957
+ this.#terminal = true;
958
+ this.#cleaned = true;
959
+ }
960
+ return result;
961
+ } catch (error) {
962
+ this.#terminal = true;
963
+ await this.#discard();
964
+ throw error;
965
+ }
966
+ }
967
+ async return() {
968
+ if (this.#terminal) return {
969
+ done: true,
970
+ value: void 0
971
+ };
972
+ this.#terminal = true;
973
+ if (this.#cleaned || this.#source.return === void 0) {
974
+ this.#cleaned = true;
975
+ return {
976
+ done: true,
977
+ value: void 0
978
+ };
979
+ }
980
+ this.#cleaned = true;
981
+ return this.#source.return();
982
+ }
983
+ async throw(error) {
984
+ if (this.#terminal) throw error;
985
+ if (this.#source.throw === void 0) {
986
+ this.#terminal = true;
987
+ await this.#discard();
988
+ throw error;
989
+ }
990
+ try {
991
+ const result = await this.#source.throw(error);
992
+ if (result.done === true) {
993
+ this.#terminal = true;
994
+ this.#cleaned = true;
995
+ }
996
+ return result;
997
+ } catch (cause) {
998
+ this.#terminal = true;
999
+ await this.#discard();
1000
+ throw cause;
1001
+ }
1002
+ }
1003
+ async #discard() {
1004
+ if (this.#cleaned) return;
1005
+ this.#cleaned = true;
1006
+ try {
1007
+ await this.#source.return?.();
1008
+ } catch {}
1009
+ }
1010
+ };
976
1011
  //#endregion
977
1012
  //#region src/server/drivers/JSONDriver.ts
978
1013
  /**
@@ -980,229 +1015,619 @@ var META_TABLE = "_meta";
980
1015
  * reference {@link MemoryDriver} plus file load / flush.
981
1016
  *
982
1017
  * @remarks
983
- * A decorator, not a reimplementation: every primitive delegates to an inner
984
- * {@link MemoryDriver}, so querying, key-order `scan` / `keys`, and capture-replay
985
- * `snapshot` are inherited unchanged this layer adds only persistence. `open`
1018
+ * A decorator, not a reimplementation: storage primitives delegate to an inner
1019
+ * {@link MemoryDriver}, while this layer owns persistence, writer ordering,
1020
+ * isolated transactions, and queued row-snapshot restoration. `open`
986
1021
  * loads the file into memory; every mutation (`write` / `delete` / `clear`) flushes
987
- * the whole store back. The file is one JSON object, `{ meta?: DriverMeta, tables: {
988
- * [name]: rows } }` — `meta` is present only once the store has been `stamp`ed
989
- * (an unstamped store serializes the old `{ tables }` shape, preserving
990
- * backward compatibility); a per-table array of rows, each row carrying its own
1022
+ * the whole store back. The file is one JSON object, `{ metadata?: DriverMetadata, tables: {
1023
+ * [name]: rows } }` — `metadata` is present only once the store has been `stamp`ed
1024
+ * (an unstamped store omits `metadata`); a per-table array of rows, each row carrying its own
991
1025
  * primary (the table contract), so the key is recovered on load with
992
1026
  * {@link extractKey} and the file need not store it. The parsed JSON crosses the
993
1027
  * boundary as `unknown` and is narrowed with {@link isRecord} / {@link extractKey},
994
- * never asserted (AGENTS §14): a missing, corrupt, or wrong-shaped file starts
995
- * empty rather than throwing, and a malformed row (or malformed `meta`) is
996
- * skipped/dropped rather than thrown on. It is scan-only — it implements none of
997
- * the optional native `records` / `count` / `aggregate` hooks, so the core engine
1028
+ * never asserted (AGENTS §14). Only an `ENOENT` read starts empty; every other
1029
+ * read failure or invalid existing document fails closed without publication,
1030
+ * mutation, or automatic repair. It is scan-only — it implements none of
1031
+ * the optional native `records` / `aggregate` hooks, so the core engine
998
1032
  * over `scan` answers every query. For development, small datasets, and portable /
999
1033
  * inspectable data; for large or concurrent workloads reach for a SQLite-backed
1000
1034
  * driver.
1001
1035
  *
1002
- * A failure in the write path ({@link JSONDriver.#serialize} `mkdir` /
1003
- * `writeFile` / `rename`) is wrapped and rethrown as `DatabaseError` `DRIVER`,
1004
- * carrying the target `path` in its context; the read path ({@link
1005
- * JSONDriver.#load}) tolerance above is a separate, deliberate contract and is
1006
- * never touched by this wrapping.
1036
+ * Metadata crosses {@link cloneDriverMetadata} at parsed-file ingress, public and
1037
+ * scoped write ingress, candidate/root publication, serialization, and copy-out.
1038
+ * Callers therefore cannot mutate queued metadata, and `metadata()` always returns a
1039
+ * distinct deeply frozen snapshot. A failure in the write path ({@link
1040
+ * JSONDriver.#serialize} `mkdir` / `writeFile` / `rename`) is wrapped and
1041
+ * rethrown as `DatabaseError` `DRIVER`, carrying the target `path` and native
1042
+ * `cause` in its context. If temporary-file cleanup also fails, the top-level
1043
+ * `DRIVER` context additionally carries `temp` and `cleanup`; a precommit abort
1044
+ * remains an `ABORTED` `DatabaseError` in `context.cause`. The fail-closed read path
1045
+ * ({@link JSONDriver.#document}) remains separate from this write-error contract.
1007
1046
  */
1008
1047
  var JSONDriver = class {
1009
1048
  #path;
1010
1049
  #memory = new _src_core.MemoryDriver();
1050
+ #identities = /* @__PURE__ */ new Map();
1011
1051
  #schema = [];
1012
- #meta;
1052
+ #metadata;
1013
1053
  #flushCount = 0;
1014
1054
  #chain = Promise.resolve();
1015
- #deferring = false;
1055
+ #transaction;
1056
+ #candidate;
1057
+ #candidateIdentities;
1058
+ #candidateSchema;
1016
1059
  constructor(path) {
1017
1060
  this.#path = path;
1018
1061
  }
1019
1062
  async open(schema) {
1020
- this.#schema = schema;
1021
- await this.#memory.open(schema);
1022
- await this.#load();
1063
+ this.#root();
1064
+ const owned = (0, _src_core.normalizeDriverSchema)(schema);
1065
+ await this.#enqueue(() => this.#open(owned));
1023
1066
  }
1024
1067
  async close() {
1068
+ this.#root();
1069
+ await this.#chain;
1025
1070
  await this.#memory.close();
1026
1071
  }
1027
1072
  async read(table, key) {
1073
+ this.#root();
1074
+ await this.#chain;
1028
1075
  return this.#memory.read(table, key);
1029
1076
  }
1030
- async write(table, key, row) {
1031
- await this.#memory.write(table, key, row);
1032
- if (!this.#deferring) await this.#flush();
1077
+ async write(table, key, row, options) {
1078
+ this.#root();
1079
+ await this.#enqueue(() => this.#write(table, key, row, options), options?.signal);
1080
+ }
1081
+ async insert(table, key, row, options) {
1082
+ this.#root();
1083
+ await this.#enqueue(() => this.#insert(table, key, row, options), options?.signal);
1033
1084
  }
1034
- async delete(table, key) {
1035
- const removed = await this.#memory.delete(table, key);
1036
- if (!this.#deferring) await this.#flush();
1037
- return removed;
1085
+ async delete(table, key, options) {
1086
+ this.#root();
1087
+ return this.#enqueue(() => this.#delete(table, key, options), options?.signal);
1038
1088
  }
1039
- keys(table) {
1089
+ async keys(table) {
1090
+ this.#root();
1091
+ await this.#chain;
1040
1092
  return this.#memory.keys(table);
1041
1093
  }
1042
1094
  scan(table) {
1043
- return this.#memory.scan(table);
1095
+ return new DriverIterator(this.#scan(table)[Symbol.asyncIterator](), () => this.#root());
1044
1096
  }
1045
1097
  /**
1046
1098
  * Natively filtered lazy iteration — delegates to the inner {@link MemoryDriver}.
1047
1099
  *
1048
1100
  * @remarks
1049
- * Semantics are the memory driver's own: `criteria.conditions` filters, `offset`
1050
- * / `limit` page lazily, and `criteria.order` is ignored (streaming yields key
1101
+ * Semantics are the memory driver's own: `input.conditions` filters, `offset`
1102
+ * / `limit` page lazily, and `input.order` is ignored (streaming yields key
1051
1103
  * order; sorted output is `records()`'s job).
1052
1104
  *
1053
1105
  * @param table - The table to stream
1054
- * @param criteria - The filter / offset / limit to apply lazily
1106
+ * @param input - The filter / offset / limit to apply lazily
1055
1107
  */
1056
- stream(table, criteria) {
1057
- return this.#memory.stream(table, criteria);
1108
+ stream(table, input) {
1109
+ (0, _src_core.validatePage)(input);
1110
+ return new DriverIterator(this.#stream(table, input)[Symbol.asyncIterator](), () => this.#root());
1058
1111
  }
1059
1112
  async clear(table) {
1060
- await this.#memory.clear(table);
1061
- if (!this.#deferring) await this.#flush();
1113
+ this.#root();
1114
+ await this.#enqueue(() => this.#clear(table));
1062
1115
  }
1063
1116
  /**
1064
- * Begin a native transaction flush-coalescing over the inner {@link MemoryDriver}.
1117
+ * Run an isolated native transaction callback over a candidate memory store.
1065
1118
  *
1066
1119
  * @remarks
1067
- * Single-writer: throws `DatabaseError` `CONFLICT` if a transaction is already
1068
- * active this driver does not support nesting. On begin, captures the inner
1069
- * memory rollback thunk via `#memory.snapshot()` and suppresses per-mutation
1070
- * `#flush` `write` / `delete` / `clear` still mutate memory but no longer
1071
- * touch the file, so N mutations under the handle cost ONE file write instead
1072
- * of N. `commit()` releases the suppression and performs that one atomic
1073
- * `#flush()`, persisting the transaction's net state. `rollback()` restores
1074
- * memory via the captured snapshot thunk, then `#flush()`s so the file reflects
1075
- * the restored state. Outside a transaction, behavior is unchanged — every
1076
- * mutation flushes on its own. Calling `commit` / `rollback` a second time (on
1077
- * either method, in either order) throws `DatabaseError` `CONFLICT`.
1120
+ * Single-writer: nesting and root operations while active throw `CONFLICT`.
1121
+ * The callback receives a capability over cloned rows, schema, and metadata.
1122
+ * Fulfillment atomically serializes that candidate and publishes it to root
1123
+ * memory only after file replacement succeeds. Rejection or persistence
1124
+ * failure discards the candidate, and every captured capability call after
1125
+ * settlement throws `CONFLICT`.
1078
1126
  *
1079
- * @returns A {@link TransactionInterface} handle to `commit` or `rollback`
1127
+ * @returns The callback's resolved value
1080
1128
  */
1081
- async transaction() {
1082
- if (this.#deferring) throw new _src_core.DatabaseError("CONFLICT", "A transaction is already active on this driver", {});
1083
- const rollback = await this.#memory.snapshot();
1084
- this.#deferring = true;
1085
- let settled = false;
1086
- return {
1087
- commit: async () => {
1088
- if (settled) throw new _src_core.DatabaseError("CONFLICT", "Transaction already settled", {});
1089
- settled = true;
1090
- this.#deferring = false;
1091
- await this.#flush();
1092
- },
1093
- rollback: async () => {
1094
- if (settled) throw new _src_core.DatabaseError("CONFLICT", "Transaction already settled", {});
1095
- settled = true;
1096
- await rollback();
1097
- this.#deferring = false;
1098
- await this.#flush();
1099
- }
1100
- };
1129
+ async transaction(scope) {
1130
+ this.#root();
1131
+ return this.#enqueue(() => this.#transact(scope));
1101
1132
  }
1133
+ /**
1134
+ * Capture an owned row snapshot at an exact writer-queue position.
1135
+ *
1136
+ * @remarks
1137
+ * Capture owns table names, schemas, rows, and one session-local identity per
1138
+ * table. Rollback is repeatable: it clones the then-current root into a
1139
+ * candidate, adapts captured rows to each surviving same-identity table
1140
+ * through the portable migration engine, persists the candidate with current
1141
+ * metadata, and publishes memory only after file replacement succeeds.
1142
+ * Removed, replaced, uncaptured, and later-added tables remain untouched.
1143
+ *
1144
+ * @param tables - Existing tables to capture; omitted captures every current table
1145
+ * @returns A repeatable rollback operation
1146
+ */
1102
1147
  async snapshot(tables) {
1103
- const rollback = await this.#memory.snapshot(tables);
1148
+ this.#root();
1149
+ const names = tables === void 0 ? void 0 : [...tables];
1150
+ const captured = await this.#enqueue(() => this.#capture(names));
1104
1151
  return async () => {
1105
- await rollback();
1106
- await this.#flush();
1152
+ this.#root();
1153
+ await this.#enqueue(() => this.#restore(captured));
1107
1154
  };
1108
1155
  }
1109
- async meta() {
1110
- return this.#meta;
1156
+ async metadata() {
1157
+ this.#root();
1158
+ await this.#chain;
1159
+ return this.#metadata === void 0 ? void 0 : (0, _src_core.cloneDriverMetadata)(this.#metadata);
1111
1160
  }
1112
1161
  /**
1113
- * Persist `meta` verbatim for a later `meta()` to return.
1162
+ * Persist an owned metadata snapshot for a later `metadata()` to copy out.
1114
1163
  *
1115
1164
  * @remarks
1116
- * Respects the same defer-flush suppression as `write` / `delete` / `clear`
1117
- * (see {@link JSONDriver.transaction} @remarks) stamping inside an active
1118
- * transaction updates memory but does not flush until the transaction settles.
1165
+ * Root stamping conflicts while a transaction is active. The scoped
1166
+ * {@link StorageInterface.stamp} updates candidate metadata and publishes
1167
+ * with the candidate rows on callback fulfillment.
1119
1168
  *
1120
- * @param meta - The {@link DriverMeta} to persist
1169
+ * @param metadata - The {@link DriverMetadata} to persist
1121
1170
  */
1122
- async stamp(meta) {
1123
- this.#meta = meta;
1124
- if (!this.#deferring) await this.#flush();
1171
+ async stamp(metadata) {
1172
+ this.#root();
1173
+ const owned = (0, _src_core.cloneDriverMetadata)(metadata);
1174
+ await this.#enqueue(() => this.#stamp(owned));
1125
1175
  }
1126
1176
  /**
1127
- * Apply a {@link Migration} plan by delegating to the inner {@link MemoryDriver},
1128
- * then persist the migrated state.
1177
+ * Apply one atomic {@link MigrationInput} through an isolated candidate.
1129
1178
  *
1130
1179
  * @remarks
1131
- * The inner `MemoryDriver.migrate` applies each step (adding/removing tables,
1132
- * adding/removing columns from stored rows, no-op index steps) and throws
1133
- * `DatabaseError` `MIGRATION` for a step referencing an unknown table — that
1134
- * error propagates untouched. `table.add` / `table.remove` steps also update
1135
- * this driver's own declared `#schema`, mirroring the bookkeeping `open` does,
1136
- * so a subsequent `#flush` / `#load` round-trip includes (or drops) the table.
1137
- * A successful migration ends with one atomic `#flush()` so the new state
1138
- * survives a close and reopen. A multi-step plan applies its steps
1139
- * sequentially and is NOT atomic — a failure partway through a plan leaves
1140
- * the earlier steps already applied.
1180
+ * The candidate receives every plan step plus optional metadata. Its complete
1181
+ * rows, derived schema, and metadata serialize through one atomic file
1182
+ * replacement before root memory changes. Any migration or persistence failure
1183
+ * therefore leaves root state and the prior file exact.
1141
1184
  *
1142
- * @param plan - The migration plan to apply
1185
+ * @param input - The plan and optional metadata to settle together
1143
1186
  */
1144
- async migrate(plan) {
1145
- await this.#memory.migrate?.(plan);
1146
- let schema = this.#schema;
1147
- for (const step of plan.steps) if (step.operation === "table.add") schema = schema.some((table) => table.name === step.table.name) ? schema : [...schema, step.table];
1148
- else if (step.operation === "table.remove") schema = schema.filter((table) => table.name !== step.table);
1149
- this.#schema = schema;
1150
- await this.#flush();
1187
+ async migrate(input) {
1188
+ this.#root();
1189
+ const owned = (0, _src_core.cloneMigrationInput)(input);
1190
+ await this.#enqueue(() => this.#migrate(owned));
1151
1191
  }
1152
- async #load() {
1153
- let raw;
1154
- try {
1155
- raw = await (0, node_fs_promises.readFile)(this.#path, "utf-8");
1156
- } catch {
1157
- return;
1192
+ async *#scan(table) {
1193
+ await this.#chain;
1194
+ for await (const row of this.#memory.scan(table)) yield row;
1195
+ }
1196
+ async *#stream(table, input) {
1197
+ await this.#chain;
1198
+ for await (const row of this.#memory.stream(table, input)) yield row;
1199
+ }
1200
+ async #open(declared) {
1201
+ const parsed = await this.#document();
1202
+ let stored;
1203
+ let tables;
1204
+ if (parsed === void 0) {
1205
+ const fresh = {};
1206
+ for (const table of declared) fresh[table.name] = [];
1207
+ tables = fresh;
1208
+ } else {
1209
+ if (!(0, _orkestrel_contract.isRecord)(parsed) || !Object.hasOwn(parsed, "tables") || Object.keys(parsed).some((key) => key !== "tables" && key !== "metadata")) throw new _src_core.DatabaseError("DRIVER", "Stored JSON database document is invalid", {
1210
+ path: this.#path,
1211
+ aspect: "document"
1212
+ });
1213
+ if (!(0, _orkestrel_contract.isRecord)(parsed.tables)) throw new _src_core.DatabaseError("DRIVER", "Stored JSON tables are invalid", {
1214
+ path: this.#path,
1215
+ aspect: "tables"
1216
+ });
1217
+ tables = parsed.tables;
1218
+ if (Object.hasOwn(parsed, "metadata")) try {
1219
+ stored = (0, _src_core.cloneDriverMetadata)(parsed.metadata);
1220
+ } catch {
1221
+ const cause = new _src_core.DatabaseError("VALIDATION", "Stored JSON metadata failed validation", { path: "metadata" });
1222
+ throw new _src_core.DatabaseError("DRIVER", "Stored JSON metadata is invalid", {
1223
+ path: this.#path,
1224
+ aspect: "metadata",
1225
+ cause
1226
+ });
1227
+ }
1158
1228
  }
1159
- let parsed;
1229
+ const schema = (0, _src_core.normalizeDriverSchema)(stored?.schema ?? declared);
1230
+ const names = new Set(schema.map((table) => table.name));
1231
+ for (const table of schema) if (!Object.hasOwn(tables, table.name)) throw new _src_core.DatabaseError("DRIVER", "Stored JSON table set is invalid", {
1232
+ path: this.#path,
1233
+ table: table.name,
1234
+ aspect: "missing"
1235
+ });
1236
+ const unknown = Object.keys(tables).filter((name) => !names.has(name)).length;
1237
+ if (unknown > 0) throw new _src_core.DatabaseError("DRIVER", "Stored JSON table set is invalid", {
1238
+ path: this.#path,
1239
+ aspect: "unknown",
1240
+ count: unknown
1241
+ });
1242
+ const memory = new _src_core.MemoryDriver();
1243
+ await memory.open(schema);
1244
+ await this.#hydrate(memory, schema, tables);
1245
+ if (stored !== void 0) await memory.stamp(stored);
1246
+ this.#memory = memory;
1247
+ this.#schema = schema;
1248
+ this.#identities = this.#alignIdentities(schema);
1249
+ const metadata = await memory.metadata();
1250
+ this.#metadata = metadata === void 0 ? void 0 : (0, _src_core.cloneDriverMetadata)(metadata);
1251
+ }
1252
+ async #migrate(input) {
1253
+ const candidate = await this.#clone();
1254
+ const identities = this.#projectIdentities(this.#identities, input.plan.steps);
1255
+ const schema = await this.#apply(candidate, this.#schema, input);
1256
+ const metadata = await candidate.metadata();
1257
+ await this.#serialize(void 0, candidate, schema, metadata);
1258
+ this.#memory = candidate;
1259
+ this.#identities = identities;
1260
+ this.#schema = schema;
1261
+ this.#metadata = metadata === void 0 ? void 0 : (0, _src_core.cloneDriverMetadata)(metadata);
1262
+ }
1263
+ async #apply(memory, current, input) {
1264
+ const schema = (0, _src_core.projectMigrationSchema)(current, input.plan.steps);
1265
+ if (input.metadata !== void 0 && !(0, _src_core.equalsValue)((0, _src_core.normalizeDriverSchema)(input.metadata.schema), schema)) throw new _src_core.DatabaseError("MIGRATION", "Migration metadata schema does not match the plan", {
1266
+ projected: schema,
1267
+ metadata: input.metadata.schema
1268
+ });
1269
+ await memory.migrate(input);
1270
+ return schema;
1271
+ }
1272
+ async #transact(scope) {
1273
+ this.#root();
1274
+ const candidate = await this.#clone();
1275
+ const token = {};
1276
+ this.#transaction = token;
1277
+ this.#candidate = candidate;
1278
+ this.#candidateIdentities = new Map(this.#identities);
1279
+ this.#candidateSchema = this.#schema;
1160
1280
  try {
1161
- parsed = JSON.parse(raw);
1162
- } catch {
1163
- return;
1164
- }
1165
- if (!(0, _orkestrel_contract.isRecord)(parsed) || !(0, _orkestrel_contract.isRecord)(parsed.tables)) return;
1166
- const tables = parsed.tables;
1167
- for (const table of this.#schema) {
1168
- const rows = tables[table.name];
1169
- if (!Array.isArray(rows)) continue;
1170
- for (const entry of rows) {
1171
- if (!(0, _orkestrel_contract.isRecord)(entry)) continue;
1172
- const key = (0, _src_core.extractKey)(entry, table.primary);
1173
- if (key === void 0) continue;
1174
- await this.#memory.write(table.name, key, entry);
1281
+ const value = await scope(this.#capability(token));
1282
+ const schema = this.#candidateSchema;
1283
+ const identities = this.#candidateIdentities;
1284
+ if (schema === void 0 || identities === void 0) throw new _src_core.DatabaseError("CONFLICT", "Transaction scope has settled");
1285
+ this.#candidate = void 0;
1286
+ this.#candidateIdentities = void 0;
1287
+ this.#candidateSchema = void 0;
1288
+ const metadata = await candidate.metadata();
1289
+ await this.#serialize(void 0, candidate, schema, metadata);
1290
+ this.#memory = candidate;
1291
+ this.#identities = identities;
1292
+ this.#schema = schema;
1293
+ this.#metadata = metadata === void 0 ? void 0 : (0, _src_core.cloneDriverMetadata)(metadata);
1294
+ return value;
1295
+ } finally {
1296
+ if (this.#transaction === token) {
1297
+ this.#transaction = void 0;
1298
+ this.#candidate = void 0;
1299
+ this.#candidateIdentities = void 0;
1300
+ this.#candidateSchema = void 0;
1175
1301
  }
1176
1302
  }
1177
- if ((0, _src_core.isDriverMeta)(parsed.meta)) this.#meta = parsed.meta;
1178
1303
  }
1179
- async #flush() {
1180
- const next = this.#chain.then(() => this.#serialize());
1181
- this.#chain = next.catch(() => {});
1182
- await next;
1304
+ async #clone() {
1305
+ const candidate = new _src_core.MemoryDriver();
1306
+ await candidate.open(this.#schema);
1307
+ for (const table of this.#schema) for await (const row of this.#memory.scan(table.name)) {
1308
+ const key = (0, _src_core.extractKey)(row, table.primary);
1309
+ if (key !== void 0) await candidate.write(table.name, key, row);
1310
+ }
1311
+ if (this.#metadata !== void 0) await candidate.stamp((0, _src_core.cloneDriverMetadata)(this.#metadata));
1312
+ return candidate;
1183
1313
  }
1184
- async #serialize() {
1185
- const tables = {};
1314
+ async #capture(names) {
1315
+ const selected = names === void 0 ? void 0 : new Set(names);
1316
+ const captured = /* @__PURE__ */ new Map();
1186
1317
  for (const table of this.#schema) {
1318
+ if (selected !== void 0 && !selected.has(table.name)) continue;
1319
+ const identity = this.#identities.get(table.name);
1320
+ if (identity === void 0) continue;
1187
1321
  const rows = [];
1188
1322
  for await (const row of this.#memory.scan(table.name)) rows.push(row);
1323
+ captured.set(table.name, {
1324
+ identity,
1325
+ schema: table,
1326
+ rows
1327
+ });
1328
+ }
1329
+ return captured;
1330
+ }
1331
+ async #restore(captured) {
1332
+ const replacements = /* @__PURE__ */ new Map();
1333
+ for (const [name, capture] of captured) {
1334
+ const current = this.#schema.find((table) => table.name === name);
1335
+ if (current === void 0 || this.#identities.get(name) !== capture.identity) continue;
1336
+ const plan = (0, _src_core.planMigration)([capture.schema], [current]);
1337
+ const migrated = (0, _src_core.migrateRows)(capture.rows, plan.steps);
1338
+ if (migrated.length !== capture.rows.length) throw new _src_core.DatabaseError("MIGRATION", "Snapshot row count changed during migration", { table: name });
1339
+ const rows = /* @__PURE__ */ new Map();
1340
+ for (const [index, row] of migrated.entries()) {
1341
+ const key = (0, _src_core.extractKey)(row, current.primary);
1342
+ if (key === void 0) throw new _src_core.DatabaseError("MIGRATION", `migrate: captured row is missing primary column '${current.primary}'`, {
1343
+ table: name,
1344
+ column: current.primary,
1345
+ index
1346
+ });
1347
+ rows.set(key, (0, _src_core.bindRowKey)(row, current.primary, key));
1348
+ }
1349
+ replacements.set(name, rows);
1350
+ }
1351
+ const candidate = await this.#clone();
1352
+ for (const [name, rows] of replacements) {
1353
+ await candidate.clear(name);
1354
+ for (const [key, row] of rows) await candidate.write(name, key, row);
1355
+ }
1356
+ const metadata = await candidate.metadata();
1357
+ await this.#serialize(void 0, candidate, this.#schema, metadata);
1358
+ this.#memory = candidate;
1359
+ this.#metadata = metadata === void 0 ? void 0 : (0, _src_core.cloneDriverMetadata)(metadata);
1360
+ }
1361
+ #capability(token) {
1362
+ return {
1363
+ read: this.#readCandidate.bind(this, token),
1364
+ write: this.#writeCandidate.bind(this, token),
1365
+ insert: this.#insertCandidate.bind(this, token),
1366
+ delete: this.#deleteCandidate.bind(this, token),
1367
+ keys: this.#keysCandidate.bind(this, token),
1368
+ scan: this.#scanCandidate.bind(this, token),
1369
+ clear: this.#clearCandidate.bind(this, token),
1370
+ migrate: this.#migrateCandidate.bind(this, token),
1371
+ metadata: this.#metadataCandidate.bind(this, token),
1372
+ stamp: this.#stampCandidate.bind(this, token)
1373
+ };
1374
+ }
1375
+ async #readCandidate(token, table, key) {
1376
+ return this.#requireCandidate(token).read(table, key);
1377
+ }
1378
+ async #writeCandidate(token, table, key, row, options) {
1379
+ await this.#requireCandidate(token).write(table, key, row, options);
1380
+ }
1381
+ async #insertCandidate(token, table, key, row, options) {
1382
+ await this.#requireCandidate(token).insert(table, key, row, options);
1383
+ }
1384
+ async #deleteCandidate(token, table, key, options) {
1385
+ return this.#requireCandidate(token).delete(table, key, options);
1386
+ }
1387
+ async #keysCandidate(token, table) {
1388
+ return this.#requireCandidate(token).keys(table);
1389
+ }
1390
+ #scanCandidate(token, table) {
1391
+ return new DriverIterator(this.#requireCandidate(token).scan(table)[Symbol.asyncIterator](), () => {
1392
+ this.#requireCandidate(token);
1393
+ });
1394
+ }
1395
+ async #clearCandidate(token, table) {
1396
+ await this.#requireCandidate(token).clear(table);
1397
+ }
1398
+ async #migrateCandidate(token, input) {
1399
+ const memory = this.#requireCandidate(token);
1400
+ const schema = this.#candidateSchema;
1401
+ const identities = this.#candidateIdentities;
1402
+ if (schema === void 0 || identities === void 0) throw new _src_core.DatabaseError("CONFLICT", "Transaction scope has settled");
1403
+ const owned = (0, _src_core.cloneMigrationInput)(input);
1404
+ const projected = this.#projectIdentities(identities, owned.plan.steps);
1405
+ this.#candidateSchema = await this.#apply(memory, schema, owned);
1406
+ this.#candidateIdentities = projected;
1407
+ }
1408
+ async #metadataCandidate(token) {
1409
+ const metadata = await this.#requireCandidate(token).metadata();
1410
+ return metadata === void 0 ? void 0 : (0, _src_core.cloneDriverMetadata)(metadata);
1411
+ }
1412
+ async #stampCandidate(token, metadata) {
1413
+ const memory = this.#requireCandidate(token);
1414
+ const owned = (0, _src_core.cloneDriverMetadata)(metadata);
1415
+ await memory.stamp(owned);
1416
+ }
1417
+ #requireCandidate(token) {
1418
+ if (this.#transaction !== token || this.#candidate === void 0) throw new _src_core.DatabaseError("CONFLICT", "Transaction scope has settled");
1419
+ return this.#candidate;
1420
+ }
1421
+ #alignIdentities(schema) {
1422
+ const aligned = /* @__PURE__ */ new Map();
1423
+ for (const table of schema) aligned.set(table.name, this.#identities.get(table.name) ?? {});
1424
+ return aligned;
1425
+ }
1426
+ #projectIdentities(identities, steps) {
1427
+ const projected = new Map(identities);
1428
+ for (const step of steps) {
1429
+ if (step.operation === "table.add") projected.set(step.table.name, {});
1430
+ if (step.operation === "table.remove") projected.delete(step.table);
1431
+ }
1432
+ return projected;
1433
+ }
1434
+ #root() {
1435
+ if (this.#transaction !== void 0) throw new _src_core.DatabaseError("CONFLICT", "A transaction is active on this driver");
1436
+ }
1437
+ async #document() {
1438
+ let raw;
1439
+ try {
1440
+ raw = await (0, node_fs_promises.readFile)(this.#path, "utf-8");
1441
+ } catch (error) {
1442
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return;
1443
+ throw new _src_core.DatabaseError("DRIVER", "Failed to read the JSON database file", {
1444
+ path: this.#path,
1445
+ cause: error
1446
+ });
1447
+ }
1448
+ try {
1449
+ return JSON.parse(raw);
1450
+ } catch {
1451
+ throw new _src_core.DatabaseError("DRIVER", "Stored JSON database is invalid JSON", {
1452
+ path: this.#path,
1453
+ aspect: "syntax"
1454
+ });
1455
+ }
1456
+ }
1457
+ async #hydrate(memory, schema, tables) {
1458
+ for (const table of schema) {
1459
+ const rows = tables[table.name];
1460
+ if (!Array.isArray(rows)) throw new _src_core.DatabaseError("DRIVER", "Stored JSON table is invalid", {
1461
+ path: this.#path,
1462
+ table: table.name,
1463
+ aspect: "container"
1464
+ });
1465
+ const keys = /* @__PURE__ */ new Set();
1466
+ for (const [index, entry] of rows.entries()) {
1467
+ if (!(0, _orkestrel_contract.isRecord)(entry)) throw new _src_core.DatabaseError("DRIVER", "Stored JSON row is invalid", {
1468
+ path: this.#path,
1469
+ table: table.name,
1470
+ index,
1471
+ aspect: "record"
1472
+ });
1473
+ const key = (0, _src_core.extractKey)(entry, table.primary);
1474
+ if (key === void 0) throw new _src_core.DatabaseError("DRIVER", "Stored JSON row is invalid", {
1475
+ path: this.#path,
1476
+ table: table.name,
1477
+ index,
1478
+ aspect: "primary"
1479
+ });
1480
+ if (keys.has(key)) throw new _src_core.DatabaseError("DRIVER", "Stored JSON row is invalid", {
1481
+ path: this.#path,
1482
+ table: table.name,
1483
+ index,
1484
+ aspect: "duplicate"
1485
+ });
1486
+ keys.add(key);
1487
+ await memory.write(table.name, key, entry);
1488
+ }
1489
+ }
1490
+ }
1491
+ async #enqueue(operation, signal) {
1492
+ (0, _src_core.checkAbort)(signal);
1493
+ let started = false;
1494
+ const next = this.#chain.then(async () => {
1495
+ started = true;
1496
+ (0, _src_core.checkAbort)(signal);
1497
+ return operation();
1498
+ });
1499
+ this.#chain = next.then(() => {}, () => {});
1500
+ if (signal === void 0) return next;
1501
+ const cleanup = new AbortController();
1502
+ return new Promise((resolve, reject) => {
1503
+ signal.addEventListener("abort", () => {
1504
+ if (started) return;
1505
+ try {
1506
+ (0, _src_core.checkAbort)(signal);
1507
+ } catch (error) {
1508
+ reject(error);
1509
+ }
1510
+ }, {
1511
+ once: true,
1512
+ signal: cleanup.signal
1513
+ });
1514
+ next.then((result) => {
1515
+ cleanup.abort();
1516
+ resolve(result);
1517
+ }, (error) => {
1518
+ cleanup.abort();
1519
+ reject(error);
1520
+ });
1521
+ });
1522
+ }
1523
+ async #write(table, key, row, options) {
1524
+ const previous = await this.#memory.read(table, key);
1525
+ await this.#memory.write(table, key, row, options);
1526
+ try {
1527
+ await this.#serialize(options?.signal);
1528
+ } catch (error) {
1529
+ if (previous === void 0) await this.#memory.delete(table, key);
1530
+ else await this.#memory.write(table, key, previous);
1531
+ throw error;
1532
+ }
1533
+ }
1534
+ async #insert(table, key, row, options) {
1535
+ await this.#memory.insert(table, key, row, options);
1536
+ try {
1537
+ await this.#serialize(options?.signal);
1538
+ } catch (error) {
1539
+ await this.#memory.delete(table, key);
1540
+ throw error;
1541
+ }
1542
+ }
1543
+ async #delete(table, key, options) {
1544
+ const previous = await this.#memory.read(table, key);
1545
+ if (previous === void 0) {
1546
+ (0, _src_core.checkAbort)(options?.signal);
1547
+ return false;
1548
+ }
1549
+ await this.#memory.delete(table, key, options);
1550
+ try {
1551
+ await this.#serialize(options?.signal);
1552
+ } catch (error) {
1553
+ await this.#memory.write(table, key, previous);
1554
+ throw error;
1555
+ }
1556
+ return true;
1557
+ }
1558
+ async #clear(table) {
1559
+ const rollback = await this.#memory.snapshot([table]);
1560
+ await this.#memory.clear(table);
1561
+ try {
1562
+ await this.#serialize();
1563
+ } catch (error) {
1564
+ await rollback();
1565
+ throw error;
1566
+ }
1567
+ }
1568
+ async #stamp(metadata) {
1569
+ const previous = this.#metadata;
1570
+ this.#metadata = (0, _src_core.cloneDriverMetadata)(metadata);
1571
+ try {
1572
+ await this.#serialize();
1573
+ } catch (error) {
1574
+ this.#metadata = previous;
1575
+ throw error;
1576
+ }
1577
+ }
1578
+ async #serialize(signal, memory = this.#memory, schema = this.#schema, metadata = this.#metadata) {
1579
+ const owned = metadata === void 0 ? void 0 : (0, _src_core.cloneDriverMetadata)(metadata);
1580
+ const tables = {};
1581
+ (0, _src_core.checkAbort)(signal);
1582
+ for (const table of schema) {
1583
+ const rows = [];
1584
+ for await (const row of memory.scan(table.name)) {
1585
+ (0, _src_core.checkAbort)(signal);
1586
+ rows.push(row);
1587
+ }
1189
1588
  tables[table.name] = rows;
1190
1589
  }
1590
+ (0, _src_core.checkAbort)(signal);
1191
1591
  this.#flushCount += 1;
1192
1592
  const temp = `${this.#path}.${process.pid}.${this.#flushCount}.tmp`;
1193
- const payload = this.#meta === void 0 ? { tables } : {
1194
- meta: this.#meta,
1593
+ const payload = owned === void 0 ? { tables } : {
1594
+ metadata: owned,
1195
1595
  tables
1196
1596
  };
1597
+ let dispatched = false;
1197
1598
  try {
1198
1599
  await (0, node_fs_promises.mkdir)((0, node_path.dirname)(this.#path), { recursive: true });
1199
- await (0, node_fs_promises.writeFile)(temp, JSON.stringify(payload, null, 2), "utf-8");
1600
+ const serialized = JSON.stringify(payload, null, 2);
1601
+ (0, _src_core.checkAbort)(signal);
1602
+ await (0, node_fs_promises.writeFile)(temp, serialized, {
1603
+ encoding: "utf-8",
1604
+ flush: true,
1605
+ signal
1606
+ });
1607
+ (0, _src_core.checkAbort)(signal);
1608
+ dispatched = true;
1200
1609
  await (0, node_fs_promises.rename)(temp, this.#path);
1201
1610
  } catch (error) {
1202
- await (0, node_fs_promises.rm)(temp, { force: true }).catch(() => {});
1611
+ let cause = error;
1612
+ if (!dispatched) try {
1613
+ (0, _src_core.checkAbort)(signal);
1614
+ } catch (abort) {
1615
+ cause = abort;
1616
+ }
1617
+ try {
1618
+ await (0, node_fs_promises.rm)(temp, { force: true });
1619
+ } catch (cleanup) {
1620
+ throw new _src_core.DatabaseError("DRIVER", "Failed to persist and clean the database file", {
1621
+ path: this.#path,
1622
+ temp,
1623
+ cause,
1624
+ cleanup
1625
+ });
1626
+ }
1627
+ if ((0, _src_core.isDatabaseError)(cause) && cause.code === "ABORTED") throw cause;
1203
1628
  throw new _src_core.DatabaseError("DRIVER", "Failed to persist the database file", {
1204
1629
  path: this.#path,
1205
- cause: error
1630
+ cause
1206
1631
  });
1207
1632
  }
1208
1633
  }
@@ -1219,21 +1644,23 @@ var JSONDriver = class {
1219
1644
  * raw `node:sqlite`. `open` issues `CREATE TABLE IF NOT EXISTS` with real typed
1220
1645
  * columns (mapped from each {@link TableSchema}'s portable column types) and a
1221
1646
  * `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**;
1647
+ * reopen-safe), and readies a reserved `_metadata` single-row table `metadata()` /
1648
+ * `stamp()` read and write — **a user table named `_metadata` collides with it**;
1224
1649
  * avoid the name. Rows cross the boundary through the codecs in `helpers.ts`
1225
1650
  * (`json` columns store / parse JSON text, a `boolean` stores `1` / `0`), so the
1226
1651
  * 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
1652
+ * `INSERT OR REPLACE` upsert, while `insert` uses a plain `INSERT` and maps its
1653
+ * atomic primary-key constraint failure to `CONFLICT`; every other backend
1654
+ * `SQLiteError` is contained by the same `DatabaseError` boundary described
1655
+ * below. Querying, ordering, paging, and
1656
+ * aggregation is native: `records` / `stream` compile a `QueryInput`
1657
+ * to SQL with `compileQuerySQL`, and `aggregate` runs a SQL
1658
+ * `COUNT`/`SUM`/`AVG`/`MIN`/`MAX` (via `compileAggregateSQL`) over the same compiled
1659
+ * WHERE. `transaction` runs a callback inside native `BEGIN` / `COMMIT` /
1660
+ * `ROLLBACK`, passing a scoped storage capability that becomes invalid after
1661
+ * settlement. `migrate` runs the plan's projected DDL
1662
+ * ({@link import('../compilers.js').stepToSQL}) inside whichever native
1663
+ * transaction is active: joined into the active transaction callback
1237
1664
  * when one exists (the core's versioned reconcile path wraps migrate + stamp
1238
1665
  * in one native `BEGIN`, and node:sqlite rejects a nested `BEGIN`), or inside
1239
1666
  * its own `database.transaction` otherwise — a mid-plan failure rolls back
@@ -1261,278 +1688,641 @@ var SQLiteDriver = class {
1261
1688
  #options;
1262
1689
  #database;
1263
1690
  #schema = /* @__PURE__ */ new Map();
1264
- #transacting = false;
1265
- constructor(path, options) {
1266
- this.#path = path;
1267
- this.#options = options ?? {};
1691
+ #identities = /* @__PURE__ */ new Map();
1692
+ #transaction;
1693
+ #candidateSchema;
1694
+ #candidateIdentities;
1695
+ constructor(options = {}) {
1696
+ this.#path = options.path ?? ":memory:";
1697
+ this.#options = options;
1268
1698
  }
1269
1699
  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 });
1700
+ this.#root();
1701
+ const owned = (0, _src_core.normalizeDriverSchema)(schema);
1702
+ if (owned.some((table) => table.name === "_metadata")) throw new _src_core.DatabaseError("VALIDATION", `A declared table cannot be named '${METADATA_TABLE}' — it is reserved for driver metadata`, { table: METADATA_TABLE });
1271
1703
  this.#guard(() => {
1272
- this.#database?.close();
1704
+ const current = this.#database;
1705
+ const previousIdentities = this.#identities;
1706
+ this.#database = void 0;
1707
+ this.#schema = /* @__PURE__ */ new Map();
1708
+ this.#identities = /* @__PURE__ */ new Map();
1709
+ current?.close();
1273
1710
  const database = (0, _orkestrel_sqlite.createSQLiteDatabase)({
1274
1711
  path: this.#path,
1275
- readonly: this.#options.readonly,
1276
- timeout: this.#options.timeout,
1277
- foreignKeys: this.#options.foreignKeys
1712
+ ...this.#options.readonly !== void 0 ? { readonly: this.#options.readonly } : {},
1713
+ ...this.#options.timeout !== void 0 ? { timeout: this.#options.timeout } : {},
1714
+ ...this.#options.references !== void 0 ? { foreignKeys: this.#options.references } : {}
1278
1715
  });
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);
1716
+ try {
1717
+ database.connect();
1718
+ for (const [name, value] of Object.entries(this.#options.pragmas ?? {})) database.pragma(name, value);
1719
+ const map = /* @__PURE__ */ new Map();
1720
+ const identities = /* @__PURE__ */ new Map();
1721
+ database.transaction(() => {
1722
+ this.#ensureMetadataTable(database);
1723
+ const stored = this.#readMetadata(database);
1724
+ const deployed = (0, _src_core.normalizeDriverSchema)(stored?.schema ?? owned);
1725
+ const missing = /* @__PURE__ */ new Map();
1726
+ for (const table of deployed) {
1727
+ map.set(table.name, table);
1728
+ missing.set(table.name, this.#validateTable(database, table));
1729
+ }
1730
+ if (stored !== void 0) {
1731
+ for (const table of stored.schema) if (missing.get(table.name) === void 0) throw new _src_core.DatabaseError("DRIVER", "Stored SQLite table is missing", {
1732
+ table: table.name,
1733
+ aspect: "missing"
1734
+ });
1735
+ }
1736
+ for (const table of deployed) {
1737
+ const absent = missing.get(table.name);
1738
+ if (absent === void 0) {
1739
+ database.exec(schemaToTable(table));
1740
+ for (const sql of schemaToIndexes(table)) database.exec(sql);
1741
+ } else for (const [index, group] of table.indexes.entries()) if (absent.includes(deriveSQLiteIndexName(table.name, group))) {
1742
+ const sql = schemaToIndexes(table)[index];
1743
+ if (sql !== void 0) database.exec(sql);
1744
+ }
1745
+ identities.set(table.name, previousIdentities.get(table.name) ?? {});
1746
+ }
1747
+ });
1748
+ this.#schema = map;
1749
+ this.#identities = identities;
1750
+ this.#database = database;
1751
+ } catch (error) {
1752
+ try {
1753
+ database.close();
1754
+ } catch {}
1755
+ this.#schema = /* @__PURE__ */ new Map();
1756
+ this.#identities = /* @__PURE__ */ new Map();
1757
+ throw error;
1286
1758
  }
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
1759
  });
1291
1760
  }
1292
1761
  async close() {
1293
- this.#database?.close();
1294
- this.#database = void 0;
1762
+ this.#root();
1763
+ this.#guard(() => {
1764
+ this.#database?.close();
1765
+ this.#database = void 0;
1766
+ });
1295
1767
  }
1296
1768
  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
- });
1769
+ this.#root();
1770
+ return this.#read(table, key);
1302
1771
  }
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
- });
1772
+ async write(table, key, row, options) {
1773
+ this.#root();
1774
+ await this.#write(table, key, row, options);
1314
1775
  }
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
- });
1776
+ async insert(table, key, row, options) {
1777
+ this.#root();
1778
+ await this.#insert(table, key, row, options);
1779
+ }
1780
+ async delete(table, key, options) {
1781
+ this.#root();
1782
+ return this.#delete(table, key, options);
1320
1783
  }
1321
1784
  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
- });
1785
+ this.#root();
1786
+ return this.#keys(table);
1333
1787
  }
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
- }
1788
+ scan(table) {
1789
+ return new DriverIterator(this.#scan(table)[Symbol.asyncIterator](), () => this.#root());
1342
1790
  }
1343
1791
  async clear(table) {
1344
- this.#table(table);
1345
- this.#guard(() => {
1346
- this.#require().prepare("DELETE FROM " + quote(table)).run();
1347
- });
1792
+ this.#root();
1793
+ await this.#clear(table);
1348
1794
  }
1349
- async records(table, criteria) {
1795
+ async records(table, input) {
1796
+ (0, _src_core.validatePage)(input);
1797
+ this.#root();
1350
1798
  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));
1799
+ if (matchesQueryExactly(input, schema)) return this.#guard(() => {
1800
+ const { sql, parameters } = compileQuerySQL(input, schema);
1801
+ return this.#require().prepare("SELECT * FROM " + quoteIdentifier(table) + (sql === "" ? "" : " " + sql)).all(parameters).map((row) => decodeRow(row, schema));
1354
1802
  });
1355
1803
  const rows = [];
1356
- for await (const row of this.scan(table)) rows.push(row);
1357
- return (0, _src_core.applyCriteria)(rows, criteria);
1804
+ for await (const row of this.#scan(table)) rows.push(row);
1805
+ return (0, _src_core.applyQuery)(rows, input);
1358
1806
  }
1359
- async count(table, criteria) {
1807
+ async aggregate(table, operation, column, input) {
1808
+ (0, _src_core.validatePage)(input);
1809
+ this.#root();
1360
1810
  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"));
1811
+ const conditions = input.conditions ?? [];
1812
+ const conditionsExact = conditions.every((condition) => matchesConditionExactly(condition, schema));
1813
+ const columnExact = matchesAggregateExactly(operation, column, schema);
1376
1814
  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;
1815
+ const { sql, parameters } = compileWhere(conditions, schema);
1816
+ const value = this.#require().prepare("SELECT " + compileAggregateSQL(operation, column) + " AS value FROM " + quoteIdentifier(table) + (sql === "" ? "" : " " + sql)).get(parameters)?.value;
1379
1817
  return value === null || value === void 0 ? void 0 : Number(value);
1380
1818
  });
1381
1819
  const rows = [];
1382
- for await (const row of this.scan(table)) rows.push(row);
1820
+ for await (const row of this.#scan(table)) rows.push(row);
1383
1821
  return (0, _src_core.computeAggregate)((0, _src_core.filterRows)(rows, conditions), operation, column);
1384
1822
  }
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
- }
1823
+ stream(table, input) {
1824
+ (0, _src_core.validatePage)(input);
1825
+ return new DriverIterator(this.#stream(table, input)[Symbol.asyncIterator](), () => this.#root());
1411
1826
  }
1412
1827
  /**
1413
1828
  * Begin a native transaction — real `BEGIN`, `COMMIT`, `ROLLBACK`.
1414
1829
  *
1415
1830
  * @remarks
1416
- * Calling `commit` or `rollback` a second time (on either method, in either
1417
- * order) throws `DatabaseError` `CONFLICT`.
1831
+ * The callback receives a scoped {@link StorageInterface}. Fulfillment
1832
+ * commits and returns its value; rejection rolls back and preserves the
1833
+ * original error. Root operations and nesting conflict while active, and a
1834
+ * captured capability conflicts after settlement.
1418
1835
  *
1419
- * @returns A {@link TransactionInterface} handle to `commit` or `rollback`
1836
+ * @returns The callback's resolved value
1420
1837
  */
1421
- async transaction() {
1838
+ async transaction(scope) {
1839
+ this.#root();
1422
1840
  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"));
1841
+ this.#guard(() => database.begin());
1842
+ const token = {};
1843
+ this.#transaction = token;
1844
+ this.#candidateSchema = new Map(this.#schema);
1845
+ this.#candidateIdentities = new Map(this.#identities);
1846
+ try {
1847
+ let value;
1848
+ try {
1849
+ value = await scope(this.#capability(token));
1850
+ } catch (error) {
1851
+ this.#guard(() => database.rollback());
1852
+ throw error;
1438
1853
  }
1439
- };
1854
+ try {
1855
+ this.#guard(() => database.commit());
1856
+ } catch (error) {
1857
+ if (database.transacting) this.#guard(() => database.rollback());
1858
+ throw error;
1859
+ }
1860
+ const schema = this.#candidateSchema;
1861
+ const identities = this.#candidateIdentities;
1862
+ if (schema === void 0 || identities === void 0) throw new _src_core.DatabaseError("CONFLICT", "Transaction scope has settled");
1863
+ this.#schema = schema;
1864
+ this.#identities = identities;
1865
+ return value;
1866
+ } finally {
1867
+ if (this.#transaction === token) {
1868
+ this.#transaction = void 0;
1869
+ this.#candidateSchema = void 0;
1870
+ this.#candidateIdentities = void 0;
1871
+ }
1872
+ }
1440
1873
  }
1441
1874
  /**
1442
1875
  * Apply a {@link Migration} plan by executing each step's projected DDL
1443
- * ({@link import('../helpers.js').stepToSQL}).
1876
+ * ({@link import('../compilers.js').stepToSQL}).
1444
1877
  *
1445
1878
  * @remarks
1446
1879
  * Atomicity is provided by whichever native transaction is active: when
1447
- * this driver's own `transaction()` hook already has a handle open (the
1880
+ * this driver's own `transaction()` callback is active (the
1448
1881
  * core's versioned reconcile / migrate path joins migrate + stamp under
1449
1882
  * 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
1883
+ * transaction — a mid-plan failure rejects the callback and the driver
1884
+ * rolls it back. node:sqlite (and SQLite
1452
1885
  * generally) rejects a nested `BEGIN`, so this driver must never open a
1453
1886
  * second native transaction while one is already open. Otherwise (no
1454
1887
  * enclosing transaction), `migrate` wraps the plan in its own native
1455
1888
  * `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).
1889
+ * back every DDL statement already applied by the plan. A scoped migration
1890
+ * uses one fixed internal savepoint literal because the published SQLite
1891
+ * wrapper intentionally exposes raw `exec` but no savepoint manager. That
1892
+ * savepoint contains a caught inner migration so the outer callback
1893
+ * transaction remains active and may continue safely. A step referencing a
1894
+ * table not in this driver's declared schema (and that is not itself a
1895
+ * `table.add`) throws `DatabaseError` `MIGRATION` before any DDL for that
1896
+ * step runs.
1461
1897
  *
1462
- * @param plan - The migration plan to apply
1898
+ * @param input - The migration plan and optional metadata stamp to apply atomically
1463
1899
  */
1464
- async migrate(plan) {
1900
+ async migrate(input) {
1901
+ this.#root();
1465
1902
  const database = this.#require();
1466
- const schema = new Map(this.#schema);
1903
+ const owned = (0, _src_core.cloneMigrationInput)(input);
1904
+ const projected = (0, _src_core.projectMigrationSchema)([...this.#schema.values()], owned.plan.steps);
1905
+ const identities = this.#projectIdentities(this.#identities, owned.plan.steps);
1906
+ if (owned.metadata !== void 0 && !(0, _src_core.equalsValue)((0, _src_core.normalizeDriverSchema)(owned.metadata.schema), projected)) throw new _src_core.DatabaseError("MIGRATION", "Migration metadata schema does not match the plan", {
1907
+ projected,
1908
+ metadata: owned.metadata.schema
1909
+ });
1467
1910
  this.#guard(() => {
1468
- if (this.#transacting) this.#applyPlan(database, plan, schema);
1469
- else database.transaction(() => this.#applyPlan(database, plan, schema));
1911
+ database.transaction(() => {
1912
+ this.#applyPlan(database, owned);
1913
+ if (owned.metadata !== void 0) this.#writeMetadata(database, owned.metadata);
1914
+ });
1470
1915
  });
1471
- this.#schema = schema;
1916
+ this.#schema = new Map(projected.map((table) => [table.name, table]));
1917
+ this.#identities = identities;
1472
1918
  }
1473
1919
  /**
1474
- * Read the persisted {@link DriverMeta} from the reserved `_meta` table.
1920
+ * Read the persisted {@link DriverMetadata} from the reserved `_metadata` table.
1475
1921
  *
1476
- * @returns The last-stamped `DriverMeta`, or `undefined` when never stamped
1922
+ * @returns The last-stamped `DriverMetadata`, or `undefined` when never stamped
1477
1923
  * (or the stored row is malformed)
1478
1924
  */
1479
- async meta() {
1480
- const row = this.#guard(() => this.#require().prepare("SELECT \"version\", \"schema\" FROM " + quote(META_TABLE) + " WHERE \"id\" = 1").get());
1925
+ async metadata() {
1926
+ this.#root();
1927
+ return this.#metadata();
1928
+ }
1929
+ /**
1930
+ * Persist an owned metadata snapshot into the reserved `_metadata` table's
1931
+ * single row.
1932
+ *
1933
+ * @param metadata - The {@link DriverMetadata} to persist
1934
+ */
1935
+ async stamp(metadata) {
1936
+ this.#root();
1937
+ await this.#stamp(metadata);
1938
+ }
1939
+ async snapshot(tables) {
1940
+ this.#root();
1941
+ const captured = this.#guard(() => {
1942
+ const database = this.#require();
1943
+ const names = tables === void 0 ? [...this.#schema.keys()] : [...new Set(tables)];
1944
+ const snapshots = /* @__PURE__ */ new Map();
1945
+ for (const name of names) {
1946
+ const schema = this.#schema.get(name);
1947
+ const identity = this.#identities.get(name);
1948
+ if (schema === void 0 || identity === void 0) continue;
1949
+ snapshots.set(name, {
1950
+ identity,
1951
+ rows: database.prepare("SELECT * FROM " + quoteIdentifier(name)).all().map((row) => decodeRow(row, schema)),
1952
+ schema
1953
+ });
1954
+ }
1955
+ return snapshots;
1956
+ });
1957
+ return async () => {
1958
+ this.#root();
1959
+ const replacements = /* @__PURE__ */ new Map();
1960
+ for (const [name, capture] of captured) {
1961
+ const schema = this.#schema.get(name);
1962
+ if (schema === void 0 || this.#identities.get(name) !== capture.identity) continue;
1963
+ const plan = (0, _src_core.planMigration)([capture.schema], [schema]);
1964
+ const rows = (0, _src_core.migrateRows)(capture.rows, plan.steps);
1965
+ if (rows.length !== capture.rows.length) throw new _src_core.DatabaseError("MIGRATION", "Snapshot row count changed during migration", { table: name });
1966
+ const names = schema.columns.map((column) => column.name);
1967
+ const values = [];
1968
+ for (const [index, row] of rows.entries()) {
1969
+ const key = (0, _src_core.extractKey)(row, schema.primary);
1970
+ if (!(0, _src_core.isKey)(key)) throw new _src_core.DatabaseError("MIGRATION", "Snapshot row has no usable primary key", {
1971
+ table: name,
1972
+ column: schema.primary,
1973
+ index
1974
+ });
1975
+ const encoded = encodeRow((0, _src_core.bindRowKey)(row, schema.primary, key), schema);
1976
+ values.push(extractValues(encoded, names, name));
1977
+ }
1978
+ replacements.set(name, {
1979
+ names,
1980
+ values
1981
+ });
1982
+ }
1983
+ this.#guard(() => {
1984
+ const current = this.#require();
1985
+ current.transaction(() => {
1986
+ for (const [name, replacement] of replacements) {
1987
+ current.exec("DELETE FROM " + quoteIdentifier(name));
1988
+ const statement = current.prepare("INSERT OR REPLACE INTO " + quoteIdentifier(name) + " (" + replacement.names.map(quoteIdentifier).join(", ") + ") VALUES (" + replacement.names.map(() => "?").join(", ") + ")");
1989
+ for (const values of replacement.values) statement.run(values);
1990
+ }
1991
+ });
1992
+ });
1993
+ };
1994
+ }
1995
+ async #read(table, key) {
1996
+ const schema = this.#table(table);
1997
+ return this.#guard(() => {
1998
+ const row = this.#require().prepare("SELECT * FROM " + quoteIdentifier(table) + " WHERE " + quoteIdentifier(schema.primary) + " = ?").get([this.#key(key, schema)]);
1999
+ return row === void 0 ? void 0 : decodeRow(row, schema);
2000
+ });
2001
+ }
2002
+ async #write(table, key, row, options) {
2003
+ const schema = this.#table(table);
2004
+ this.#guard(() => {
2005
+ const encoded = encodeRow((0, _src_core.bindRowKey)(row, schema.primary, key), schema);
2006
+ const names = schema.columns.map((column) => column.name);
2007
+ const values = extractValues(encoded, names, table);
2008
+ const statement = this.#require().prepare("INSERT OR REPLACE INTO " + quoteIdentifier(table) + " (" + names.map(quoteIdentifier).join(", ") + ") VALUES (" + names.map(() => "?").join(", ") + ")");
2009
+ (0, _src_core.checkAbort)(options?.signal);
2010
+ statement.run(values);
2011
+ });
2012
+ }
2013
+ async #insert(table, key, row, options) {
2014
+ const schema = this.#table(table);
2015
+ this.#guard(() => {
2016
+ const encoded = encodeRow((0, _src_core.bindRowKey)(row, schema.primary, key), schema);
2017
+ const names = schema.columns.map((column) => column.name);
2018
+ const values = extractValues(encoded, names, table);
2019
+ const statement = this.#require().prepare("INSERT INTO " + quoteIdentifier(table) + " (" + names.map(quoteIdentifier).join(", ") + ") VALUES (" + names.map(() => "?").join(", ") + ")");
2020
+ (0, _src_core.checkAbort)(options?.signal);
2021
+ statement.run(values);
2022
+ });
2023
+ }
2024
+ async #delete(table, key, options) {
2025
+ const schema = this.#table(table);
2026
+ return this.#guard(() => {
2027
+ const statement = this.#require().prepare("DELETE FROM " + quoteIdentifier(table) + " WHERE " + quoteIdentifier(schema.primary) + " = ?");
2028
+ (0, _src_core.checkAbort)(options?.signal);
2029
+ return statement.run([this.#key(key, schema)]).changes > 0;
2030
+ });
2031
+ }
2032
+ async #keys(table) {
2033
+ const schema = this.#table(table);
2034
+ return this.#guard(() => {
2035
+ const primary = quoteIdentifier(schema.primary);
2036
+ const rows = this.#require().prepare("SELECT " + primary + " FROM " + quoteIdentifier(table) + " ORDER BY " + primary).all();
2037
+ const keys = [];
2038
+ for (const row of rows) {
2039
+ const value = row[schema.primary];
2040
+ if (typeof value === "string" || typeof value === "number") keys.push(value);
2041
+ }
2042
+ return keys;
2043
+ });
2044
+ }
2045
+ async *#scan(table) {
2046
+ const schema = this.#table(table);
2047
+ for await (const row of this.#iterate(schema, "SELECT * FROM " + quoteIdentifier(table) + " ORDER BY " + quoteIdentifier(schema.primary))) yield row;
2048
+ }
2049
+ async *#stream(table, input) {
2050
+ const schema = this.#table(table);
2051
+ const conditions = input.conditions ?? [];
2052
+ if (conditions.every((condition) => matchesConditionExactly(condition, schema))) {
2053
+ const compiled = compileQuerySQL({
2054
+ conditions,
2055
+ ...input.limit !== void 0 ? { limit: input.limit } : {},
2056
+ ...input.offset !== void 0 ? { offset: input.offset } : {}
2057
+ }, schema);
2058
+ for await (const row of this.#iterate(schema, "SELECT * FROM " + quoteIdentifier(table) + (compiled.sql === "" ? "" : " " + compiled.sql), compiled.parameters)) yield row;
2059
+ return;
2060
+ }
2061
+ const offset = input.offset ?? 0;
2062
+ const limit = input.limit;
2063
+ let skipped = 0;
2064
+ let yielded = 0;
2065
+ for await (const row of this.#scan(table)) {
2066
+ if (limit !== void 0 && yielded >= limit) return;
2067
+ if (conditions.length > 0 && !(0, _src_core.matchesQuery)(row, conditions)) continue;
2068
+ if (skipped < offset) {
2069
+ skipped += 1;
2070
+ continue;
2071
+ }
2072
+ yield row;
2073
+ yielded += 1;
2074
+ }
2075
+ }
2076
+ async *#iterate(schema, sql, parameters = []) {
2077
+ const iterator = this.#guard(() => this.#require().prepare(sql).iterate(parameters)[Symbol.iterator]());
2078
+ try {
2079
+ while (true) {
2080
+ const step = this.#guard(() => iterator.next());
2081
+ if (step.done === true) return;
2082
+ yield this.#guard(() => decodeRow(step.value, schema));
2083
+ }
2084
+ } finally {
2085
+ if (iterator.return !== void 0) this.#guard(() => iterator.return?.());
2086
+ }
2087
+ }
2088
+ async #clear(table) {
2089
+ this.#table(table);
2090
+ this.#guard(() => {
2091
+ this.#require().prepare("DELETE FROM " + quoteIdentifier(table)).run();
2092
+ });
2093
+ }
2094
+ async #metadata() {
2095
+ return this.#guard(() => this.#readMetadata(this.#require()));
2096
+ }
2097
+ #ensureMetadataTable(database) {
2098
+ database.exec("CREATE TABLE IF NOT EXISTS " + quoteIdentifier(METADATA_TABLE) + " (\"id\" INTEGER, \"version\" INTEGER, \"schema\" TEXT, PRIMARY KEY (\"id\"))");
2099
+ }
2100
+ #validateTable(database, schema) {
2101
+ const object = database.prepare("SELECT \"type\" AS \"category\" FROM \"sqlite_schema\" WHERE \"name\" = ?").get([schema.name]);
2102
+ if (object === void 0) return void 0;
2103
+ if (object.category !== "table") throw new _src_core.DatabaseError("DRIVER", "SQLite object is not a table", {
2104
+ table: schema.name,
2105
+ aspect: "object",
2106
+ actual: object.category
2107
+ });
2108
+ const trigger = database.prepare("SELECT \"name\" FROM \"sqlite_schema\" WHERE \"type\" = ? AND \"tbl_name\" = ? LIMIT 1").get(["trigger", schema.name]);
2109
+ if (trigger !== void 0) throw new _src_core.DatabaseError("DRIVER", "SQLite table has an undeclared trigger", {
2110
+ table: schema.name,
2111
+ aspect: "trigger",
2112
+ actual: trigger.name
2113
+ });
2114
+ const columns = database.prepare("SELECT * FROM pragma_table_xinfo(?)").all([schema.name]);
2115
+ if (columns.length !== schema.columns.length) throw new _src_core.DatabaseError("DRIVER", "SQLite table has different columns", {
2116
+ table: schema.name,
2117
+ aspect: "columns",
2118
+ expected: schema.columns.map((column) => column.name),
2119
+ actual: columns.map((column) => column.name)
2120
+ });
2121
+ for (const declared of schema.columns) {
2122
+ const column = columns.find((candidate) => candidate.name === declared.name);
2123
+ if (column === void 0) throw new _src_core.DatabaseError("DRIVER", "SQLite table is missing a declared column", {
2124
+ table: schema.name,
2125
+ aspect: "column",
2126
+ column: declared.name
2127
+ });
2128
+ const expectedRequired = !declared.optional && !declared.nullable;
2129
+ const required = column.notnull === 1 || column.notnull === 1n;
2130
+ const expectedPrimary = declared.name === schema.primary ? 1 : 0;
2131
+ const primary = column.pk === expectedPrimary || column.pk === BigInt(expectedPrimary);
2132
+ const hidden = column.hidden === 0 || column.hidden === 0n;
2133
+ if (!matchesSQLiteAffinity(column.type, declared.storage) || required !== expectedRequired || !primary || !hidden) throw new _src_core.DatabaseError("DRIVER", "SQLite column does not match its declaration", {
2134
+ table: schema.name,
2135
+ aspect: "column",
2136
+ column: declared.name,
2137
+ expected: declared,
2138
+ actual: column
2139
+ });
2140
+ }
2141
+ const indexes = database.prepare("SELECT * FROM pragma_index_list(?)").all([schema.name]);
2142
+ for (const index of indexes) if ((index.unique === 1 || index.unique === 1n) && index.origin !== "pk") throw new _src_core.DatabaseError("DRIVER", "SQLite table has an undeclared unique constraint", {
2143
+ table: schema.name,
2144
+ aspect: "index",
2145
+ actual: index.name
2146
+ });
2147
+ const missing = [];
2148
+ for (const group of schema.indexes) {
2149
+ const name = deriveSQLiteIndexName(schema.name, group);
2150
+ const index = indexes.find((candidate) => candidate.name === name);
2151
+ if (index === void 0) {
2152
+ missing.push(name);
2153
+ continue;
2154
+ }
2155
+ const ordinary = index.unique === 0 || index.unique === 0n;
2156
+ const complete = index.partial === 0 || index.partial === 0n;
2157
+ if (!ordinary || !complete || index.origin !== "c") throw new _src_core.DatabaseError("DRIVER", "SQLite index does not match its declaration", {
2158
+ table: schema.name,
2159
+ aspect: "index",
2160
+ index: name,
2161
+ actual: index
2162
+ });
2163
+ const entries = database.prepare("SELECT * FROM pragma_index_xinfo(?)").all([name]).filter((entry) => entry.key === 1 || entry.key === 1n);
2164
+ if (entries.length !== group.length) throw new _src_core.DatabaseError("DRIVER", "SQLite index has different columns", {
2165
+ table: schema.name,
2166
+ aspect: "index",
2167
+ index: name,
2168
+ expected: group,
2169
+ actual: entries.map((entry) => entry.name)
2170
+ });
2171
+ for (const [position, column] of group.entries()) {
2172
+ const entry = entries.find((candidate) => candidate.seqno === position || candidate.seqno === BigInt(position));
2173
+ const stored = entry !== void 0 && (typeof entry.cid === "number" && Number.isInteger(entry.cid) && entry.cid >= 0 || typeof entry.cid === "bigint" && entry.cid >= 0n);
2174
+ if (entry === void 0 || entry.name !== column || !stored || entry.desc !== 0 && entry.desc !== 0n || entry.coll !== "BINARY") throw new _src_core.DatabaseError("DRIVER", "SQLite index column does not match its declaration", {
2175
+ table: schema.name,
2176
+ aspect: "index",
2177
+ index: name,
2178
+ column,
2179
+ actual: entry
2180
+ });
2181
+ }
2182
+ }
2183
+ return missing;
2184
+ }
2185
+ #readMetadata(database) {
2186
+ const row = database.prepare("SELECT \"version\", \"schema\" FROM " + quoteIdentifier(METADATA_TABLE) + " WHERE \"id\" = 1").get();
1481
2187
  if (row === void 0) return void 0;
1482
2188
  const version = row.version;
1483
2189
  const text = row.schema;
1484
- if (typeof text !== "string") return void 0;
1485
- if (typeof version !== "number" && typeof version !== "bigint") return void 0;
2190
+ if (typeof text !== "string") throw new _src_core.DatabaseError("DRIVER", "Stored SQLite metadata schema is invalid", {
2191
+ table: METADATA_TABLE,
2192
+ aspect: "metadata"
2193
+ });
2194
+ if (typeof version !== "number" && typeof version !== "bigint") throw new _src_core.DatabaseError("DRIVER", "Stored SQLite metadata version is invalid", {
2195
+ table: METADATA_TABLE,
2196
+ aspect: "metadata"
2197
+ });
1486
2198
  let parsed;
1487
2199
  try {
1488
2200
  parsed = JSON.parse(text);
1489
- } catch {
1490
- return;
2201
+ } catch (error) {
2202
+ throw new _src_core.DatabaseError("DRIVER", "Stored SQLite metadata JSON is invalid", {
2203
+ table: METADATA_TABLE,
2204
+ aspect: "metadata",
2205
+ cause: error
2206
+ });
1491
2207
  }
2208
+ if (!Array.isArray(parsed)) throw new _src_core.DatabaseError("DRIVER", "Stored SQLite metadata schema is invalid", {
2209
+ table: METADATA_TABLE,
2210
+ aspect: "metadata"
2211
+ });
1492
2212
  const candidate = {
1493
2213
  version: Number(version),
1494
2214
  schema: parsed
1495
2215
  };
1496
- if (!(0, _src_core.isDriverMeta)(candidate)) return void 0;
1497
- return candidate;
2216
+ try {
2217
+ return (0, _src_core.cloneDriverMetadata)(candidate);
2218
+ } catch (error) {
2219
+ if ((0, _src_core.isDatabaseError)(error) && error.code === "VALIDATION") throw new _src_core.DatabaseError("DRIVER", "Stored SQLite metadata is invalid", {
2220
+ table: METADATA_TABLE,
2221
+ aspect: "metadata",
2222
+ cause: error
2223
+ });
2224
+ throw error;
2225
+ }
1498
2226
  }
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)]);
2227
+ async #stamp(metadata) {
2228
+ const database = this.#require();
2229
+ const owned = (0, _src_core.cloneDriverMetadata)(metadata);
2230
+ this.#guard(() => this.#writeMetadata(database, owned));
2231
+ }
2232
+ #writeMetadata(database, metadata) {
2233
+ database.prepare("INSERT OR REPLACE INTO " + quoteIdentifier(METADATA_TABLE) + " (\"id\", \"version\", \"schema\") VALUES (1, ?, ?)").run([metadata.version, JSON.stringify(metadata.schema)]);
2234
+ }
2235
+ #capability(token) {
2236
+ return {
2237
+ read: this.#readTransaction.bind(this, token),
2238
+ write: this.#writeTransaction.bind(this, token),
2239
+ insert: this.#insertTransaction.bind(this, token),
2240
+ delete: this.#deleteTransaction.bind(this, token),
2241
+ keys: this.#keysTransaction.bind(this, token),
2242
+ scan: this.#scanTransaction.bind(this, token),
2243
+ clear: this.#clearTransaction.bind(this, token),
2244
+ migrate: this.#migrateTransaction.bind(this, token),
2245
+ metadata: this.#metadataTransaction.bind(this, token),
2246
+ stamp: this.#stampTransaction.bind(this, token)
2247
+ };
2248
+ }
2249
+ async #readTransaction(token, table, key) {
2250
+ this.#requireTransaction(token);
2251
+ return this.#read(table, key);
2252
+ }
2253
+ async #writeTransaction(token, table, key, row, options) {
2254
+ this.#requireTransaction(token);
2255
+ await this.#write(table, key, row, options);
2256
+ }
2257
+ async #insertTransaction(token, table, key, row, options) {
2258
+ this.#requireTransaction(token);
2259
+ await this.#insert(table, key, row, options);
2260
+ }
2261
+ async #deleteTransaction(token, table, key, options) {
2262
+ this.#requireTransaction(token);
2263
+ return this.#delete(table, key, options);
2264
+ }
2265
+ async #keysTransaction(token, table) {
2266
+ this.#requireTransaction(token);
2267
+ return this.#keys(table);
2268
+ }
2269
+ #scanTransaction(token, table) {
2270
+ return new DriverIterator(this.#scan(table)[Symbol.asyncIterator](), () => {
2271
+ this.#requireTransaction(token);
1508
2272
  });
1509
2273
  }
1510
- async snapshot(tables) {
2274
+ async #clearTransaction(token, table) {
2275
+ this.#requireTransaction(token);
2276
+ await this.#clear(table);
2277
+ }
2278
+ async #migrateTransaction(token, input) {
2279
+ this.#requireTransaction(token);
1511
2280
  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]));
2281
+ const owned = (0, _src_core.cloneMigrationInput)(input);
2282
+ const candidate = this.#candidateSchema;
2283
+ const identities = this.#candidateIdentities;
2284
+ if (candidate === void 0 || identities === void 0) throw new _src_core.DatabaseError("CONFLICT", "Transaction scope has settled");
2285
+ const projected = (0, _src_core.projectMigrationSchema)([...candidate.values()], owned.plan.steps);
2286
+ const projectedIdentities = this.#projectIdentities(identities, owned.plan.steps);
2287
+ if (owned.metadata !== void 0 && !(0, _src_core.equalsValue)((0, _src_core.normalizeDriverSchema)(owned.metadata.schema), projected)) throw new _src_core.DatabaseError("MIGRATION", "Migration metadata schema does not match the plan", {
2288
+ projected,
2289
+ metadata: owned.metadata.schema
2290
+ });
2291
+ this.#guard(() => {
2292
+ database.exec("SAVEPOINT \"_orkestrel_migration\"");
2293
+ try {
2294
+ this.#applyPlan(database, owned);
2295
+ if (owned.metadata !== void 0) this.#writeMetadata(database, owned.metadata);
2296
+ database.exec("RELEASE SAVEPOINT \"_orkestrel_migration\"");
2297
+ } catch (error) {
2298
+ try {
2299
+ database.exec("ROLLBACK TO SAVEPOINT \"_orkestrel_migration\"");
2300
+ } finally {
2301
+ database.exec("RELEASE SAVEPOINT \"_orkestrel_migration\"");
1529
2302
  }
1530
- });
1531
- };
2303
+ throw error;
2304
+ }
2305
+ });
2306
+ this.#candidateSchema = new Map(projected.map((table) => [table.name, table]));
2307
+ this.#candidateIdentities = projectedIdentities;
2308
+ }
2309
+ async #metadataTransaction(token) {
2310
+ this.#requireTransaction(token);
2311
+ return this.#metadata();
1532
2312
  }
1533
- #guard(run) {
2313
+ async #stampTransaction(token, metadata) {
2314
+ this.#requireTransaction(token);
2315
+ await this.#stamp(metadata);
2316
+ }
2317
+ #requireTransaction(token) {
2318
+ if (this.#transaction !== token) throw new _src_core.DatabaseError("CONFLICT", "Transaction scope has settled");
2319
+ }
2320
+ #root() {
2321
+ if (this.#transaction !== void 0) throw new _src_core.DatabaseError("CONFLICT", "A transaction is active on this driver");
2322
+ }
2323
+ #guard(operation) {
1534
2324
  try {
1535
- return run();
2325
+ return operation();
1536
2326
  } catch (error) {
1537
2327
  if (error instanceof _src_core.DatabaseError) throw error;
1538
2328
  if ((0, _orkestrel_sqlite.isSQLiteError)(error)) {
@@ -1561,28 +2351,30 @@ var SQLiteDriver = class {
1561
2351
  if (this.#database === void 0) throw new _src_core.DatabaseError("CLOSED", `SQLite database '${this.#path}' is not open`, { path: this.#path });
1562
2352
  return this.#database;
1563
2353
  }
2354
+ #projectIdentities(identities, steps) {
2355
+ const projected = new Map(identities);
2356
+ for (const step of steps) {
2357
+ if (step.operation === "table.add") projected.set(step.table.name, {});
2358
+ if (step.operation === "table.remove") projected.delete(step.table);
2359
+ }
2360
+ return projected;
2361
+ }
1564
2362
  #table(name) {
1565
2363
  this.#require();
1566
- const schema = this.#schema.get(name);
2364
+ const schema = (this.#candidateSchema ?? this.#schema).get(name);
1567
2365
  if (schema === void 0) throw new _src_core.DatabaseError("NOT_FOUND", `Table '${name}' is not in the schema`, { table: name });
1568
2366
  return schema;
1569
2367
  }
1570
2368
  #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
- }
2369
+ return encodeValue(key, schema.columns.find((column) => column.name === schema.primary) ?? {
2370
+ name: schema.primary,
2371
+ storage: "text",
2372
+ optional: false,
2373
+ nullable: false
2374
+ });
2375
+ }
2376
+ #applyPlan(database, input) {
2377
+ for (const step of input.plan.steps) for (const sql of stepToSQL(step)) database.exec(sql);
1586
2378
  }
1587
2379
  };
1588
2380
  //#endregion
@@ -1591,9 +2383,9 @@ var SQLiteDriver = class {
1591
2383
  * Create a persistent JSON-file {@link DriverInterface} for the core database layer.
1592
2384
  *
1593
2385
  * @remarks
1594
- * Pass it to `createDatabase` from `@orkestrel/database` to run the whole typed database +
1595
- * relations stack against a single JSON file instead of memory — the `Database` /
1596
- * `Table` / `Query` / relations API is unchanged; only where the bytes live changes.
2386
+ * Pass it to `createDatabase` from `@orkestrel/database` to run the typed
2387
+ * database against a single JSON file instead of memory — the `Database` /
2388
+ * `Table` / `Query` API is unchanged; only where the bytes live changes.
1597
2389
  * The driver is the reference `MemoryDriver` plus JSON-file persistence: `open` loads
1598
2390
  * the file, every mutation flushes the whole store back, and querying runs through
1599
2391
  * the core engine over `scan` (it is scan-only — no native `records` / `count` /
@@ -1623,19 +2415,20 @@ function createJSONDriver(path) {
1623
2415
  * Create a trusted-mode SQLite {@link DriverInterface} for the core database layer.
1624
2416
  *
1625
2417
  * @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
2418
+ * Pass it to `createDatabase` from `@orkestrel/database` to run the typed
2419
+ * database against a real SQLite database — the `Database` / `Table` /
2420
+ * `Query` API is unchanged; only where the bytes live changes. Built
1629
2421
  * on the published `@orkestrel/sqlite` synchronous wrapper: `open` issues real
1630
2422
  * typed `CREATE TABLE` / `CREATE INDEX` statements (reopen-safe) plus a reserved
1631
- * `_meta` table for `meta()` / `stamp()` — avoid naming a table `_meta`.
2423
+ * `_metadata` table for `metadata()` / `stamp()` — avoid naming a table `_metadata`.
1632
2424
  * Querying, paging, and aggregation run natively (`records` / `count` /
1633
2425
  * `aggregate` / `stream`); `transaction` and `migrate` use real `BEGIN` /
1634
2426
  * `COMMIT` / `ROLLBACK`, so `migrate` is atomic even mid-plan.
1635
2427
  *
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`)
2428
+ * @param options - The {@link SQLiteDriverOptions} bag (`path`, `readonly`,
2429
+ * `timeout`, `references`, `pragmas`); `references` directly enables or
2430
+ * disables foreign-key enforcement, and omission retains the upstream
2431
+ * default; omit the whole bag for an in-memory database
1639
2432
  * @returns A {@link DriverInterface} backed by SQLite
1640
2433
  *
1641
2434
  * @example
@@ -1645,53 +2438,53 @@ function createJSONDriver(path) {
1645
2438
  * import { createSQLiteDriver } from '@orkestrel/database/server'
1646
2439
  *
1647
2440
  * const db = createDatabase({
1648
- * driver: createSQLiteDriver('data/app.sqlite'),
2441
+ * driver: createSQLiteDriver({ path: 'data/app.sqlite' }),
1649
2442
  * tables: { users: { id: stringShape(), name: stringShape() } },
1650
2443
  * })
1651
2444
  * await db.table('users').set({ id: 'u1', name: 'Ada' }) // persisted to app.sqlite
1652
2445
  *
1653
- * // Or with options:
2446
+ * // Or with additional options:
1654
2447
  * createSQLiteDriver({ path: 'data/app.sqlite', pragmas: { journal_mode: 'WAL' } })
1655
2448
  * ```
1656
2449
  */
1657
- function createSQLiteDriver(options = ":memory:") {
1658
- const resolved = (0, _orkestrel_contract.isString)(options) ? { path: options } : options;
1659
- return new SQLiteDriver(resolved.path ?? ":memory:", resolved);
2450
+ function createSQLiteDriver(options) {
2451
+ return new SQLiteDriver(options);
1660
2452
  }
1661
2453
  //#endregion
1662
- exports.EXACT_COLUMN_TYPES = EXACT_COLUMN_TYPES;
1663
- exports.EXACT_RANGE_COLUMN_TYPES = EXACT_RANGE_COLUMN_TYPES;
2454
+ exports.EXACT_COLUMN_STORAGE = EXACT_COLUMN_STORAGE;
2455
+ exports.EXACT_RANGE_COLUMN_STORAGE = EXACT_RANGE_COLUMN_STORAGE;
1664
2456
  exports.JSONDriver = JSONDriver;
1665
- exports.META_TABLE = META_TABLE;
2457
+ exports.METADATA_TABLE = METADATA_TABLE;
1666
2458
  exports.SQLiteDriver = SQLiteDriver;
1667
- exports.aggregateSQL = aggregateSQL;
1668
- exports.columnSQL = columnSQL;
1669
- exports.compileCriteria = compileCriteria;
2459
+ exports.compileAggregateSQL = compileAggregateSQL;
2460
+ exports.compileColumnSQL = compileColumnSQL;
2461
+ exports.compileConditionSQL = compileConditionSQL;
2462
+ exports.compileFieldSQL = compileFieldSQL;
2463
+ exports.compileJSONTypeSQL = compileJSONTypeSQL;
1670
2464
  exports.compileOrder = compileOrder;
1671
2465
  exports.compilePage = compilePage;
2466
+ exports.compileQuerySQL = compileQuerySQL;
1672
2467
  exports.compileWhere = compileWhere;
1673
2468
  exports.createJSONDriver = createJSONDriver;
1674
2469
  exports.createSQLiteDriver = createSQLiteDriver;
1675
- exports.declaredType = declaredType;
1676
2470
  exports.decodeRow = decodeRow;
1677
2471
  exports.decodeValue = decodeValue;
2472
+ exports.deriveSQLiteIndexName = deriveSQLiteIndexName;
1678
2473
  exports.encodeRow = encodeRow;
1679
2474
  exports.encodeValue = encodeValue;
1680
2475
  exports.escapeLike = escapeLike;
1681
- exports.fieldColumn = fieldColumn;
1682
- exports.fragment = fragment;
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;
1690
- exports.quote = quote;
2476
+ exports.extractValues = extractValues;
2477
+ exports.findColumnStorage = findColumnStorage;
2478
+ exports.inferValueStorage = inferValueStorage;
2479
+ exports.matchesAggregateExactly = matchesAggregateExactly;
2480
+ exports.matchesConditionExactly = matchesConditionExactly;
2481
+ exports.matchesDeclaredStorage = matchesDeclaredStorage;
2482
+ exports.matchesOrderExactly = matchesOrderExactly;
2483
+ exports.matchesQueryExactly = matchesQueryExactly;
2484
+ exports.matchesSQLiteAffinity = matchesSQLiteAffinity;
2485
+ exports.quoteIdentifier = quoteIdentifier;
1691
2486
  exports.schemaToIndexes = schemaToIndexes;
1692
2487
  exports.schemaToTable = schemaToTable;
1693
2488
  exports.stepToSQL = stepToSQL;
1694
- exports.stepToSchema = stepToSchema;
1695
- exports.valueType = valueType;
1696
2489
 
1697
2490
  //# sourceMappingURL=index.cjs.map