@orkestrel/database 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1014 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_crypto = require("node:crypto");
3
+ let _src_core = require("../core/index.js");
4
+ let node_fs_promises = require("node:fs/promises");
5
+ let node_path = require("node:path");
6
+ Object.freeze([
7
+ "null",
8
+ "boolean",
9
+ "object",
10
+ "array",
11
+ "number",
12
+ "integer",
13
+ "string"
14
+ ]);
15
+ /** Determine whether a value is a string. */
16
+ function isString(value) {
17
+ return typeof value === "string";
18
+ }
19
+ /** Determine whether a value is a boolean. */
20
+ function isBoolean(value) {
21
+ return typeof value === "boolean";
22
+ }
23
+ /**
24
+ * Determine whether a value is a non-null object.
25
+ *
26
+ * @remarks
27
+ * `true` for arrays, class instances, plain objects, `Map`, `Set`, etc. — use
28
+ * {@link isRecord} when you need a plain-record check.
29
+ */
30
+ function isObject(value) {
31
+ return typeof value === "object" && value !== null;
32
+ }
33
+ /**
34
+ * Determine whether a value is a plain record (object literal or null-prototype),
35
+ * not an array or class instance.
36
+ *
37
+ * @remarks
38
+ * Use instead of {@link isObject} to distinguish a plain `{}` /
39
+ * `Object.create(null)` from arrays, `Date`, `Map`, etc. The prototype-chain
40
+ * test is realm-agnostic: rather than comparing against the current realm's
41
+ * `Object.prototype` (which a plain object from another `vm.Context`, iframe,
42
+ * or worker would fail), it accepts any value whose prototype is `null`, OR
43
+ * whose prototype's own prototype is `null` — the shape every plain object
44
+ * has in every realm, since `Object.prototype` itself always sits one step
45
+ * above `null`. Arrays and class instances are still rejected: an array's
46
+ * prototype chain runs through `Array.prototype` before `null`, and a class
47
+ * instance's runs through the class's own prototype. The whole body runs
48
+ * inside `attempt` (AGENTS §14) so a revoked `Proxy` or a hostile
49
+ * `getPrototypeOf` trap cannot escape as a thrown error.
50
+ */
51
+ function isRecord(value) {
52
+ const outcome = attempt(() => {
53
+ if (!isObject(value) || isArray(value)) return false;
54
+ const prototype = Object.getPrototypeOf(value);
55
+ return prototype === null || Object.getPrototypeOf(prototype) === null;
56
+ });
57
+ return outcome.success && outcome.value;
58
+ }
59
+ /** Determine whether a value is an array. */
60
+ function isArray(value) {
61
+ return Array.isArray(value);
62
+ }
63
+ /**
64
+ * Invoke a callback and capture its outcome as a {@link Result}, never letting
65
+ * a throw escape.
66
+ *
67
+ * @remarks
68
+ * The single sanctioned never-throw boundary for the guards (AGENTS §14). The
69
+ * `whereOf`, `lazyOf`, and `transformOf` combinators invoke caller-supplied
70
+ * callbacks *inside* a guard body, yet a guard must NEVER throw — it returns a
71
+ * `boolean`. This converts a throwing callback into a `Failure` so the
72
+ * surrounding guard can treat it as a non-match instead of propagating the
73
+ * exception, written once and shared rather than copy-pasted as ad-hoc
74
+ * `try`/`catch`.
75
+ *
76
+ * @param callback - The callback to invoke with no arguments
77
+ * @returns A `Success` carrying the return value, or a `Failure` carrying the
78
+ * thrown reason normalised to an `Error`
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * const outcome = attempt(() => predicate(value))
83
+ * return outcome.success && outcome.value
84
+ * ```
85
+ */
86
+ function attempt(callback) {
87
+ try {
88
+ return {
89
+ success: true,
90
+ value: callback()
91
+ };
92
+ } catch (reason) {
93
+ if (reason instanceof Error) return {
94
+ success: false,
95
+ error: reason
96
+ };
97
+ let message = "Unknown thrown value";
98
+ try {
99
+ message = String(reason);
100
+ } catch {}
101
+ return {
102
+ success: false,
103
+ error: new Error(message)
104
+ };
105
+ }
106
+ }
107
+ //#endregion
108
+ //#region src/server/helpers.ts
109
+ /**
110
+ * Generate a fresh unique key — a v4 UUID string, backed by `node:crypto`.
111
+ *
112
+ * @remarks
113
+ * Supply this as {@link import('@src/core').DatabaseOptions.key} so a table mints
114
+ * a key when a written row lacks its primary-key value. Strings work as keys on
115
+ * every backend; supply your own key values directly to use numeric keys instead.
116
+ *
117
+ * @returns A new UUID string
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * const db = createDatabase({ driver, tables, key: generateKey })
122
+ * ```
123
+ */
124
+ function generateKey() {
125
+ return (0, node_crypto.randomUUID)();
126
+ }
127
+ /**
128
+ * Map a portable {@link ColumnType} to its SQLite column type.
129
+ *
130
+ * @remarks
131
+ * `text` / `json` → `TEXT` (JSON is stored as text and read back with
132
+ * `json_extract` for nested-field queries); `integer` / `boolean` → `INTEGER`
133
+ * (a boolean stores `1` / `0`); `real` → `REAL`; `blob` → `BLOB`. No `NOT NULL`
134
+ * is ever emitted — the contract validates required-ness; the database is just
135
+ * storage (AGENTS §14, the typed layer above imposes the shape).
136
+ *
137
+ * @param type - The portable column type
138
+ * @returns The SQLite column type keyword
139
+ *
140
+ * @example
141
+ * ```ts
142
+ * columnSQL('integer') // 'INTEGER'
143
+ * columnSQL('json') // 'TEXT'
144
+ * ```
145
+ */
146
+ function columnSQL(type) {
147
+ switch (type) {
148
+ case "text":
149
+ case "json": return "TEXT";
150
+ case "integer":
151
+ case "boolean": return "INTEGER";
152
+ case "real": return "REAL";
153
+ case "blob": return "BLOB";
154
+ }
155
+ }
156
+ /**
157
+ * Quote a SQL identifier (a table or column name) so any characters are literal.
158
+ *
159
+ * @remarks
160
+ * Wraps the name in double quotes and doubles any embedded quote — the standard
161
+ * SQL identifier-quoting that lets a column named `order` or `from` be referenced
162
+ * safely. Identifiers cannot be bound as parameters, so they are quoted instead.
163
+ *
164
+ * @param identifier - The raw identifier
165
+ * @returns The double-quoted identifier
166
+ *
167
+ * @example
168
+ * ```ts
169
+ * quote('order') // '"order"'
170
+ * ```
171
+ */
172
+ function quote(identifier) {
173
+ return "\"" + identifier.replaceAll("\"", "\"\"") + "\"";
174
+ }
175
+ /**
176
+ * Compile a {@link FieldPath} to the SQL expression that reads it.
177
+ *
178
+ * @remarks
179
+ * A single string is ONE column — `quote(path)`. An array descends a JSON column:
180
+ * the first element is the (quoted) column, the rest a `json_extract` path
181
+ * (`json_extract("payload", '$.user.id')`), matching the guide's nested-field
182
+ * examples (simple identifier keys). The string's value is never split on `.`
183
+ * (AGENTS — `FieldPath` semantics): a dotted string is one column literally.
184
+ *
185
+ * @param path - The field path (a column, or a column + nested keys)
186
+ * @returns The SQL expression selecting the value
187
+ *
188
+ * @example
189
+ * ```ts
190
+ * fieldColumn('payload') // '"payload"'
191
+ * fieldColumn(['payload', 'user', 'id']) // 'json_extract("payload", \'$.user.id\')'
192
+ * ```
193
+ */
194
+ function fieldColumn(path) {
195
+ if (isString(path)) return quote(path);
196
+ const rest = path.slice(1).map((key) => "." + key.replaceAll("'", "''")).join("");
197
+ return "json_extract(" + quote(path[0]) + ", '$" + rest + "')";
198
+ }
199
+ /**
200
+ * Compile an {@link AggregateFunction} over a {@link FieldPath} to its SQL
201
+ * aggregate expression — the SELECT body the SQLite driver's native `aggregate`
202
+ * runs.
203
+ *
204
+ * @remarks
205
+ * `count` → `COUNT(*)` (counting all matched ROWS, not non-null column values —
206
+ * mirroring the engine's `computeAggregate('count')`, which is `rows.length`); the
207
+ * numeric aggregates wrap the column's read expression (a flat column, or a nested
208
+ * `json_extract` path) in `SUM` / `AVG` / `MIN` / `MAX`. Over zero matched rows
209
+ * `COUNT(*)` is `0` and the numeric aggregates are SQL `NULL` (→ `undefined`),
210
+ * matching the engine.
211
+ *
212
+ * @param operation - The aggregate to compute
213
+ * @param column - The column (or nested path) to aggregate
214
+ * @returns The SQL aggregate expression
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * aggregateSQL('count', 'age') // 'COUNT(*)'
219
+ * aggregateSQL('sum', 'age') // 'SUM("age")'
220
+ * aggregateSQL('average', ['payload', 'score']) // 'AVG(json_extract("payload", \'$.score\'))'
221
+ * ```
222
+ */
223
+ function aggregateSQL(operation, column) {
224
+ switch (operation) {
225
+ case "count": return "COUNT(*)";
226
+ case "sum": return "SUM(" + fieldColumn(column) + ")";
227
+ case "average": return "AVG(" + fieldColumn(column) + ")";
228
+ case "minimum": return "MIN(" + fieldColumn(column) + ")";
229
+ case "maximum": return "MAX(" + fieldColumn(column) + ")";
230
+ }
231
+ }
232
+ /**
233
+ * Encode a JS value to its stored {@link SQLiteValue} for a column's type.
234
+ *
235
+ * @remarks
236
+ * The forward half of the bridge, total (AGENTS §14): a value that does not fit
237
+ * its column's storage type encodes to `null` rather than throwing. A `boolean`
238
+ * column stores `1` / `0` (and `null` / `undefined` → `null`); a `json` column
239
+ * stores `JSON.stringify` (or `null` for `null` / `undefined`); `integer` /
240
+ * `real` keep a `number` / `bigint`, else `null`; `text` keeps a `string`, else
241
+ * `null`; `blob` keeps a `Uint8Array`, else `null`. Narrowed with `typeof` /
242
+ * `instanceof`, never `as`.
243
+ *
244
+ * @param value - The JS value to store
245
+ * @param type - The column's portable storage type
246
+ * @returns The value SQLite stores
247
+ *
248
+ * @example
249
+ * ```ts
250
+ * encodeValue(true, 'boolean') // 1
251
+ * encodeValue({ a: 1 }, 'json') // '{"a":1}'
252
+ * ```
253
+ */
254
+ function encodeValue(value, type) {
255
+ switch (type) {
256
+ case "boolean": return value === void 0 || value === null ? null : value === true ? 1 : 0;
257
+ case "json": return value === void 0 || value === null ? null : JSON.stringify(value);
258
+ case "integer":
259
+ case "real": return typeof value === "number" || typeof value === "bigint" ? value : null;
260
+ case "text": return typeof value === "string" ? value : null;
261
+ case "blob": return value instanceof Uint8Array ? value : null;
262
+ }
263
+ }
264
+ /**
265
+ * Decode a stored {@link SQLiteValue} back to its JS value for a column's type —
266
+ * the exact inverse of {@link encodeValue}.
267
+ *
268
+ * @remarks
269
+ * A `boolean` column reads `1` / `0` back to `true` / `false` (a stored `null`
270
+ * → `undefined`); a `json` column `JSON.parse`s a string (anything else →
271
+ * `undefined`); every other type passes the value through, mapping a stored
272
+ * `NULL` to `undefined`. NULL decodes to `undefined` so {@link decodeRow} can
273
+ * omit absent columns.
274
+ *
275
+ * @param value - The stored SQLite value
276
+ * @param type - The column's portable storage type
277
+ * @returns The decoded JS value (`undefined` for a stored `NULL`)
278
+ *
279
+ * @example
280
+ * ```ts
281
+ * decodeValue(1, 'boolean') // true
282
+ * decodeValue('{"a":1}', 'json') // { a: 1 }
283
+ * ```
284
+ */
285
+ function decodeValue(value, type) {
286
+ switch (type) {
287
+ case "boolean": return value === null ? void 0 : value !== 0;
288
+ case "json": return typeof value === "string" ? JSON.parse(value) : void 0;
289
+ default: return value === null ? void 0 : value;
290
+ }
291
+ }
292
+ /**
293
+ * Encode a whole {@link Row} to a {@link SQLiteRow} by its table's schema.
294
+ *
295
+ * @remarks
296
+ * Encodes each declared column's value with {@link encodeValue}; columns the row
297
+ * does not carry encode from `undefined` (so they store `null`). Only the
298
+ * schema's columns appear in the result — an extra row key is dropped.
299
+ *
300
+ * @param row - The JS row to store
301
+ * @param schema - The table's schema
302
+ * @returns The storable SQLite row
303
+ *
304
+ * @example
305
+ * ```ts
306
+ * encodeRow({ id: 'u1', active: true }, schema) // { id: 'u1', active: 1, ... }
307
+ * ```
308
+ */
309
+ function encodeRow(row, schema) {
310
+ const result = {};
311
+ for (const column of schema.columns) result[column.name] = encodeValue(row[column.name], column.type);
312
+ return result;
313
+ }
314
+ /**
315
+ * Decode a stored {@link SQLiteRow} back to a {@link Row} by its table's schema.
316
+ *
317
+ * @remarks
318
+ * Decodes each declared column with {@link decodeValue} and **omits** any column
319
+ * whose decoded value is `undefined` — so an absent / `NULL` optional column does
320
+ * not surface as `{ bio: undefined }`, matching how the contract's optional
321
+ * columns expect absence. A known, documented edge: a non-optional `nullableShape`
322
+ * column storing `null` round-trips to absent (a `null` cell decodes to
323
+ * `undefined`, and an `undefined` value is omitted).
324
+ *
325
+ * @param row - The stored SQLite row
326
+ * @param schema - The table's schema
327
+ * @returns The decoded JS row (absent columns omitted)
328
+ *
329
+ * @example
330
+ * ```ts
331
+ * decodeRow({ id: 'u1', active: 1, bio: null }, schema) // { id: 'u1', active: true }
332
+ * ```
333
+ */
334
+ function decodeRow(row, schema) {
335
+ const result = {};
336
+ for (const column of schema.columns) {
337
+ const decoded = decodeValue(row[column.name], column.type);
338
+ if (decoded !== void 0) result[column.name] = decoded;
339
+ }
340
+ return result;
341
+ }
342
+ /**
343
+ * Project a {@link TableSchema} to the `CREATE TABLE IF NOT EXISTS` statement a
344
+ * SQLite driver's `open` issues for it.
345
+ *
346
+ * @remarks
347
+ * Each column compiles to `<quoted name> <columnSQL(type)>`; the statement ends
348
+ * with `PRIMARY KEY (<quoted primary>)`. No `NOT NULL` is emitted — the contract
349
+ * validates required-ness, the database is just storage (AGENTS §14).
350
+ *
351
+ * @param schema - The table's schema
352
+ * @returns The `CREATE TABLE IF NOT EXISTS …` statement
353
+ *
354
+ * @example
355
+ * ```ts
356
+ * schemaToTable(schema)
357
+ * // 'CREATE TABLE IF NOT EXISTS "users" ("id" TEXT, "age" INTEGER, PRIMARY KEY ("id"))'
358
+ * ```
359
+ */
360
+ function schemaToTable(schema) {
361
+ const columns = schema.columns.map((column) => quote(column.name) + " " + columnSQL(column.type));
362
+ return "CREATE TABLE IF NOT EXISTS " + quote(schema.name) + " (" + columns.join(", ") + ", PRIMARY KEY (" + quote(schema.primary) + "))";
363
+ }
364
+ /**
365
+ * Project a {@link TableSchema} to the `CREATE INDEX IF NOT EXISTS` statements a
366
+ * SQLite driver's `open` issues for its declared indexes.
367
+ *
368
+ * @remarks
369
+ * One statement per index group; the index name is `idx_<table>_<columns joined
370
+ * by _>`, matching the driver's naming so a repeated `open` is idempotent.
371
+ *
372
+ * @param schema - The table's schema
373
+ * @returns One `CREATE INDEX IF NOT EXISTS …` statement per declared index
374
+ *
375
+ * @example
376
+ * ```ts
377
+ * schemaToIndexes(schema)
378
+ * // ['CREATE INDEX IF NOT EXISTS "idx_users_name" ON "users" ("name")']
379
+ * ```
380
+ */
381
+ function schemaToIndexes(schema) {
382
+ return schema.indexes.map((group) => "CREATE INDEX IF NOT EXISTS " + quote("idx_" + schema.name + "_" + group.join("_")) + " ON " + quote(schema.name) + " (" + group.map(quote).join(", ") + ")");
383
+ }
384
+ //#endregion
385
+ //#region src/server/compilers.ts
386
+ /**
387
+ * Escape `\`, `%`, and `_` (each with a leading `\`) so a `starts` / `ends`
388
+ * operand is matched literally under the `LIKE … ESCAPE '\'` clause.
389
+ *
390
+ * @param text - The raw operand text
391
+ * @returns The text with LIKE metacharacters escaped
392
+ *
393
+ * @example
394
+ * ```ts
395
+ * escapeLike('50%_off') // '50\\%\\_off'
396
+ * ```
397
+ */
398
+ function escapeLike(text) {
399
+ return text.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
400
+ }
401
+ /**
402
+ * The declared storage type of a flat (string) column, read from the schema.
403
+ *
404
+ * @param column - The column name
405
+ * @param schema - The table's schema
406
+ * @returns The column's {@link ColumnType}, or `undefined` if the schema does not carry it
407
+ *
408
+ * @example
409
+ * ```ts
410
+ * declaredType('age', schema) // 'integer'
411
+ * ```
412
+ */
413
+ function declaredType(column, schema) {
414
+ return schema.columns.find((candidate) => candidate.name === column)?.type;
415
+ }
416
+ /**
417
+ * The storage type a nested (`json_extract`) operand encodes as, derived from its
418
+ * RUNTIME value — NOT `json`.
419
+ *
420
+ * @remarks
421
+ * `json_extract` returns the unquoted, natively-typed scalar (a JSON boolean as
422
+ * `1` / `0`, a number as-is, a string as-is), so the operand must encode to that
423
+ * same scalar to compare. A boolean → `'boolean'` (→ `1` / `0`); a number →
424
+ * `'integer'` / `'real'`; a bigint → `'integer'`; a string → `'text'`; `null` /
425
+ * `undefined` → `'text'` (encodes to `null`); an object / array → `'json'` (the
426
+ * edge of comparing against a json subtree).
427
+ *
428
+ * @param value - The runtime operand value
429
+ * @returns The {@link ColumnType} to encode it as
430
+ *
431
+ * @example
432
+ * ```ts
433
+ * valueType(true) // 'boolean'
434
+ * valueType(9) // 'integer'
435
+ * ```
436
+ */
437
+ function valueType(value) {
438
+ if (typeof value === "boolean") return "boolean";
439
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
440
+ if (typeof value === "bigint") return "integer";
441
+ if (typeof value === "object" && value !== null) return "json";
442
+ return "text";
443
+ }
444
+ /**
445
+ * Compile one condition to its `<column> <operator>` SQL fragment and the params
446
+ * it binds.
447
+ *
448
+ * @remarks
449
+ * Every operand is run through `encodeValue`, so a bound value matches the SQL
450
+ * the column side compiles to. A flat column encodes operands with its DECLARED
451
+ * schema type (a flat `json` column → `JSON.stringify`); a nested `FieldPath`
452
+ * encodes each operand as the NATIVE scalar `json_extract` returns, derived from
453
+ * the operand's runtime type (per-operand, since `between` / `any` / `none` can
454
+ * mix types). `any` / `none` collapse an empty list to a constant (`0` matches
455
+ * nothing, `1` matches all) with no params. A nested field with a null/undefined
456
+ * operand under `equals` / `not` compiles to `IS NULL` / `IS NOT NULL` (no bound
457
+ * param) instead of `= ?` / `!= ?`, matching the engine's treatment of a
458
+ * present-but-null nested value.
459
+ *
460
+ * @param condition - The condition to compile
461
+ * @param schema - The table's schema (for declared column types)
462
+ * @returns The SQL fragment and its bound parameters
463
+ *
464
+ * @example
465
+ * ```ts
466
+ * fragment({ column: 'age', operator: 'above', values: [18], connector: 'and' }, schema)
467
+ * // { sql: '"age" > ?', params: [18] }
468
+ * ```
469
+ */
470
+ function fragment(condition, schema) {
471
+ const column = fieldColumn(condition.column);
472
+ const nested = !isString(condition.column);
473
+ const declared = isString(condition.column) ? declaredType(condition.column, schema) : void 0;
474
+ const encode = (value) => encodeValue(value, nested ? valueType(value) : declared ?? "json");
475
+ const first = condition.values[0];
476
+ const second = condition.values[1];
477
+ const nullOperand = nested && (first === null || first === void 0);
478
+ switch (condition.operator) {
479
+ case "equals":
480
+ if (nullOperand) return {
481
+ sql: column + " IS NULL",
482
+ params: []
483
+ };
484
+ return {
485
+ sql: column + " = ?",
486
+ params: [encode(first)]
487
+ };
488
+ case "not":
489
+ if (nullOperand) return {
490
+ sql: column + " IS NOT NULL",
491
+ params: []
492
+ };
493
+ return {
494
+ sql: column + " != ?",
495
+ params: [encode(first)]
496
+ };
497
+ case "above": return {
498
+ sql: column + " > ?",
499
+ params: [encode(first)]
500
+ };
501
+ case "below": return {
502
+ sql: column + " < ?",
503
+ params: [encode(first)]
504
+ };
505
+ case "from": return {
506
+ sql: column + " >= ?",
507
+ params: [encode(first)]
508
+ };
509
+ case "to": return {
510
+ sql: column + " <= ?",
511
+ params: [encode(first)]
512
+ };
513
+ case "between": return {
514
+ sql: column + " BETWEEN ? AND ?",
515
+ params: [encode(first), encode(second)]
516
+ };
517
+ case "like": return {
518
+ sql: column + " LIKE ?",
519
+ params: [encode(first)]
520
+ };
521
+ case "glob": return {
522
+ sql: column + " GLOB ?",
523
+ params: [encode(first)]
524
+ };
525
+ case "starts": return {
526
+ sql: column + " LIKE ? ESCAPE '\\'",
527
+ params: [(isString(first) ? escapeLike(first) : "") + "%"]
528
+ };
529
+ case "ends": return {
530
+ sql: column + " LIKE ? ESCAPE '\\'",
531
+ params: ["%" + (isString(first) ? escapeLike(first) : "")]
532
+ };
533
+ case "any":
534
+ if (condition.values.length === 0) return {
535
+ sql: "0",
536
+ params: []
537
+ };
538
+ return {
539
+ sql: column + " IN (" + condition.values.map(() => "?").join(", ") + ")",
540
+ params: condition.values.map(encode)
541
+ };
542
+ case "none":
543
+ if (condition.values.length === 0) return {
544
+ sql: "1",
545
+ params: []
546
+ };
547
+ return {
548
+ sql: column + " NOT IN (" + condition.values.map(() => "?").join(", ") + ")",
549
+ params: condition.values.map(encode)
550
+ };
551
+ case "absent": return {
552
+ sql: column + " IS NULL",
553
+ params: []
554
+ };
555
+ case "present": return {
556
+ sql: column + " IS NOT NULL",
557
+ params: []
558
+ };
559
+ }
560
+ }
561
+ /**
562
+ * Fold the conditions into one WHERE clause, parenthesizing progressively
563
+ * left-to-right so the grouping matches the engine's `matchesCriteria` fold.
564
+ *
565
+ * @remarks
566
+ * The first condition's connector is ignored, per the {@link Condition} types.
567
+ *
568
+ * @param conditions - The conditions to fold
569
+ * @param schema - The table's schema
570
+ * @returns The `WHERE …` clause and its bound parameters, or an empty clause for zero conditions
571
+ *
572
+ * @example
573
+ * ```ts
574
+ * compileWhere([{ column: 'age', operator: 'from', values: [18], connector: 'and' }], schema)
575
+ * // { sql: 'WHERE "age" >= ?', params: [18] }
576
+ * ```
577
+ */
578
+ function compileWhere(conditions, schema) {
579
+ if (conditions.length === 0) return {
580
+ sql: "",
581
+ params: []
582
+ };
583
+ const head = fragment(conditions[0], schema);
584
+ let clause = head.sql;
585
+ const params = [...head.params];
586
+ for (let index = 1; index < conditions.length; index += 1) {
587
+ const next = fragment(conditions[index], schema);
588
+ const operator = conditions[index].connector === "or" ? "OR" : "AND";
589
+ clause = "(" + clause + " " + operator + " " + next.sql + ")";
590
+ params.push(...next.params);
591
+ }
592
+ return {
593
+ sql: "WHERE " + clause,
594
+ params
595
+ };
596
+ }
597
+ /**
598
+ * Compile the ORDER BY clause from the order terms, always ending with the
599
+ * primary key as the final determinant.
600
+ *
601
+ * @remarks
602
+ * The native `records` read then resolves ties in key order, matching a
603
+ * primary-key-ordered `scan` and the core engine's stable `sortRows` over a
604
+ * key-ordered scan (and IndexedDB's key-ordered reads), so a native read equals
605
+ * the scan path (AGENTS §21 / §22 native ↔ engine parity). SQLite without an
606
+ * `ORDER BY` returns rowid (insertion) order, and an explicit order alone breaks
607
+ * ties by rowid too — both diverge from every key-ordered backend. The
608
+ * tie-breaker is ASCENDING regardless of the explicit directions: the engine's
609
+ * stable sort runs over key-ascending input, so equal rows stay in
610
+ * ascending-key order whichever way the explicit terms point. Skipped when the
611
+ * primary is already an explicit order term (no double-append).
612
+ *
613
+ * @param order - The explicit order terms, or `undefined`
614
+ * @param schema - The table's schema (for the primary key)
615
+ * @returns The `ORDER BY …` clause, or an empty string when there is nothing to order by
616
+ *
617
+ * @example
618
+ * ```ts
619
+ * compileOrder([{ column: 'age', direction: 'descending' }], schema)
620
+ * // 'ORDER BY "age" DESC, "id"'
621
+ * ```
622
+ */
623
+ function compileOrder(order, schema) {
624
+ const terms = (order ?? []).map((term) => fieldColumn(term.column) + (term.direction === "descending" ? " DESC" : " ASC"));
625
+ if (!(order ?? []).some((term) => isString(term.column) && term.column === schema.primary)) terms.push(quote(schema.primary));
626
+ return terms.length === 0 ? "" : "ORDER BY " + terms.join(", ");
627
+ }
628
+ /**
629
+ * Compile the LIMIT / OFFSET clause.
630
+ *
631
+ * @remarks
632
+ * An offset without a limit uses `LIMIT -1` (SQLite's "no limit") so OFFSET is
633
+ * still honored.
634
+ *
635
+ * @param limit - The maximum row count, or `undefined`
636
+ * @param offset - The row count to skip, or `undefined`
637
+ * @returns The `LIMIT …` clause and its bound parameters, or an empty clause when neither is set
638
+ *
639
+ * @example
640
+ * ```ts
641
+ * compilePage(undefined, 5) // { sql: 'LIMIT -1 OFFSET ?', params: [5] }
642
+ * ```
643
+ */
644
+ function compilePage(limit, offset) {
645
+ if (limit !== void 0 && offset !== void 0) return {
646
+ sql: "LIMIT ? OFFSET ?",
647
+ params: [limit, offset]
648
+ };
649
+ if (limit !== void 0) return {
650
+ sql: "LIMIT ?",
651
+ params: [limit]
652
+ };
653
+ if (offset !== void 0) return {
654
+ sql: "LIMIT -1 OFFSET ?",
655
+ params: [offset]
656
+ };
657
+ return {
658
+ sql: "",
659
+ params: []
660
+ };
661
+ }
662
+ /**
663
+ * Compile a {@link Criteria} into the SQL clause that follows a table name, with
664
+ * its bound parameters in clause order.
665
+ *
666
+ * @remarks
667
+ * The driver's native `records` / `count` path: it assembles
668
+ * `[where, orderBy, limitOffset]` (each possibly empty) into one clause so a
669
+ * `SELECT * FROM <table> <clause>` runs the whole read in the engine instead of
670
+ * over a JS `scan`. The WHERE fold is parenthesized **left-to-right** to mirror
671
+ * the core engine's `matchesCriteria` (not SQL's native AND-over-OR precedence),
672
+ * so a native and an engine read return identical rows. Each operand is encoded
673
+ * via `encodeValue`: a flat column uses its declared schema type, while a nested
674
+ * `FieldPath` (a `json_extract` read) encodes each operand as the native scalar
675
+ * the extract returns — derived from the operand's runtime type — so it compares.
676
+ * The 15 operators map per the databases guide's operator table, with
677
+ * `starts` / `ends` using `LIKE … ESCAPE '\'` and an empty `any` / `none` list
678
+ * collapsing to a constant. A `undefined` criteria (or one with no parts)
679
+ * compiles to an empty clause.
680
+ *
681
+ * @param criteria - The read specification, or `undefined` for all rows
682
+ * @param schema - The table's schema (column types for operand encoding)
683
+ * @returns The SQL tail and its bound parameters
684
+ *
685
+ * @example
686
+ * ```ts
687
+ * compileCriteria({ conditions: [{ column: 'age', operator: 'from', values: [18], connector: 'and' }] }, schema)
688
+ * // { sql: 'WHERE "age" >= ? ORDER BY "id"', params: [18] }
689
+ * ```
690
+ */
691
+ function compileCriteria(criteria, schema) {
692
+ const where = compileWhere(criteria?.conditions ?? [], schema);
693
+ const orderBy = compileOrder(criteria?.order, schema);
694
+ const page = compilePage(criteria?.limit, criteria?.offset);
695
+ return {
696
+ sql: [
697
+ where.sql,
698
+ orderBy,
699
+ page.sql
700
+ ].filter((part) => part !== "").join(" "),
701
+ params: [...where.params, ...page.params]
702
+ };
703
+ }
704
+ //#endregion
705
+ //#region src/server/drivers/JSONDriver.ts
706
+ /**
707
+ * A persistent {@link DriverInterface} backed by a single JSON file — the
708
+ * reference {@link MemoryDriver} plus file load / flush.
709
+ *
710
+ * @remarks
711
+ * A decorator, not a reimplementation: every primitive delegates to an inner
712
+ * {@link MemoryDriver}, so querying, key-order `scan` / `keys`, and capture-replay
713
+ * `snapshot` are inherited unchanged — this layer adds only persistence. `open`
714
+ * loads the file into memory; every mutation (`write` / `delete` / `clear`) flushes
715
+ * the whole store back. The file is one JSON object, `{ meta?: DriverMeta, tables: {
716
+ * [name]: rows } }` — `meta` is present only once the store has been `stamp`ed
717
+ * (an unstamped store serializes the old `{ tables }` shape, preserving
718
+ * backward compatibility); a per-table array of rows, each row carrying its own
719
+ * primary (the table contract), so the key is recovered on load with
720
+ * {@link extractKey} and the file need not store it. The parsed JSON crosses the
721
+ * boundary as `unknown` and is narrowed with {@link isRecord} / {@link extractKey},
722
+ * never asserted (AGENTS §14): a missing, corrupt, or wrong-shaped file starts
723
+ * empty rather than throwing, and a malformed row (or malformed `meta`) is
724
+ * skipped/dropped rather than thrown on. It is scan-only — it implements none of
725
+ * the optional native `records` / `count` / `aggregate` hooks, so the core engine
726
+ * over `scan` answers every query. For development, small datasets, and portable /
727
+ * inspectable data; for large or concurrent workloads reach for a SQLite-backed
728
+ * driver.
729
+ *
730
+ * A failure in the write path ({@link JSONDriver.#serialize} — `mkdir` /
731
+ * `writeFile` / `rename`) is wrapped and rethrown as `DatabaseError` `DRIVER`,
732
+ * carrying the target `path` in its context; the read path ({@link
733
+ * JSONDriver.#load}) tolerance above is a separate, deliberate contract and is
734
+ * never touched by this wrapping.
735
+ */
736
+ var JSONDriver = class {
737
+ #path;
738
+ #memory = new _src_core.MemoryDriver();
739
+ #schema = [];
740
+ #meta;
741
+ #flushCount = 0;
742
+ #chain = Promise.resolve();
743
+ #deferring = false;
744
+ constructor(path) {
745
+ this.#path = path;
746
+ }
747
+ async open(schema) {
748
+ this.#schema = schema;
749
+ await this.#memory.open(schema);
750
+ await this.#load();
751
+ }
752
+ async close() {
753
+ await this.#memory.close();
754
+ }
755
+ async read(table, key) {
756
+ return this.#memory.read(table, key);
757
+ }
758
+ async write(table, key, row) {
759
+ await this.#memory.write(table, key, row);
760
+ if (!this.#deferring) await this.#flush();
761
+ }
762
+ async delete(table, key) {
763
+ const removed = await this.#memory.delete(table, key);
764
+ if (!this.#deferring) await this.#flush();
765
+ return removed;
766
+ }
767
+ keys(table) {
768
+ return this.#memory.keys(table);
769
+ }
770
+ scan(table) {
771
+ return this.#memory.scan(table);
772
+ }
773
+ /**
774
+ * Natively filtered lazy iteration — delegates to the inner {@link MemoryDriver}.
775
+ *
776
+ * @remarks
777
+ * Semantics are the memory driver's own: `criteria.conditions` filters, `offset`
778
+ * / `limit` page lazily, and `criteria.order` is ignored (streaming yields key
779
+ * order; sorted output is `records()`'s job).
780
+ *
781
+ * @param table - The table to stream
782
+ * @param criteria - The filter / offset / limit to apply lazily
783
+ */
784
+ stream(table, criteria) {
785
+ return this.#memory.stream(table, criteria);
786
+ }
787
+ async clear(table) {
788
+ await this.#memory.clear(table);
789
+ if (!this.#deferring) await this.#flush();
790
+ }
791
+ /**
792
+ * Begin a native transaction — flush-coalescing over the inner {@link MemoryDriver}.
793
+ *
794
+ * @remarks
795
+ * Single-writer: throws `DatabaseError` `CONFLICT` if a transaction is already
796
+ * active — this driver does not support nesting. On begin, captures the inner
797
+ * memory rollback thunk via `#memory.snapshot()` and suppresses per-mutation
798
+ * `#flush` — `write` / `delete` / `clear` still mutate memory but no longer
799
+ * touch the file, so N mutations under the handle cost ONE file write instead
800
+ * of N. `commit()` releases the suppression and performs that one atomic
801
+ * `#flush()`, persisting the transaction's net state. `rollback()` restores
802
+ * memory via the captured snapshot thunk, then `#flush()`s so the file reflects
803
+ * the restored state. Outside a transaction, behavior is unchanged — every
804
+ * mutation flushes on its own. Calling `commit` / `rollback` a second time (on
805
+ * either method, in either order) throws `DatabaseError` `CONFLICT`.
806
+ *
807
+ * @returns A {@link TransactionInterface} handle to `commit` or `rollback`
808
+ */
809
+ async transaction() {
810
+ if (this.#deferring) throw new _src_core.DatabaseError("CONFLICT", "A transaction is already active on this driver", {});
811
+ const rollback = await this.#memory.snapshot();
812
+ this.#deferring = true;
813
+ let settled = false;
814
+ return {
815
+ commit: async () => {
816
+ if (settled) throw new _src_core.DatabaseError("CONFLICT", "Transaction already settled", {});
817
+ settled = true;
818
+ this.#deferring = false;
819
+ await this.#flush();
820
+ },
821
+ rollback: async () => {
822
+ if (settled) throw new _src_core.DatabaseError("CONFLICT", "Transaction already settled", {});
823
+ settled = true;
824
+ await rollback();
825
+ this.#deferring = false;
826
+ await this.#flush();
827
+ }
828
+ };
829
+ }
830
+ async snapshot(tables) {
831
+ const rollback = await this.#memory.snapshot(tables);
832
+ return async () => {
833
+ await rollback();
834
+ await this.#flush();
835
+ };
836
+ }
837
+ async meta() {
838
+ return this.#meta;
839
+ }
840
+ /**
841
+ * Persist `meta` verbatim for a later `meta()` to return.
842
+ *
843
+ * @remarks
844
+ * Respects the same defer-flush suppression as `write` / `delete` / `clear`
845
+ * (see {@link JSONDriver.transaction} @remarks) — stamping inside an active
846
+ * transaction updates memory but does not flush until the transaction settles.
847
+ *
848
+ * @param meta - The {@link DriverMeta} to persist
849
+ */
850
+ async stamp(meta) {
851
+ this.#meta = meta;
852
+ if (!this.#deferring) await this.#flush();
853
+ }
854
+ /**
855
+ * Apply a {@link Migration} plan by delegating to the inner {@link MemoryDriver},
856
+ * then persist the migrated state.
857
+ *
858
+ * @remarks
859
+ * The inner `MemoryDriver.migrate` applies each step (adding/removing tables,
860
+ * adding/removing columns from stored rows, no-op index steps) and throws
861
+ * `DatabaseError` `MIGRATION` for a step referencing an unknown table — that
862
+ * error propagates untouched. `table.add` / `table.remove` steps also update
863
+ * this driver's own declared `#schema`, mirroring the bookkeeping `open` does,
864
+ * so a subsequent `#flush` / `#load` round-trip includes (or drops) the table.
865
+ * A successful migration ends with one atomic `#flush()` so the new state
866
+ * survives a close and reopen. A multi-step plan applies its steps
867
+ * sequentially and is NOT atomic — a failure partway through a plan leaves
868
+ * the earlier steps already applied.
869
+ *
870
+ * @param plan - The migration plan to apply
871
+ */
872
+ async migrate(plan) {
873
+ await this.#memory.migrate?.(plan);
874
+ let schema = this.#schema;
875
+ for (const step of plan.steps) if (step.operation === "table.add") schema = schema.some((table) => table.name === step.table.name) ? schema : [...schema, step.table];
876
+ else if (step.operation === "table.remove") schema = schema.filter((table) => table.name !== step.table);
877
+ this.#schema = schema;
878
+ await this.#flush();
879
+ }
880
+ async #load() {
881
+ let raw;
882
+ try {
883
+ raw = await (0, node_fs_promises.readFile)(this.#path, "utf-8");
884
+ } catch {
885
+ return;
886
+ }
887
+ let parsed;
888
+ try {
889
+ parsed = JSON.parse(raw);
890
+ } catch {
891
+ return;
892
+ }
893
+ if (!isRecord(parsed) || !isRecord(parsed.tables)) return;
894
+ const tables = parsed.tables;
895
+ for (const table of this.#schema) {
896
+ const rows = tables[table.name];
897
+ if (!Array.isArray(rows)) continue;
898
+ for (const entry of rows) {
899
+ if (!isRecord(entry)) continue;
900
+ const key = (0, _src_core.extractKey)(entry, table.primary);
901
+ if (key === void 0) continue;
902
+ await this.#memory.write(table.name, key, entry);
903
+ }
904
+ }
905
+ const COLUMN_TYPES = [
906
+ "text",
907
+ "integer",
908
+ "real",
909
+ "boolean",
910
+ "json",
911
+ "blob"
912
+ ];
913
+ const isColumnType = (value) => isString(value) && COLUMN_TYPES.some((type) => type === value);
914
+ const isColumnSchema = (value) => isRecord(value) && isString(value.name) && isColumnType(value.type) && isBoolean(value.nullable);
915
+ const isIndexGroup = (value) => isArray(value) && value.every(isString);
916
+ const isTableSchema = (value) => isRecord(value) && isString(value.name) && isString(value.primary) && isArray(value.columns) && value.columns.every(isColumnSchema) && isArray(value.indexes) && value.indexes.every(isIndexGroup);
917
+ if (isRecord(parsed.meta)) {
918
+ const version = parsed.meta.version;
919
+ const schema = parsed.meta.schema;
920
+ if (typeof version === "number" && Number.isFinite(version) && isArray(schema) && schema.every(isTableSchema)) this.#meta = {
921
+ version,
922
+ schema
923
+ };
924
+ }
925
+ }
926
+ async #flush() {
927
+ const next = this.#chain.then(() => this.#serialize());
928
+ this.#chain = next.catch(() => {});
929
+ await next;
930
+ }
931
+ async #serialize() {
932
+ const tables = {};
933
+ for (const table of this.#schema) {
934
+ const rows = [];
935
+ for await (const row of this.#memory.scan(table.name)) rows.push(row);
936
+ tables[table.name] = rows;
937
+ }
938
+ this.#flushCount += 1;
939
+ const temp = `${this.#path}.${process.pid}.${this.#flushCount}.tmp`;
940
+ const payload = this.#meta === void 0 ? { tables } : {
941
+ meta: this.#meta,
942
+ tables
943
+ };
944
+ try {
945
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(this.#path), { recursive: true });
946
+ await (0, node_fs_promises.writeFile)(temp, JSON.stringify(payload, null, 2), "utf-8");
947
+ await (0, node_fs_promises.rename)(temp, this.#path);
948
+ } catch (error) {
949
+ await (0, node_fs_promises.rm)(temp, { force: true }).catch(() => {});
950
+ throw new _src_core.DatabaseError("DRIVER", "Failed to persist the database file", {
951
+ path: this.#path,
952
+ cause: error
953
+ });
954
+ }
955
+ }
956
+ };
957
+ //#endregion
958
+ //#region src/server/factories.ts
959
+ /**
960
+ * Create a persistent JSON-file {@link DriverInterface} for the core database layer.
961
+ *
962
+ * @remarks
963
+ * Pass it to `createDatabase` from `@src/core` to run the whole typed database +
964
+ * relations stack against a single JSON file instead of memory — the `Database` /
965
+ * `Table` / `Query` / relations API is unchanged; only where the bytes live changes.
966
+ * The driver is the reference `MemoryDriver` plus JSON-file persistence: `open` loads
967
+ * the file, every mutation flushes the whole store back, and querying runs through
968
+ * the core engine over `scan` (it is scan-only — no native `records` / `count` /
969
+ * `aggregate`). A missing, corrupt, or wrong-shaped file starts empty rather than
970
+ * throwing.
971
+ *
972
+ * @param path - The JSON file path data is loaded from and flushed to
973
+ * @returns A {@link DriverInterface} backed by a JSON file
974
+ *
975
+ * @example
976
+ * ```ts
977
+ * import { createDatabase } from '@orkestrel/database'
978
+ * import { stringShape } from '@orkestrel/contract'
979
+ * import { createJSONDriver } from '@orkestrel/database/server'
980
+ *
981
+ * const db = createDatabase({
982
+ * driver: createJSONDriver('data/app.json'),
983
+ * tables: { users: { id: stringShape(), name: stringShape() } },
984
+ * })
985
+ * await db.table('users').set({ id: 'u1', name: 'Ada' }) // persisted to app.json
986
+ * ```
987
+ */
988
+ function createJSONDriver(path) {
989
+ return new JSONDriver(path);
990
+ }
991
+ //#endregion
992
+ exports.JSONDriver = JSONDriver;
993
+ exports.aggregateSQL = aggregateSQL;
994
+ exports.columnSQL = columnSQL;
995
+ exports.compileCriteria = compileCriteria;
996
+ exports.compileOrder = compileOrder;
997
+ exports.compilePage = compilePage;
998
+ exports.compileWhere = compileWhere;
999
+ exports.createJSONDriver = createJSONDriver;
1000
+ exports.declaredType = declaredType;
1001
+ exports.decodeRow = decodeRow;
1002
+ exports.decodeValue = decodeValue;
1003
+ exports.encodeRow = encodeRow;
1004
+ exports.encodeValue = encodeValue;
1005
+ exports.escapeLike = escapeLike;
1006
+ exports.fieldColumn = fieldColumn;
1007
+ exports.fragment = fragment;
1008
+ exports.generateKey = generateKey;
1009
+ exports.quote = quote;
1010
+ exports.schemaToIndexes = schemaToIndexes;
1011
+ exports.schemaToTable = schemaToTable;
1012
+ exports.valueType = valueType;
1013
+
1014
+ //# sourceMappingURL=index.cjs.map