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