@jarenjs/db 0.75.0 → 0.83.3

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 (50) hide show
  1. package/ARCHITECTURE.md +20 -0
  2. package/README.md +25 -0
  3. package/docs/HOSTS.md +17 -0
  4. package/docs/JOBS-FORMAT.md +26 -0
  5. package/docs/LIVE-FORMAT.md +4 -0
  6. package/docs/MIGRATION-FORMAT.md +34 -0
  7. package/docs/MODEL-FORMAT.md +132 -1
  8. package/docs/NATIVE-PLANS.md +111 -0
  9. package/docs/SEARCH.md +55 -0
  10. package/package.json +8 -4
  11. package/schemas/jaren-migration.draft-07.schema.json +54 -5
  12. package/schemas/jaren-migration.schema.json +49 -0
  13. package/schemas/jaren-model.authoring.schema.json +360 -0
  14. package/schemas/jaren-model.draft-07.schema.json +128 -0
  15. package/schemas/jaren-model.schema.json +128 -0
  16. package/src/algebra.js +17 -1
  17. package/src/backup.js +12 -7
  18. package/src/cursor.js +27 -4
  19. package/src/dag-job.js +2 -1
  20. package/src/ddl.js +13 -0
  21. package/src/dialect.js +10 -0
  22. package/src/dialects/check-read.js +3 -3
  23. package/src/dialects/invariant-sql.js +117 -0
  24. package/src/dialects/postgres.js +14 -2
  25. package/src/dialects/sqlite.js +18 -2
  26. package/src/driver.js +1 -0
  27. package/src/drivers/bun.js +22 -4
  28. package/src/emit.js +80 -13
  29. package/src/entity.js +98 -41
  30. package/src/errors.js +8 -0
  31. package/src/graph.js +8 -1
  32. package/src/index.js +3 -0
  33. package/src/introspect.js +44 -7
  34. package/src/invariants.js +45 -0
  35. package/src/jobs.js +39 -6
  36. package/src/live.js +4 -1
  37. package/src/migrate.js +136 -22
  38. package/src/model.js +12 -0
  39. package/src/mutation.js +165 -0
  40. package/src/physical.js +147 -0
  41. package/src/plan.js +114 -20
  42. package/src/query.js +144 -70
  43. package/src/search.js +144 -0
  44. package/src/sql.js +60 -0
  45. package/src/store.js +49 -13
  46. package/src/tracker.js +63 -39
  47. package/src/window.js +1 -0
  48. package/types/index.d.ts +58 -3
  49. package/types/search.d.ts +20 -0
  50. package/types/typed.d.ts +1 -0
