@jarenjs/db 0.73.0 → 0.83.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/ARCHITECTURE.md +70 -7
  2. package/README.md +69 -6
  3. package/docs/HOSTS.md +17 -0
  4. package/docs/JOBS-FORMAT.md +26 -0
  5. package/docs/LIVE-FORMAT.md +52 -13
  6. package/docs/MIGRATION-FORMAT.md +34 -0
  7. package/docs/MODEL-FORMAT.md +163 -15
  8. package/docs/NATIVE-PLANS.md +111 -0
  9. package/docs/REPLICATION-FORMAT.md +19 -13
  10. package/docs/SEARCH.md +55 -0
  11. package/package.json +8 -4
  12. package/schemas/jaren-migration.draft-07.schema.json +54 -5
  13. package/schemas/jaren-migration.schema.json +49 -0
  14. package/schemas/jaren-model.authoring.schema.json +360 -0
  15. package/schemas/jaren-model.draft-07.schema.json +128 -0
  16. package/schemas/jaren-model.schema.json +128 -0
  17. package/src/algebra.js +26 -4
  18. package/src/backup.js +12 -7
  19. package/src/cursor.js +27 -4
  20. package/src/dag-job.js +2 -1
  21. package/src/ddl.js +13 -0
  22. package/src/derive.js +14 -3
  23. package/src/dialect.js +12 -0
  24. package/src/dialects/check-read.js +151 -0
  25. package/src/dialects/invariant-sql.js +117 -0
  26. package/src/dialects/postgres.js +28 -4
  27. package/src/dialects/sqlite.js +23 -3
  28. package/src/driver.js +1 -0
  29. package/src/drivers/bun.js +22 -4
  30. package/src/emit.js +133 -25
  31. package/src/entity.js +98 -41
  32. package/src/errors.js +8 -0
  33. package/src/graph.js +8 -1
  34. package/src/index.js +3 -0
  35. package/src/introspect.js +81 -12
  36. package/src/invariants.js +45 -0
  37. package/src/jobs.js +39 -6
  38. package/src/live-nested.js +27 -10
  39. package/src/live.js +51 -136
  40. package/src/migrate.js +136 -22
  41. package/src/model.js +12 -0
  42. package/src/mutation.js +165 -0
  43. package/src/physical.js +147 -0
  44. package/src/plan.js +275 -64
  45. package/src/query.js +175 -78
  46. package/src/search.js +144 -0
  47. package/src/sql.js +60 -0
  48. package/src/store.js +49 -13
  49. package/src/tracker.js +63 -39
  50. package/src/window.js +1 -0
  51. package/types/index.d.ts +59 -4
  52. package/types/search.d.ts +20 -0
  53. package/types/typed.d.ts +1 -0
package/src/backup.js CHANGED
@@ -20,13 +20,13 @@
20
20
  * platform reports progress (`rate` pages per step): the platform
21
21
  * itself takes no signal, so the signal is checked in the progress
22
22
  * callback and a throw there is what stops the pump. There is no
23
- * progress event with a zero remainder — the platform's completion
24
- * signal is the resolved copy, which answers the page total so
25
- * progress events are handed on verbatim and completion is the promise.
23
+ * guaranteed terminal progress event — the platform's completion signal
24
+ * is the resolved copy, which answers the page total. Cancellation is
25
+ * checked again before publication, including after a terminal event.
26
26
  *
27
27
  * This module imports no runtime builtin: the copy, the rename and the
28
- * removal are the driver's primitives (`connection.backup`), present on
29
- * the Node binding and declared absent elsewhere.
28
+ * removal are the driver's primitives (`connection.backup`), supplied by
29
+ * Node online backup and Bun serialized snapshots.
30
30
  */
31
31
 
32
32
  import { DbRuntimeError, wrapDriverError } from './errors.js';
@@ -139,7 +139,8 @@ export function createBackup({ connection, readOnly, gated, checkpoint, random,
139
139
  };
140
140
  let copying;
141
141
  try {
142
- copying = files.copy(tmpPath, rate === undefined ? { progress } : { rate, progress });
142
+ const copy = () => files.copy(tmpPath, rate === undefined ? { progress } : { rate, progress });
143
+ copying = files.snapshot === true ? gated(copy, 'a snapshot backup') : copy();
143
144
  }
144
145
  catch (error) {
145
146
  return discard(aborted ?? failed(error));
@@ -153,7 +154,11 @@ export function createBackup({ connection, readOnly, gated, checkpoint, random,
153
154
  attempt(() => files.rename(tmpPath, targetPath), failed),
154
155
  () => Object.freeze({ path: targetPath, pages: Number(pages), checkpoint: checkpointed ?? null }));
155
156
  return toPromise(copying)
156
- .then((pages) => toPromise(publish(pages)))
157
+ .then((pages) => {
158
+ if (signal?.aborted === true) { aborted = cancelled(signal); throw aborted; }
159
+ callable(options, 'the temporary file was removed and the target path was not written');
160
+ return toPromise(publish(pages));
161
+ })
157
162
  .catch((error) => discard(aborted ?? failed(error)));
158
163
  });
159
164
  },
