@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.
- package/ARCHITECTURE.md +20 -0
- package/README.md +25 -0
- package/docs/HOSTS.md +17 -0
- package/docs/JOBS-FORMAT.md +26 -0
- package/docs/LIVE-FORMAT.md +4 -0
- package/docs/MIGRATION-FORMAT.md +34 -0
- package/docs/MODEL-FORMAT.md +132 -1
- package/docs/NATIVE-PLANS.md +111 -0
- package/docs/SEARCH.md +55 -0
- package/package.json +8 -4
- package/schemas/jaren-migration.draft-07.schema.json +54 -5
- package/schemas/jaren-migration.schema.json +49 -0
- package/schemas/jaren-model.authoring.schema.json +360 -0
- package/schemas/jaren-model.draft-07.schema.json +128 -0
- package/schemas/jaren-model.schema.json +128 -0
- package/src/algebra.js +17 -1
- package/src/backup.js +12 -7
- package/src/cursor.js +27 -4
- package/src/dag-job.js +2 -1
- package/src/ddl.js +13 -0
- package/src/dialect.js +10 -0
- package/src/dialects/check-read.js +3 -3
- package/src/dialects/invariant-sql.js +117 -0
- package/src/dialects/postgres.js +14 -2
- package/src/dialects/sqlite.js +18 -2
- package/src/driver.js +1 -0
- package/src/drivers/bun.js +22 -4
- package/src/emit.js +80 -13
- package/src/entity.js +98 -41
- package/src/errors.js +8 -0
- package/src/graph.js +8 -1
- package/src/index.js +3 -0
- package/src/introspect.js +44 -7
- package/src/invariants.js +45 -0
- package/src/jobs.js +39 -6
- package/src/live.js +4 -1
- package/src/migrate.js +136 -22
- package/src/model.js +12 -0
- package/src/mutation.js +165 -0
- package/src/physical.js +147 -0
- package/src/plan.js +114 -20
- package/src/query.js +144 -70
- package/src/search.js +144 -0
- package/src/sql.js +60 -0
- package/src/store.js +49 -13
- package/src/tracker.js +63 -39
- package/src/window.js +1 -0
- package/types/index.d.ts +58 -3
- package/types/search.d.ts +20 -0
- 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
|
-
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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`),
|
|
29
|
-
*
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
+
}
|
package/src/dialects/postgres.js
CHANGED
|
@@ -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 '
|
package/src/dialects/sqlite.js
CHANGED
|
@@ -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
|
|
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); },
|
package/src/drivers/bun.js
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
:
|
|
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
|
/**
|