package/src/algebra.js CHANGED
@@ -335,7 +335,23 @@ export function planOrder(plan) {
335
335
  if (plan === null || plan === undefined) return null;
336
336
  if (plan.alg === 'entity-select' || plan.alg === 'entity-join') {
337
337
  if (plan.aggregate !== null) return null;
338
- return effectiveOrder(plan.order, { bindings: plan.bindings.map((b) => b.name) });
338
+ const bindings = plan.bindings;
339
+ const ties = bindings.flatMap((binding) => binding.keys === undefined
340
+ ? [identityTerm(binding.name)] : binding.keys.map((column) => ({
341
+ source: 'column', binding: binding.name, column, path: null,
342
+ desc: false, nullsFirst: true, tieBreaker: true,
343
+ })));
344
+ if (plan.group) {
345
+ const group = plan.group;
346
+ const terms = group.order === 'first-seen' ? [] : group.order.map((term) => ({
347
+ source: 'group', binding: bindings[0].name,
348
+ column: term.aggregate === undefined ? group.keys[term.index].as : `a${term.aggregate}`,
349
+ path: null, desc: term.desc, nullsFirst: term.nullsFirst, tieBreaker: false,
350
+ }));
351
+ return [...terms, ...ties.map((term) => ({ ...term, source: 'group',
352
+ column: term.column, tieBreaker: group.order !== 'first-seen' }))];
353
+ }
354
+ return [...(plan.order ?? []).map((term) => declaredTerm(term, term.binding)), ...ties];
339
355
  }
340
356
  if (plan.aggregate !== null || plan.rank !== null) return null;
341
357
  if (plan.bucket !== null) {
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/dialect.js CHANGED
@@ -185,6 +185,11 @@ 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[],
188
193
  * readChecks?: (rows: any[]) => { name: string, column?: string, values?: any[] }[],
189
194
  * introspect: { version: () => string, compileOptions: () => string,
190
195
  * pragma: (name: string) => string,
@@ -642,6 +647,11 @@ export function createDialect(spec) {
642
647
  */
643
648
  readGenerated: spec.readGenerated,
644
649
  readChecks: spec.readChecks,
650
+ physicalRead: spec.physicalRead,
651
+ mutationRowGuard: spec.mutationRowGuard,
652
+ codepoint: spec.codepoint,
653
+ physicalTypeMatches: spec.physicalTypeMatches,
654
+ invariantTriggers: spec.invariantTriggers,
645
655
  explainQuery: spec.explainQuery,
646
656
  /** The plan narrative, one line per row the engine answered. */
647
657
  explainLines: spec.explainLines,
@@ -3,7 +3,7 @@
3
3
 
4
4
  /** Tokenize catalog SQL without treating quoted text or comments as syntax.
5
5
  * @param {string} sql @returns {{ kind: string, value: string }[]} */
6
- function tokensOf(sql) {
6
+ export function sqlTokens(sql) {
7
7
  const tokens = [];
8
8
  for (let i = 0; i < sql.length;) {
9
9
  const c = sql[i];
@@ -120,7 +120,7 @@ function enumOf(tokens) {
120
120
  export function sqliteChecks(rows) {
121
121
  const checks = [];
122
122
  for (const row of rows) {
123
- const tokens = tokensOf(String(row.sql ?? ''));
123
+ const tokens = sqlTokens(String(row.sql ?? ''));
124
124
  // A column's inherited collation changes IN equality even when the
125
125
  // CHECK itself names no collation. Refuse conservatively per table.
126
126
  const collated = tokens.some((token, i) => token.kind === 'word'
@@ -147,5 +147,5 @@ export function sqliteChecks(rows) {
147
147
  * @param {any[]} rows @returns {any[]} neutral constraints */
148
148
  export function postgresChecks(rows) {
149
149
  return rows.map((row) => ({ name: String(row.name),
150
- ...(row.unsafe_collation ? null : enumOf(tokensOf(String(row.expression ?? '')))) }));
150
+ ...(row.unsafe_collation ? null : enumOf(sqlTokens(String(row.expression ?? '')))) }));
151
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
+ }
@@ -563,8 +563,11 @@ export function postgresDialect(options = undefined) {
563
563
  // fact the shape check reads beside the name and the type
564
564
  columns: (table) =>
565
565
  'SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type, '
566
- + "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 '
567
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 '
568
571
  + 'JOIN pg_namespace n ON n.oid = c.relnamespace '
569
572
  + `WHERE c.relname = ${stringLiteral(table)} AND ${inNamespace} `
570
573
  + 'AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum',
@@ -597,6 +600,15 @@ export function postgresDialect(options = undefined) {
597
600
  + 'JOIN pg_namespace n ON n.oid = c.relnamespace '
598
601
  + `WHERE c.relkind IN ('r', 'p', 'v', 'm') AND ${inNamespace} `
599
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`,
600
612
  generated: (table) =>
601
613
  'SELECT a.attname AS name, pg_get_expr(d.adbin, d.adrelid) AS expression '
602
614
  + 'FROM pg_attrdef d '
@@ -620,7 +632,7 @@ export function postgresDialect(options = undefined) {
620
632
  + "WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' ELSE 'NO ACTION' END";
621
633
  return 'SELECT tt.relname AS target, sa.attname AS source_column, '
622
634
  + `ta.attname AS target_column, ${action('c.confdeltype')} AS on_delete, `
623
- + `${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 `
624
636
  + 'FROM pg_constraint c JOIN pg_class ct ON ct.oid = c.conrelid '
625
637
  + 'JOIN pg_namespace n ON n.oid = ct.relnamespace '
626
638
  + 'JOIN pg_class tt ON tt.oid = c.confrelid '
@@ -11,6 +11,7 @@
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';
14
15
  import { sqliteChecks } from './check-read.js';
15
16
 
16
17
  /** @param {string} s */
@@ -428,6 +429,17 @@ export const sqliteDialect = createDialect({
428
429
  : `PRAGMA integrity_check(${pragmaValue(limit)})`),
429
430
  optimize: () => 'PRAGMA optimize',
430
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),
431
443
  introspect: {
432
444
  version: () => 'SELECT sqlite_version() AS version',
433
445
  // the read-back of one configuration pragma: `PRAGMA name` answers
@@ -438,7 +450,8 @@ export const sqliteDialect = createDialect({
438
450
  tableExists: () =>
439
451
  "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?",
440
452
  columns: (table) =>
441
- `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`,
442
455
  indexes: (table) =>
443
456
  `SELECT name, "unique" AS uniq, origin, partial FROM pragma_index_list(${stringLiteral(table)})`,
444
457
  indexColumns: (index) =>
@@ -447,7 +460,7 @@ export const sqliteDialect = createDialect({
447
460
  dataVersion: () => 'SELECT data_version AS v FROM pragma_data_version',
448
461
  foreignKeyList: (table) =>
449
462
  `SELECT "table" AS target, "from" AS source_column, "to" AS target_column, `
450
- + `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)}) `
451
464
  + 'ORDER BY id, seq',
452
465
  // Every schema object one table owns, with the CREATE text SQLite
453
466
  // stored verbatim. That text is where the physical facts no pragma
@@ -475,6 +488,9 @@ export const sqliteDialect = createDialect({
475
488
  schemaDump: () =>
476
489
  "SELECT type, name, tbl_name AS owner, sql FROM sqlite_schema "
477
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",
478
494
  },
479
495
  readChecks: sqliteChecks,
480
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); },
@@ -27,7 +27,7 @@ import { PRAGMA_NAMES } from '../pragmas.js';
27
27
  * with its shape) into a probed connection. Exported so the adapter is
28
28
  * exercisable without the builtin.
29
29
  * @param {any} db - A Bun `Database`-shaped database
30
- * @param {{ queueTimeout?: number }} [options]
30
+ * @param {{ queueTimeout?: number, backup?: any }} [options]
31
31
  * @returns {any} a Connection, or a promise of one
32
32
  */
33
33
  export function adaptBunDatabase(db, options) {
@@ -38,6 +38,7 @@ export function adaptBunDatabase(db, options) {
38
38
  const statements = new Set();
39
39
  const collected = new FinalizationRegistry((ref) => statements.delete(ref));
40
40
  const raw = {
41
+ ...(options?.backup ? { backup: options.backup } : {}),
41
42
  /** @param {string} sql */
42
43
  exec: (sql) => db.run(sql),
43
44
  /** @param {string} sql */
@@ -91,6 +92,7 @@ export function adaptBunDatabase(db, options) {
91
92
  synchronous: true,
92
93
  queueTimeout: options?.queueTimeout,
93
94
  declared: {
95
+ backup: options?.backup !== undefined,
94
96
  sessions: false,
95
97
  userFunctions: false,
96
98
  deterministicIndexableFunctions: false,
@@ -112,9 +114,25 @@ export function adaptBunDatabase(db, options) {
112
114
  * @returns {any}
113
115
  */
114
116
  export function fromBunModule(mod, path, options) {
115
- return adaptBunDatabase(options?.readOnly === true
116
- ? new mod.Database(path, { readonly: true })
117
- : new mod.Database(path), options);
117
+ const db = options?.readOnly === true ? new mod.Database(path, { readonly: true }) : new mod.Database(path);
118
+ const backup = typeof db.serialize !== 'function' ? undefined : {
119
+ snapshot: true,
120
+ copy: async (target, copyOptions) => {
121
+ const bytes = db.serialize();
122
+ const pageSize = ((bytes[16] << 8) | bytes[17]) || 65536;
123
+ const pages = bytes.length / (pageSize === 1 ? 65536 : pageSize);
124
+ copyOptions?.progress?.({ totalPages: pages, remainingPages: pages });
125
+ const fs = await import('node:fs/promises');
126
+ const file = await fs.open(target, 'wx');
127
+ try { await file.writeFile(bytes); await file.sync(); }
128
+ finally { await file.close(); }
129
+ copyOptions?.progress?.({ totalPages: pages, remainingPages: 0 });
130
+ return pages;
131
+ },
132
+ rename: (from, to) => import('node:fs/promises').then((fs) => fs.rename(from, to)),
133
+ remove: (target) => import('node:fs/promises').then((fs) => fs.rm(target, { force: true })),
134
+ };
135
+ return adaptBunDatabase(db, { ...options, ...(backup ? { backup } : {}) });
118
136
  }
119
137
 
120
138
  /**