package/src/cursor.js CHANGED
@@ -345,6 +345,12 @@ export function admitSyncCursor(cursor, admit) {
345
345
  /** The page size a page takes when none is given. */
346
346
  export const PAGE_LIMIT_DEFAULT = 100;
347
347
 
348
+ /** Classify the cursor's byte, row and work refusals at structural adapter boundaries.
349
+ * @param {any} error @returns {boolean} */
350
+ export function isCursorBudgetError(error) {
351
+ return ['JD2073', 'JD2074', 'JD2076'].includes(error?.code);
352
+ }
353
+
348
354
  /**
349
355
  * Drain a cursor into ONE page: at most `limit` items, at most `maxBytes`
350
356
  * serialised bytes (`null` for no byte bound), stopping at an item
@@ -357,9 +363,13 @@ export const PAGE_LIMIT_DEFAULT = 100;
357
363
  * An item that does not fit beside earlier ones ends the page before
358
364
  * it: `hasMore` is true and the continuation is the last delivered
359
365
  * item's, so the next page starts at the item that did not fit.
360
- * `hasMore` is otherwise decided by one peek past `limit`.
366
+ * `hasMore` is otherwise decided by one peek past `limit`. With
367
+ * `lookahead: false`, a full page reports `hasMore: null` and `work`
368
+ * records each consumed root and its serialized payload bytes, including
369
+ * the root that stopped the page at its byte boundary. Failures preserve
370
+ * those counters on the error; pages using the default keep their shape.
361
371
  * @param {any} cursor - a `QueryCursor`
362
- * @param {{ limit: number, maxBytes: number | null, after?: any,
372
+ * @param {{ limit: number, maxBytes: number | null, after?: any, lookahead?: boolean,
363
373
  * sizeOf: (item: any) => number, continuationOf: (item: any) => any }} options
364
374
  * @returns {any} value-or-promise, matching the cursor
365
375
  */
@@ -368,10 +378,13 @@ export function drainPage(cursor, options) {
368
378
  const after = options.after ?? null;
369
379
  const items = [];
370
380
  let bytes = 0;
381
+ const measured = options.lookahead === false;
382
+ const work = { rows: 0, bytes: 0 };
371
383
  let last = after;
372
384
  let hasMore = false;
373
385
  const finish = () => chain(cursor.return(), () => ({
374
386
  items, continuation: items.length > 0 ? last : (hasMore ? after : null), hasMore,
387
+ ...(measured ? { work: { ...work } } : {}),
375
388
  }));
376
389
  const consume = (pulled) => {
377
390
  if (items.length >= limit) {
@@ -380,7 +393,8 @@ export function drainPage(cursor, options) {
380
393
  }
381
394
  if (pulled.done === true) return finish();
382
395
  const item = pulled.value;
383
- const size = maxBytes === null ? 0 : sizeOf(item);
396
+ const size = maxBytes === null && !measured ? 0 : sizeOf(item);
397
+ if (measured) { work.rows++; work.bytes += size; }
384
398
  if (maxBytes !== null && bytes + size > maxBytes) {
385
399
  if (items.length === 0) {
386
400
  return chain(cursor.return(), () => {
@@ -393,6 +407,10 @@ export function drainPage(cursor, options) {
393
407
  items.push(item);
394
408
  bytes += size;
395
409
  last = continuationOf(item);
410
+ if (options.lookahead === false && items.length === limit) {
411
+ hasMore = null;
412
+ return finish();
413
+ }
396
414
  return null;
397
415
  };
398
416
  const step = () => {
@@ -403,7 +421,12 @@ export function drainPage(cursor, options) {
403
421
  if (result !== null) return result;
404
422
  }
405
423
  };
406
- return settling(step, () => cursor.return());
424
+ return settling(step, () => cursor.return(), (error) => {
425
+ // A refused boundary row was still consumed. Preserve that evidence
426
+ // through the owning error so a bounded caller cannot report zero work.
427
+ if (measured && error !== null && typeof error === 'object') error.work = { ...work };
428
+ return error;
429
+ });
407
430
  }
408
431
 
409
432
  /** The shared refusal for an indivisible item, including a replicated transaction.
package/src/dag-job.js CHANGED
@@ -52,7 +52,7 @@ const fingerprint = (value) => hashContent(canonicalizeJson(value ?? null));
52
52
  * concurrency?: number, pollInterval?: number, leaseMs?: number,
53
53
  * owner?: string, renew?: boolean, onOutcome?: (event: any) => void,
54
54
  * backoffBase?: number, backoffCap?: number,
55
- * stopGraceMs?: number }} options
55
+ * stopGraceMs?: number, effectSafety?: (job: any, context: any) => any }} options
56
56
  * @returns {{ start: () => any, stop: (options?: any) => Promise<any>, stats: () => any }}
57
57
  */
58
58
  export function createDagJobRunner(store, options) {
@@ -215,6 +215,7 @@ export function createDagJobRunner(store, options) {
215
215
  owner: options.owner,
216
216
  renew: options.renew,
217
217
  onOutcome: options.onOutcome,
218
+ effectSafety: options.effectSafety,
218
219
  backoffBase: options.backoffBase,
219
220
  backoffCap: options.backoffCap,
220
221
  // the runner's declared default for `stop()` with no override; an
package/src/ddl.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * silently indexing the wrong thing.
15
15
  */
16
16
 
17
+ import { explainMapping } from './model.js';
17
18
  import { analyzeQuery } from '@jarenjs/json/query';
18
19
  import { DbCompileError } from './errors.js';
19
20
  import { chain } from './driver.js';
@@ -902,6 +903,9 @@ export function verifyShape(connection, plan, collection, docPath) {
902
903
  * columnNames: Set<string> }}
903
904
  */
904
905
  export function planEntity(name, entityMapping, entities, dialect) {
906
+ if (entityMapping.document === false) return { table: entityMapping.table,
907
+ physical: { ...entityMapping, triggers: dialect.invariantTriggers?.(entityMapping, entities, dialect) ?? [] }, createSql: [], expected: { columns: [], indexes: [] },
908
+ columnNames: new Set(entityMapping.columns.map((c) => c.physical)) };
905
909
  const storageType = (storage) => dialect.typeFor(storage, 'generated');
906
910
  const keyType = (entityName) => {
907
911
  const target = entities.entities[entityName];
@@ -1037,3 +1041,12 @@ export function planJoinTable(tableName, join, entities, dialect) {
1037
1041
  },
1038
1042
  };
1039
1043
  }
1044
+
1045
+ /** Plan explicit database-enforced persistence rules; never applies them.
1046
+ * @param {any} model @param {{ dialect: any }} options @returns {any[]} */
1047
+ export function planInvariants(model, options) {
1048
+ const mapping = explainMapping(model);
1049
+ const dialect = options.dialect;
1050
+ if (typeof dialect.invariantTriggers !== 'function') throw new DbCompileError('JD0005', 'this dialect cannot lower database invariants');
1051
+ return Object.values(mapping.entities).flatMap((entity) => dialect.invariantTriggers(entity, mapping, dialect));
1052
+ }
package/src/derive.js CHANGED
@@ -383,12 +383,23 @@ export function cellNeighbourhood(cell) {
383
383
  * external's bounding box, computed at bind time because a GeoJSON
384
384
  * object is not a value any database can bind. `null` when the value
385
385
  * has no box, which is what tells the caller to divert.
386
- * @param {{ kind: string, external: string, axis: string }} derived
387
- * @param {any} value - the bound external
386
+ * @param {any} derived
387
+ * @param {any} value - the bound external for bboxAxis; all externals for circleAxis
388
388
  * @returns {number | null}
389
389
  */
390
390
  export function derivedSlotValue(derived, value) {
391
- const box = bboxOf(value);
391
+ let box;
392
+ if (derived.kind === 'bboxAxis') box = bboxOf(value);
393
+ else if (derived.kind === 'circleAxis') {
394
+ const read = (input) => 'external' in input ? value[input.external] : input.literal;
395
+ const at = probePosition(read(derived.centre));
396
+ const radius = read(derived.radius);
397
+ if (at === null || typeof radius !== 'number' || !Number.isFinite(radius) || radius < 0)
398
+ return null;
399
+ box = probeCircleBox(at, radius);
400
+ if (box !== null && (box[0] < -180 || box[2] > 180)) return null;
401
+ }
402
+ else throw new TypeError(`unknown derived parameter kind '${derived.kind}'`);
392
403
  return box === null ? null : box[BBOX_AT[derived.axis]];
393
404
  }
394
405
 
package/src/dialect.js CHANGED
@@ -185,6 +185,12 @@ function normalizeCapabilities(declared) {
185
185
  * memberPathOf?: (expression: string) => (JsonPathSegment[] | null),
186
186
  * expressionOf?: (expression: string, byName: Record<string, string>) => (any | null),
187
187
  * readGenerated?: (rows: any[]) => { name: string, expression: string }[],
188
+ * physicalRead?: (codec: string, sql: string) => string,
189
+ * mutationRowGuard?: (count: string, limit: number) => string,
190
+ * codepoint?: (value: string) => string,
191
+ * physicalTypeMatches?: (codec: string, type: string) => boolean,
192
+ * invariantTriggers?: (mapping: any, all: any, dialect: any) => any[],
193
+ * readChecks?: (rows: any[]) => { name: string, column?: string, values?: any[] }[],
188
194
  * introspect: { version: () => string, compileOptions: () => string,
189
195
  * pragma: (name: string) => string,
190
196
  * tableExists: () => string, columns: (table: string) => string,
@@ -640,6 +646,12 @@ export function createDialect(spec) {
640
646
  * one that can read it.
641
647
  */
642
648
  readGenerated: spec.readGenerated,
649
+ readChecks: spec.readChecks,
650
+ physicalRead: spec.physicalRead,
651
+ mutationRowGuard: spec.mutationRowGuard,
652
+ codepoint: spec.codepoint,
653
+ physicalTypeMatches: spec.physicalTypeMatches,
654
+ invariantTriggers: spec.invariantTriggers,
643
655
  explainQuery: spec.explainQuery,
644
656
  /** The plan narrative, one line per row the engine answered. */
645
657
  explainLines: spec.explainLines,
@@ -0,0 +1,151 @@
1
+ //@ts-check
2
+ /** Recover only a complete scalar enum CHECK; unfamiliar SQL remains a loss. */
3
+
4
+ /** Tokenize catalog SQL without treating quoted text or comments as syntax.
5
+ * @param {string} sql @returns {{ kind: string, value: string }[]} */
6
+ export function sqlTokens(sql) {
7
+ const tokens = [];
8
+ for (let i = 0; i < sql.length;) {
9
+ const c = sql[i];
10
+ if (/\s/.test(c)) { i++; continue; }
11
+ if (sql.startsWith('--', i)) {
12
+ const end = sql.indexOf('\n', i + 2); i = end < 0 ? sql.length : end + 1; continue;
13
+ }
14
+ if (sql.startsWith('/*', i)) {
15
+ const end = sql.indexOf('*/', i + 2);
16
+ if (end < 0) return [];
17
+ i = end + 2; continue;
18
+ }
19
+ if (c === "'" || c === '"' || c === '`') {
20
+ let value = '';
21
+ let closed = false;
22
+ for (i++; i < sql.length; i++) {
23
+ if (sql[i] !== c) { value += sql[i]; continue; }
24
+ if (sql[i + 1] === c) { value += c; i++; continue; }
25
+ i++; closed = true; break;
26
+ }
27
+ if (!closed) return [];
28
+ tokens.push({ kind: c === "'" ? 'string' : 'identifier', value });
29
+ continue;
30
+ }
31
+ const number = /^(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?/.exec(sql.slice(i));
32
+ if (number) { tokens.push({ kind: 'number', value: number[0] }); i += number[0].length; continue; }
33
+ const word = /^[A-Za-z_][A-Za-z_0-9$]*/.exec(sql.slice(i));
34
+ if (word) { tokens.push({ kind: 'word', value: word[0] }); i += word[0].length; continue; }
35
+ const value = sql.startsWith('::', i) ? '::' : c;
36
+ tokens.push({ kind: 'symbol', value }); i += value.length;
37
+ }
38
+ return tokens;
39
+ }
40
+
41
+ /** @param {any[]} tokens @returns {{ column: string, values: any[] } | null} */
42
+ function enumOf(tokens) {
43
+ let at = 0;
44
+ const take = (value) => {
45
+ const token = tokens[at];
46
+ if (token && token.kind !== 'string' && token.kind !== 'identifier'
47
+ && token.value.toUpperCase() === value) { at++; return true; }
48
+ return false;
49
+ };
50
+ const scalar = () => {
51
+ if (take('(')) {
52
+ const value = scalar();
53
+ return value === undefined || !take(')') ? undefined : cast(value);
54
+ }
55
+ const negative = take('-');
56
+ const token = tokens[at++];
57
+ if (!token) return undefined;
58
+ let value;
59
+ if (token.kind === 'number') value = Number(`${negative ? '-' : ''}${token.value}`);
60
+ else if (negative) return undefined;
61
+ else if (token.kind === 'string') value = token.value;
62
+ else if (token.kind === 'word' && /^(true|false)$/i.test(token.value)) value = token.value.toLowerCase() === 'true';
63
+ else return undefined;
64
+ if (typeof value === 'number' && !Number.isFinite(value)) return undefined;
65
+ return cast(value);
66
+ };
67
+ const cast = (value) => {
68
+ if (!take('::')) return value;
69
+ // Only exact scalar casts emitted by the catalog are understood.
70
+ // Rounding casts could change the enum's members.
71
+ const type = tokens[at++];
72
+ if (type?.kind !== 'word') return undefined;
73
+ const name = type.value.toLowerCase();
74
+ if (typeof value === 'string' && name === 'text') return value;
75
+ if (typeof value === 'boolean' && name === 'boolean') return value;
76
+ // PostgreSQL renders negative constants as quoted numeric casts,
77
+ // e.g. ('-1'::integer)::numeric. Decode only finite numeric syntax.
78
+ if (typeof value === 'string' && ['numeric', 'integer', 'bigint', 'smallint'].includes(name)
79
+ && /^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/.test(value)) {
80
+ const number = Number(value);
81
+ if (!Number.isFinite(number) || (['integer', 'bigint', 'smallint'].includes(name)
82
+ && !Number.isSafeInteger(number))) return undefined;
83
+ return number;
84
+ }
85
+ if (typeof value === 'number' && name === 'numeric') return value;
86
+ if (typeof value === 'number' && ['integer', 'bigint', 'smallint'].includes(name)
87
+ && Number.isSafeInteger(value)) return value;
88
+ if (typeof value === 'number' && name === 'double' && take('PRECISION')) return value;
89
+ return undefined;
90
+ };
91
+ const expression = () => {
92
+ if (take('(')) {
93
+ const result = expression();
94
+ return result === null || !take(')') ? null : result;
95
+ }
96
+ const column = tokens[at++];
97
+ if (!column || !['identifier', 'word'].includes(column.kind)) return null;
98
+ const any = take('=');
99
+ // A singleton IN list is normalized to scalar equality by PostgreSQL.
100
+ if (any && !take('ANY')) {
101
+ const value = scalar();
102
+ return value === undefined ? null : { column: column.value, values: [value] };
103
+ }
104
+ if (any ? !(take('(') && take('ARRAY') && take('[')) : !(take('IN') && take('('))) return null;
105
+ const values = [];
106
+ do {
107
+ const value = scalar();
108
+ if (value === undefined) return null;
109
+ if (!values.includes(value)) values.push(value);
110
+ } while (take(','));
111
+ if (any ? !(take(']') && take(')')) : !take(')')) return null;
112
+ return { column: column.value, values };
113
+ };
114
+ const result = expression();
115
+ return at === tokens.length ? result : null;
116
+ }
117
+
118
+ /** SQLite retains the CREATE text, including inline and table CHECKs.
119
+ * @param {any[]} rows @returns {any[]} neutral constraints */
120
+ export function sqliteChecks(rows) {
121
+ const checks = [];
122
+ for (const row of rows) {
123
+ const tokens = sqlTokens(String(row.sql ?? ''));
124
+ // A column's inherited collation changes IN equality even when the
125
+ // CHECK itself names no collation. Refuse conservatively per table.
126
+ const collated = tokens.some((token, i) => token.kind === 'word'
127
+ && token.value.toUpperCase() === 'COLLATE'
128
+ && tokens[i + 1]?.value.toUpperCase() !== 'BINARY');
129
+ for (let i = 0; i < tokens.length; i++) {
130
+ if (tokens[i].kind !== 'word' || tokens[i].value.toUpperCase() !== 'CHECK'
131
+ || tokens[i + 1]?.value !== '(') continue;
132
+ const start = i + 2;
133
+ let depth = 1;
134
+ for (i = start; i < tokens.length; i++) {
135
+ if (tokens[i].kind !== 'symbol') continue;
136
+ if (tokens[i].value === '(') depth++;
137
+ if (tokens[i].value === ')' && --depth === 0) break;
138
+ }
139
+ checks.push({ name: `check_${checks.length + 1}`,
140
+ ...(collated ? null : enumOf(tokens.slice(start, i))) });
141
+ }
142
+ }
143
+ return checks;
144
+ }
145
+
146
+ /** PostgreSQL exposes each CHECK expression separately.
147
+ * @param {any[]} rows @returns {any[]} neutral constraints */
148
+ export function postgresChecks(rows) {
149
+ return rows.map((row) => ({ name: String(row.name),
150
+ ...(row.unsafe_collation ? null : enumOf(sqlTokens(String(row.expression ?? '')))) }));
151
+ }
@@ -0,0 +1,117 @@
1
+ //@ts-check
2
+ /** SQLite trigger lowering for bounded query predicates over old/new columns. */
3
+ import { DbCompileError } from '../errors.js';
4
+
5
+ /** @param {any} mapping @param {any} all @param {any} dialect @returns {any[]} */
6
+ export function sqliteInvariantTriggers(mapping, all, dialect) {
7
+ const q = dialect.quoteIdentifier;
8
+ const sl = dialect.stringLiteral;
9
+ const fail = (why) => { throw new DbCompileError('JD0005', `database invariant: ${why}`); };
10
+ const rules = mapping.invariants ?? [];
11
+ const columns = mapping.columns;
12
+ let domains = new Set();
13
+ const scalar = (value, op) => {
14
+ if (typeof value === 'string' && value.startsWith('$.')) {
15
+ if (value === '$.op') return sl(op);
16
+ const match = /^\$\.(old|new)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(value);
17
+ if (!match) return fail(`unsupported record path '${value}'`);
18
+ const column = columns.find((c) => c.name === match[2]);
19
+ if (!column || column.null === 'absent' || !['text', 'integer', 'number', 'boolean', 'date', 'datetime'].includes(column.codec)) return fail(`'${match[2]}' needs a scalar column codec`);
20
+ if ((op === 'insert' && match[1] === 'old') || (op === 'delete' && match[1] === 'new')) return fail('a property of an unavailable record is absent, not SQL NULL');
21
+ const ref = `${match[1].toUpperCase()}.${q(column.physical)}`;
22
+ const type = `typeof(${ref})`;
23
+ let valid = ['integer', 'number', 'boolean'].includes(column.codec)
24
+ ? column.codec === 'number' ? `${type} IN ('integer', 'real') AND ${ref} BETWEEN -9007199254740991 AND 9007199254740991`
25
+ : `${type} = 'integer' AND ${column.codec === 'boolean' ? `${ref} IN (0, 1)` : `${ref} BETWEEN -9007199254740991 AND 9007199254740991`}`
26
+ : `${type} = 'text'`;
27
+ if (column.null === 'null') valid = `${ref} IS NULL OR (${valid})`;
28
+ domains.add(`(${valid})`);
29
+ return `(${ref} COLLATE BINARY)`;
30
+ }
31
+ if (value === null) return 'NULL';
32
+ if (typeof value === 'string') return sl(value);
33
+ if (typeof value === 'boolean') return value ? '1' : '0';
34
+ if (typeof value === 'number' && Number.isFinite(value) && (!Number.isInteger(value) || Number.isSafeInteger(value))) return String(value);
35
+ if (value && typeof value === 'object' && Object.keys(value).length === 1 && Object.hasOwn(value, '$const')) {
36
+ const literal = value.$const;
37
+ return typeof literal === 'string' ? sl(literal) : scalar(literal, op);
38
+ }
39
+ return fail('only scalar literals and old/new property paths can be lowered');
40
+ };
41
+ const typeOf = (value) => {
42
+ if (typeof value === 'string' && value.startsWith('$.')) {
43
+ if (value === '$.op') return 'string';
44
+ const name = value.slice(value.indexOf('.', 2) + 1);
45
+ const column = columns.find((c) => c.name === name);
46
+ return column?.codec === 'boolean' ? 'boolean'
47
+ : ['integer', 'number'].includes(column?.codec) ? 'number' : 'string';
48
+ }
49
+ if (value && typeof value === 'object' && Object.hasOwn(value, '$const')) return value.$const === null ? 'null' : typeof value.$const;
50
+ return value === null ? 'null' : typeof value;
51
+ };
52
+ const predicate = (node, op) => {
53
+ if (typeof node === 'boolean') return node ? '1' : '0';
54
+ if (!node || typeof node !== 'object' || Array.isArray(node) || Object.keys(node).length !== 1) return fail('predicate must be a bounded query expression');
55
+ const [key, args] = Object.entries(node)[0];
56
+ if (key === '$and' || key === '$or') {
57
+ if (!Array.isArray(args) || !args.length) return fail('logical predicates require operands');
58
+ return `(${args.map((a) => predicate(a, op)).join(key === '$and' ? ' AND ' : ' OR ')})`;
59
+ }
60
+ if (key === '$not') return `(NOT (${predicate(args, op)}))`;
61
+ const operators = { $eq: 'IS', $ne: 'IS NOT', $lt: '<', $le: '<=', $gt: '>', $ge: '>=' };
62
+ if (!operators[key] || !Array.isArray(args) || args.length !== 2) return fail(`unsupported predicate '${key}'`);
63
+ const left = scalar(args[0], op), right = scalar(args[1], op);
64
+ const lt = typeOf(args[0]), rt = typeOf(args[1]);
65
+ if (lt !== rt && lt !== 'null' && rt !== 'null') return key === '$ne' ? '1' : '0';
66
+ const compare = `(${left} ${operators[key]} ${right})`;
67
+ return key === '$eq' || key === '$ne' ? compare : `COALESCE(${compare}, 0)`;
68
+ };
69
+ const changed = columns.filter((c) => c.name !== mapping.version && !c.generated)
70
+ .map((c) => `OLD.${q(c.physical)} IS NOT NEW.${q(c.physical)}`).join(' OR ') || '0';
71
+ const out = [];
72
+ const groups = new Map();
73
+ const add = (op, stage, rule, when, body) => {
74
+ const key = `${op}_${stage}`;
75
+ if (!groups.has(key)) groups.set(key, { op, stage, rules: [], bodies: [] });
76
+ const group = groups.get(key);
77
+ group.rules.push(rule); group.bodies.push(`${body}${when ? ` WHERE ${when}` : ''};`);
78
+ };
79
+ for (const rule of rules) {
80
+ if (rule.enforcement !== 'database') continue;
81
+ if (mapping.document !== false || mapping.kind === 'view') fail('database rules require a writable explicit column layout');
82
+ for (const op of rule.on) {
83
+ domains = new Set();
84
+ const assertion = predicate(rule.assert, op);
85
+ if (rule.audit !== undefined) {
86
+ const target = all.entities[rule.audit.entity];
87
+ if (!target || target.document !== false || target.kind === 'view') fail('audit target must be a mapped application table');
88
+ if (target === mapping || (target.invariants ?? []).some((r) => r.audit)) fail('recursive or chained audit effects are refused');
89
+ const names = [], values = [];
90
+ for (const [member, expression] of Object.entries(rule.audit.values)) {
91
+ const column = target.columns.find((c) => c.name === member);
92
+ if (!column || column.generated || !['text', 'integer', 'number', 'boolean'].includes(column.codec)) fail(`audit member '${member}' needs a writable scalar codec`);
93
+ const expected = column.codec === 'text' ? 'string' : column.codec === 'boolean' ? 'boolean' : 'number';
94
+ const actual = typeOf(expression);
95
+ if (actual !== expected && actual !== 'null') fail(`audit member '${member}' needs a matching scalar type`);
96
+ const value = scalar(expression, op);
97
+ if (column.null === 'reject') domains.add(`(${value} IS NOT NULL)`);
98
+ names.push(q(column.physical)); values.push(value);
99
+ }
100
+ if (!names.length) fail('audit values cannot be empty');
101
+ add(op, 'AFTER', rule.name, op === 'update' ? `(${changed})` : '',
102
+ `INSERT INTO ${q(target.table)} (${names.join(', ')}) SELECT ${values.join(', ')}`);
103
+ }
104
+ const when = `${op === 'update' ? `(${changed}) AND ` : ''}(${[...domains, assertion].join(' AND ')}) IS NOT TRUE`;
105
+ add(op, 'BEFORE', rule.name, when, `SELECT RAISE(ABORT, ${sl(`jaren invariant:${rule.name}`)})`);
106
+ }
107
+ }
108
+ for (const op of ['insert', 'update', 'delete']) {
109
+ const checks = groups.get(`${op}_BEFORE`);
110
+ if (!checks) continue;
111
+ const bodies = [...checks.bodies, ...(groups.get(`${op}_AFTER`)?.bodies ?? [])];
112
+ const name = `_jaren_rule_${mapping.table.length}_${mapping.table}_${op}`;
113
+ out.push({ type: 'trigger', name, owner: mapping.table, rule: checks.rules.join(','),
114
+ sql: `CREATE TRIGGER ${q(name)} AFTER ${op.toUpperCase()} ON ${q(mapping.table)} BEGIN ${bodies.join(' ')} END` });
115
+ }
116
+ return out;
117
+ }
@@ -39,6 +39,7 @@
39
39
 
40
40
  import { createDialect } from '../dialect.js';
41
41
  import { readExpression } from './expression-read.js';
42
+ import { postgresChecks } from './check-read.js';
42
43
 
43
44
  /** PostgreSQL truncates an identifier past this many BYTES, silently
44
45
  * and with only a notice — so two long generated names would collide
@@ -519,6 +520,7 @@ export function postgresDialect(options = undefined) {
519
520
  memberPathOf,
520
521
  expressionOf,
521
522
  // the catalog already answers one row per generated column
523
+ readChecks: postgresChecks,
522
524
  readGenerated: (rows) => rows.map((row) => ({
523
525
  name: String(row.name), expression: String(row.expression ?? ''),
524
526
  })),
@@ -561,8 +563,11 @@ export function postgresDialect(options = undefined) {
561
563
  // fact the shape check reads beside the name and the type
562
564
  columns: (table) =>
563
565
  'SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type, '
564
- + "CASE WHEN a.attgenerated <> '' THEN 1 ELSE 0 END AS hidden "
566
+ + "CASE WHEN a.attgenerated <> '' THEN 1 ELSE 0 END AS hidden, "
567
+ + 'CASE WHEN a.attnotnull THEN 1 ELSE 0 END AS not_null, '
568
+ + 'pg_get_expr(d.adbin, d.adrelid) AS default_value '
565
569
  + 'FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid '
570
+ + 'LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum '
566
571
  + 'JOIN pg_namespace n ON n.oid = c.relnamespace '
567
572
  + `WHERE c.relname = ${stringLiteral(table)} AND ${inNamespace} `
568
573
  + 'AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum',
@@ -571,7 +576,8 @@ export function postgresDialect(options = undefined) {
571
576
  // key's is the engine's own
572
577
  indexes: (table) =>
573
578
  'SELECT ci.relname AS name, CASE WHEN i.indisunique THEN 1 ELSE 0 END AS uniq, '
574
- + "CASE WHEN i.indisprimary THEN 'pk' ELSE 'c' END AS origin "
579
+ + "CASE WHEN i.indisprimary THEN 'pk' ELSE 'c' END AS origin, "
580
+ + 'CASE WHEN i.indpred IS NULL THEN 0 ELSE 1 END AS partial '
575
581
  + 'FROM pg_index i JOIN pg_class ci ON ci.oid = i.indexrelid '
576
582
  + 'JOIN pg_class ct ON ct.oid = i.indrelid '
577
583
  + 'JOIN pg_namespace n ON n.oid = ct.relnamespace '
@@ -584,7 +590,7 @@ export function postgresDialect(options = undefined) {
584
590
  + 'JOIN pg_class ci ON ci.oid = i.indexrelid '
585
591
  + 'JOIN pg_namespace n ON n.oid = ci.relnamespace '
586
592
  + 'CROSS JOIN LATERAL unnest(i.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord) '
587
- + 'JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum '
593
+ + 'LEFT JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum '
588
594
  + `WHERE ci.relname = ${stringLiteral(index)} AND ${inNamespace} ORDER BY k.ord`,
589
595
  // every table this store might own; a view is reported rather
590
596
  // than derived, and the engine's own schemas are never in scope
@@ -594,6 +600,15 @@ export function postgresDialect(options = undefined) {
594
600
  + 'JOIN pg_namespace n ON n.oid = c.relnamespace '
595
601
  + `WHERE c.relkind IN ('r', 'p', 'v', 'm') AND ${inNamespace} `
596
602
  + 'ORDER BY type, c.relname',
603
+ objects: () =>
604
+ "SELECT 'trigger' AS type, t.tgname AS name, c.relname AS owner, "
605
+ + 'pg_get_triggerdef(t.oid) AS sql FROM pg_trigger t '
606
+ + 'JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace '
607
+ + `WHERE NOT t.tgisinternal AND ${inNamespace} UNION ALL `
608
+ + "SELECT 'index', ci.relname, c.relname, pg_get_indexdef(i.indexrelid) "
609
+ + 'FROM pg_index i JOIN pg_class c ON c.oid = i.indrelid '
610
+ + 'JOIN pg_class ci ON ci.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace '
611
+ + `WHERE ${inNamespace} ORDER BY type, name`,
597
612
  generated: (table) =>
598
613
  'SELECT a.attname AS name, pg_get_expr(d.adbin, d.adrelid) AS expression '
599
614
  + 'FROM pg_attrdef d '
@@ -602,13 +617,22 @@ export function postgresDialect(options = undefined) {
602
617
  + 'JOIN pg_namespace n ON n.oid = c.relnamespace '
603
618
  + `WHERE c.relname = ${stringLiteral(table)} AND ${inNamespace} `
604
619
  + "AND a.attgenerated <> '' ORDER BY a.attnum",
620
+ checks: (table) =>
621
+ 'SELECT c.conname AS name, pg_get_expr(c.conbin, c.conrelid) AS expression, '
622
+ + 'EXISTS (SELECT 1 FROM pg_attribute ca JOIN pg_collation co ON co.oid = ca.attcollation '
623
+ + 'WHERE ca.attrelid = c.conrelid AND ca.attnum = ANY(c.conkey) '
624
+ + 'AND NOT co.collisdeterministic) AS unsafe_collation '
625
+ + 'FROM pg_constraint c JOIN pg_class ct ON ct.oid = c.conrelid '
626
+ + 'JOIN pg_namespace n ON n.oid = ct.relnamespace '
627
+ + `WHERE c.contype = 'c' AND ct.relname = ${stringLiteral(table)} AND ${inNamespace} `
628
+ + 'ORDER BY c.conname',
605
629
  foreignKeyList: (table) => {
606
630
  const action = (column) => `CASE ${column} `
607
631
  + "WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' "
608
632
  + "WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' ELSE 'NO ACTION' END";
609
633
  return 'SELECT tt.relname AS target, sa.attname AS source_column, '
610
634
  + `ta.attname AS target_column, ${action('c.confdeltype')} AS on_delete, `
611
- + `${action('c.confupdtype')} AS on_update, k.ord - 1 AS seq `
635
+ + `${action('c.confupdtype')} AS on_update, c.conname AS id, k.ord - 1 AS seq `
612
636
  + 'FROM pg_constraint c JOIN pg_class ct ON ct.oid = c.conrelid '
613
637
  + 'JOIN pg_namespace n ON n.oid = ct.relnamespace '
614
638
  + 'JOIN pg_class tt ON tt.oid = c.confrelid '
@@ -11,6 +11,8 @@
11
11
  import { createDialect } from '../dialect.js';
12
12
  import { rtreeDdl } from './rtree-ddl.js';
13
13
  import { readExpression } from './expression-read.js';
14
+ import { sqliteInvariantTriggers } from './invariant-sql.js';
15
+ import { sqliteChecks } from './check-read.js';
14
16
 
15
17
  /** @param {string} s */
16
18
  function quoteIdentifier(s) {
@@ -427,6 +429,17 @@ export const sqliteDialect = createDialect({
427
429
  : `PRAGMA integrity_check(${pragmaValue(limit)})`),
428
430
  optimize: () => 'PRAGMA optimize',
429
431
  },
432
+ invariantTriggers: sqliteInvariantTriggers,
433
+ // The invalid path is evaluated only on overflow and carries a distinct
434
+ // marker through SQLite's error boundary, before any source row is written.
435
+ codepoint: (value) => `(${value} COLLATE BINARY)`,
436
+ mutationRowGuard: (count, limit) => `CASE WHEN ${count} > ${limit} `
437
+ + "THEN json_extract('{}', 'jaren-mutation-row-bound') ELSE 1 END",
438
+ physicalRead: (codec, sql) => codec === 'bigint' ? `CAST(${sql} AS TEXT)`
439
+ : codec === 'blob-hex' ? `CASE WHEN ${sql} IS NULL THEN NULL ELSE hex(${sql}) END` : sql,
440
+ physicalTypeMatches: (codec, type) => (codec === 'blob-hex' ? /BLOB/
441
+ : ['integer', 'bigint', 'boolean', 'epoch-ms'].includes(codec) ? /INT/
442
+ : codec === 'number' ? /REAL|FLOA|DOUB/ : /TEXT|CHAR|CLOB/).test(type),
430
443
  introspect: {
431
444
  version: () => 'SELECT sqlite_version() AS version',
432
445
  // the read-back of one configuration pragma: `PRAGMA name` answers
@@ -437,16 +450,17 @@ export const sqliteDialect = createDialect({
437
450
  tableExists: () =>
438
451
  "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?",
439
452
  columns: (table) =>
440
- `SELECT name, type, hidden FROM pragma_table_xinfo(${stringLiteral(table)})`,
453
+ `SELECT name, type, hidden, pk, "notnull" AS not_null, dflt_value AS default_value `
454
+ + `FROM pragma_table_xinfo(${stringLiteral(table)}) ORDER BY cid`,
441
455
  indexes: (table) =>
442
- `SELECT name, "unique" AS uniq, origin FROM pragma_index_list(${stringLiteral(table)})`,
456
+ `SELECT name, "unique" AS uniq, origin, partial FROM pragma_index_list(${stringLiteral(table)})`,
443
457
  indexColumns: (index) =>
444
458
  `SELECT name FROM pragma_index_info(${stringLiteral(index)})`,
445
459
  foreignKeysOn: () => 'SELECT foreign_keys AS enabled FROM pragma_foreign_keys',
446
460
  dataVersion: () => 'SELECT data_version AS v FROM pragma_data_version',
447
461
  foreignKeyList: (table) =>
448
462
  `SELECT "table" AS target, "from" AS source_column, "to" AS target_column, `
449
- + `on_delete, on_update, seq FROM pragma_foreign_key_list(${stringLiteral(table)}) `
463
+ + `on_delete, on_update, id, seq, match FROM pragma_foreign_key_list(${stringLiteral(table)}) `
450
464
  + 'ORDER BY id, seq',
451
465
  // Every schema object one table owns, with the CREATE text SQLite
452
466
  // stored verbatim. That text is where the physical facts no pragma
@@ -467,10 +481,16 @@ export const sqliteDialect = createDialect({
467
481
  // the CREATE text is where a generated column's expression lives
468
482
  generated: (table) =>
469
483
  `SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ${stringLiteral(table)}`,
484
+ checks: (table) =>
485
+ `SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ${stringLiteral(table)}`,
470
486
  // the whole declared schema, for shape-equality comparison after a
471
487
  // rebuild: every object that carries SQL text, in a stable order
472
488
  schemaDump: () =>
473
489
  "SELECT type, name, tbl_name AS owner, sql FROM sqlite_schema "
474
490
  + "WHERE sql IS NOT NULL ORDER BY type, name",
491
+ objects: () =>
492
+ 'SELECT type, name, tbl_name AS owner, sql FROM sqlite_schema '
493
+ + "WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name",
475
494
  },
495
+ readChecks: sqliteChecks,
476
496
  });
package/src/driver.js CHANGED
@@ -822,6 +822,7 @@ export function finishConnection(raw, dialect, synchronous, capabilities, queueT
822
822
  // closed exactly as a statement is
823
823
  backup: capabilities.backup === true
824
824
  ? Object.freeze({
825
+ snapshot: raw.backup.snapshot === true,
825
826
  copy: (path, options) => { requireOpen(); return raw.backup.copy(path, options); },
826
827
  rename: (from, to) => { requireOpen(); return raw.backup.rename(from, to); },
827
828
  remove: (path) => { requireOpen(); return raw.backup.remove(path); },