@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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Persistence rules compile through the existing query evaluator. SQL lowering
|
|
3
|
+
* accepts a bounded scalar subset and reports its writer population explicitly. */
|
|
4
|
+
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
5
|
+
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
/** @param {any} rules @param {string} path @returns {any[]} */
|
|
8
|
+
export function normalizeInvariants(rules, path) {
|
|
9
|
+
if (rules === undefined) return [];
|
|
10
|
+
if (!Array.isArray(rules)) throw new DbCompileError('JD0005', 'invariants must be an array', path);
|
|
11
|
+
const names = new Set();
|
|
12
|
+
return rules.map((rule) => {
|
|
13
|
+
const fail = (reason) => { throw new DbCompileError('JD0005', reason, path); };
|
|
14
|
+
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) fail('an invariant must be an object');
|
|
15
|
+
for (const key of Object.keys(rule))
|
|
16
|
+
if (!['name', 'on', 'assert', 'enforcement', 'audit'].includes(key)) fail(`unknown invariant member '${key}'`);
|
|
17
|
+
if (typeof rule.name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(rule.name) || names.has(rule.name)) fail('invariant names must be distinct identifiers');
|
|
18
|
+
names.add(rule.name);
|
|
19
|
+
if (!['database', 'store'].includes(rule.enforcement)) fail('invariant enforcement must be database or store');
|
|
20
|
+
if (!Array.isArray(rule.on) || !rule.on.length || new Set(rule.on).size !== rule.on.length
|
|
21
|
+
|| rule.on.some((op) => !['insert', 'update', 'delete'].includes(op))) fail('invariant on must name distinct insert/update/delete operations');
|
|
22
|
+
if (rule.assert === undefined) fail('invariant assert is required');
|
|
23
|
+
if (rule.audit !== undefined && (rule.enforcement !== 'database' || !rule.audit
|
|
24
|
+
|| typeof rule.audit.entity !== 'string' || !rule.audit.values || typeof rule.audit.values !== 'object'
|
|
25
|
+
|| Array.isArray(rule.audit.values) || Object.keys(rule.audit).some((k) => !['entity', 'values'].includes(k)))) fail('audit is a database effect with an entity and values');
|
|
26
|
+
return { ...rule, evaluate: compileJsonQuery(rule.assert) };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {string} name @returns {DbRuntimeError} */
|
|
31
|
+
export function invariantFailure(name) {
|
|
32
|
+
const error = new DbRuntimeError('JD2096', `persistence invariant '${name}' rejected the mutation`);
|
|
33
|
+
error.class = 'constraint';
|
|
34
|
+
error.retryable = false;
|
|
35
|
+
return error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {any[]} rules @param {string} op @param {any} before @param {any} after */
|
|
39
|
+
export function checkInvariants(rules, op, before, after) {
|
|
40
|
+
for (const rule of rules) {
|
|
41
|
+
if (rule.enforcement === 'store' && rule.on.includes(op)
|
|
42
|
+
&& rule.evaluate({ old: before ?? null, new: after ?? null, op }) !== true)
|
|
43
|
+
throw invariantFailure(rule.name);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/jobs.js
CHANGED
|
@@ -194,7 +194,7 @@ function isLease(value) {
|
|
|
194
194
|
/**
|
|
195
195
|
* The queue engine over one open connection.
|
|
196
196
|
* @param {{ connection: any, now?: () => number,
|
|
197
|
-
* random?: () => number,
|
|
197
|
+
* random?: () => number, adopt?: boolean,
|
|
198
198
|
* gate?: (fn: () => any, what?: string, signal?: AbortSignal) => any,
|
|
199
199
|
* bracket?: (fn: () => any) => any,
|
|
200
200
|
* defaults?: Partial<typeof JOB_DEFAULTS>,
|
|
@@ -276,9 +276,22 @@ export function createJobEngine(options) {
|
|
|
276
276
|
|
|
277
277
|
// the tables are created here, or refused here: a read-only store
|
|
278
278
|
// leaked the driver's "attempt to write a readonly database"
|
|
279
|
-
|
|
279
|
+
// Adoption accepts the current engine-owned schema without provisioning or
|
|
280
|
+
// upgrading it. The DDL remains the single schema declaration; differently
|
|
281
|
+
// shaped historical queues need an explicit upgrade before adoption.
|
|
282
|
+
const verifyExisting = () => chain(connection.prepare(connection.dialect.introspect.objects()), (statement) => chain(statement.all([]), (objects) => {
|
|
283
|
+
const normalize = (sql) => sql.replace(/'[^']*'|\bIF\s+NOT\s+EXISTS\b|[\s";]/g, (part) => part.startsWith("'") ? part : '');
|
|
284
|
+
for (const sql of CREATE_JOBS.split(';').filter((part) => part.trim())) {
|
|
285
|
+
const name = sql.match(/(?:TABLE|INDEX) IF NOT EXISTS "([^"]+)"/)[1];
|
|
286
|
+
const object = objects.find((value) => value.name === name);
|
|
287
|
+
if (!object?.sql || normalize(object.sql) !== normalize(sql))
|
|
288
|
+
throw new Error(`existing job object ${name} needs an explicit schema migration`);
|
|
289
|
+
}
|
|
290
|
+
}));
|
|
291
|
+
const ready = attempt(() => bracket(() => options.adopt === true
|
|
292
|
+
? verifyExisting() : chain(connection.exec(CREATE_JOBS), upgradeColumns)),
|
|
280
293
|
(error) => new DbCompileError('JD0002',
|
|
281
|
-
`the job tables could not be
|
|
294
|
+
`the job tables could not be opened (${error?.message ?? String(error)}) — `
|
|
282
295
|
+ 'a read-only store creates nothing; open it read-write once, or without jobs',
|
|
283
296
|
'/jobs', error));
|
|
284
297
|
|
|
@@ -436,6 +449,21 @@ export function createJobEngine(options) {
|
|
|
436
449
|
{ docPath: '/jobs', collection: JOBS_TABLE });
|
|
437
450
|
};
|
|
438
451
|
|
|
452
|
+
/** Verify current execution authority without renewing or writing a checkpoint.
|
|
453
|
+
* Inside tx.jobs this check and mapped record writes share the same lock.
|
|
454
|
+
* @param {any} lease */
|
|
455
|
+
const assertLease = (lease) => {
|
|
456
|
+
const misuse = requireLease(lease, 'assertLease()');
|
|
457
|
+
if (misuse !== null) throw misuse;
|
|
458
|
+
return chain(prepared('assertLease', `SELECT id FROM "${JOBS_TABLE}" WHERE id=? AND ${FENCE}`)
|
|
459
|
+
.get([lease.jobId, lease.token, now()]), (row) => {
|
|
460
|
+
if (row !== undefined) return true;
|
|
461
|
+
return chain(refuseSettlement(lease, 'assertLease()'), (error) => {
|
|
462
|
+
throw error ?? new DbRuntimeError('JD2065', 'the job is already settled', { docPath: '/jobs' });
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
};
|
|
466
|
+
|
|
439
467
|
/** §4: the jittered exponential backoff. */
|
|
440
468
|
const backoffOf = (attempts, workerDefaults) => {
|
|
441
469
|
const base = workerDefaults?.backoffBase ?? defaults.backoffBase;
|
|
@@ -849,10 +877,11 @@ export function createJobEngine(options) {
|
|
|
849
877
|
* @param {{ handlers: Record<string, Function>, concurrency?: number,
|
|
850
878
|
* pollInterval?: number, leaseMs?: number, owner?: string,
|
|
851
879
|
* backoffBase?: number, backoffCap?: number, renew?: boolean,
|
|
852
|
-
* onOutcome?: (event: any) => void }} workerOptions
|
|
880
|
+
* onOutcome?: (event: any) => void, effectSafety?: (job: any, context: any) => any }} workerOptions
|
|
853
881
|
*/
|
|
854
882
|
const createWorker = (workerOptions) => {
|
|
855
883
|
const handlers = workerOptions?.handlers;
|
|
884
|
+
if (workerOptions?.effectSafety !== undefined && typeof workerOptions.effectSafety !== 'function') throw new TypeError('effectSafety must be a function');
|
|
856
885
|
if (handlers === null || typeof handlers !== 'object'
|
|
857
886
|
|| Object.keys(handlers).length === 0
|
|
858
887
|
|| Object.values(handlers).some((handler) => typeof handler !== 'function')) {
|
|
@@ -1143,8 +1172,11 @@ export function createJobEngine(options) {
|
|
|
1143
1172
|
try {
|
|
1144
1173
|
let result;
|
|
1145
1174
|
try {
|
|
1146
|
-
|
|
1147
|
-
|
|
1175
|
+
const context = { job, lease: () => attempt.lease, checkpoints: attempt.checkpoints, signal: attempt.signal,
|
|
1176
|
+
pause: () => io(() => cancel(job.id, { lease: attempt.lease }), 'a durable effect pause') };
|
|
1177
|
+
const admitted = workerOptions.effectSafety === undefined || await workerOptions.effectSafety(job, context) === true;
|
|
1178
|
+
if (!admitted) { await context.pause(); recordCancelled(attempt); return; }
|
|
1179
|
+
result = await handlers[job.kind](job.payload, context);
|
|
1148
1180
|
}
|
|
1149
1181
|
catch (error) {
|
|
1150
1182
|
// past cancellation the store is closing: leave the leased
|
|
@@ -1330,6 +1362,7 @@ export function createJobEngine(options) {
|
|
|
1330
1362
|
counts,
|
|
1331
1363
|
claim,
|
|
1332
1364
|
renew,
|
|
1365
|
+
assertLease,
|
|
1333
1366
|
complete,
|
|
1334
1367
|
fail,
|
|
1335
1368
|
checkpointsFor,
|
package/src/live.js
CHANGED
|
@@ -549,6 +549,7 @@ function rowsStrategy(description, context) {
|
|
|
549
549
|
return flatten();
|
|
550
550
|
}),
|
|
551
551
|
entries: () => flatten().length,
|
|
552
|
+
close: () => itemsByKey.clear(),
|
|
552
553
|
apply(record, previousRows) {
|
|
553
554
|
const touched = touchedKeys(record, context.name, description.deps);
|
|
554
555
|
if (touched === null) return null;
|
|
@@ -628,6 +629,7 @@ function windowStrategy(description, context) {
|
|
|
628
629
|
return visibleRows();
|
|
629
630
|
}),
|
|
630
631
|
entries: () => sortedWindow.size(),
|
|
632
|
+
close: () => sortedWindow.clear(),
|
|
631
633
|
apply(record, previousRows) {
|
|
632
634
|
const touched = touchedKeys(record, context.name, description.deps);
|
|
633
635
|
if (touched === null) return null;
|
|
@@ -707,6 +709,7 @@ function accumulatorStrategy(description, context) {
|
|
|
707
709
|
return rowsOf();
|
|
708
710
|
}),
|
|
709
711
|
entries: () => contributions.size,
|
|
712
|
+
close: () => contributions.clear(),
|
|
710
713
|
stats: () => ({ ...stats }),
|
|
711
714
|
apply(record, previousRows) {
|
|
712
715
|
const touched = touchedKeys(record, context.name, description.deps);
|
|
@@ -867,7 +870,7 @@ export function createLiveRegistry(bounds) {
|
|
|
867
870
|
outcome = ops.length === 0 && !late ? null
|
|
868
871
|
: { ops, rows, ...(late ? { late: outcome.late } : {}) };
|
|
869
872
|
}
|
|
870
|
-
|
|
873
|
+
checkBound(strategy.entries(outcome?.rows ?? state.result.rows));
|
|
871
874
|
}
|
|
872
875
|
catch (error) {
|
|
873
876
|
state.status = 'errored';
|
package/src/migrate.js
CHANGED
|
@@ -42,6 +42,9 @@ import { normalizeEntities, explainMapping } from './model.js';
|
|
|
42
42
|
import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
|
|
43
43
|
import { mergeEntityRow } from './graph.js';
|
|
44
44
|
import { entityCore } from './entity.js';
|
|
45
|
+
import { sqlTokens } from './dialects/check-read.js';
|
|
46
|
+
import { readSchema } from './introspect.js';
|
|
47
|
+
import { verifyPhysical, physicalSelection } from './physical.js';
|
|
45
48
|
import {
|
|
46
49
|
MIGRATION_VERSION, isPerDocumentAssertion, compileDocumentStep, checkMigrationDocument,
|
|
47
50
|
normalizeAssertionBounds, ASSERTION_BOUNDS_DEFAULT, createAssertionBoundGuard,
|
|
@@ -195,6 +198,8 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
195
198
|
const dialect = options?.dialect ?? null;
|
|
196
199
|
if (dialect === null || typeof dialect !== 'object')
|
|
197
200
|
throw new TypeError('planMigration needs { dialect } (the store dialect renders the DDL)');
|
|
201
|
+
if ([fromModel, toModel].some((m) => Object.values(m.entities ?? {}).some((e) => e.physical !== undefined)))
|
|
202
|
+
throw refuse('JD0021', 'column layouts require planPhysicalMigration with explicit preservation dispositions');
|
|
198
203
|
const mapping = { derived: options?.derived ?? 'virtual', rtree: options?.rtree !== false,
|
|
199
204
|
// a model that declares an index EXPRESSION resolves its functions
|
|
200
205
|
// here too: a plan is DDL, and DDL over a function this planner was
|
|
@@ -1054,6 +1059,18 @@ function walkRows(connection, table, batchSize, handle, keyed = true, entityMapp
|
|
|
1054
1059
|
// the rest-document alone could not see `id` or `name` at all
|
|
1055
1060
|
const dialect = connection.dialect;
|
|
1056
1061
|
const q = dialect.quoteIdentifier;
|
|
1062
|
+
if (entityMapping?.document === false) {
|
|
1063
|
+
const order = entityMapping.keys.map((k) => q(entityMapping.columns.find((c) => c.name === k).physical)).join(', ');
|
|
1064
|
+
const next = (offset) => {
|
|
1065
|
+
check?.();
|
|
1066
|
+
const sql = `SELECT ${physicalSelection(entityMapping, dialect)} FROM ${q(entityMapping.table)} ORDER BY ${order} ${dialect.limitClause(batchSize, offset)}`;
|
|
1067
|
+
return chain(connection.prepare(sql), (statement) => chain(statement.all([]), (rows) => {
|
|
1068
|
+
if (!rows.length) return null;
|
|
1069
|
+
return chain(handle(rows.map((row, i) => ({ ...row, rid: offset + i }))), () => next(offset + rows.length));
|
|
1070
|
+
}));
|
|
1071
|
+
};
|
|
1072
|
+
return next(0);
|
|
1073
|
+
}
|
|
1057
1074
|
const rid = dialect.rowIdentity();
|
|
1058
1075
|
const keySelect = keyed ? `, ${q('key')} AS ${q('k')}` : '';
|
|
1059
1076
|
const columnSelect = entityMapping === null ? '' : entityColumnsOf(entityMapping)
|
|
@@ -1118,6 +1135,8 @@ function runSteps(connection, migration, options) {
|
|
|
1118
1135
|
// migration in flight back whole, as any step failure does
|
|
1119
1136
|
if (options.check !== undefined) options.check();
|
|
1120
1137
|
const current = migration.steps[i];
|
|
1138
|
+
if (migration.physical && !['ddl', 'sql', 'rebuild'].includes(current.kind))
|
|
1139
|
+
throw refuse('JD0021', 'physical preservation plans use explicit SQL/rebuild steps and preservation assertions');
|
|
1121
1140
|
const fail = (reason, cause) => {
|
|
1122
1141
|
throw refuse('JD0023',
|
|
1123
1142
|
`migration '${migration.id}' step ${i} (${current.kind}) failed: ${reason}`,
|
|
@@ -1356,14 +1375,16 @@ function validateTargetState(connection, model, options) {
|
|
|
1356
1375
|
const validate = options.compileSchema !== undefined
|
|
1357
1376
|
? options.compileSchema(entity.schema)
|
|
1358
1377
|
: null;
|
|
1359
|
-
if (validate === null) return verifyEntity(i + 1);
|
|
1378
|
+
if (validate === null && entity.physical === null) return verifyEntity(i + 1);
|
|
1360
1379
|
// the WHOLE document — mapped columns folded in — is what the target
|
|
1361
1380
|
// schema judges; the rest-document alone failed every entity whose
|
|
1362
1381
|
// required members are columns, so a pure widening could not land
|
|
1363
1382
|
const entityMapping = explainMapping(model).entities[entity.name];
|
|
1364
|
-
return chain(
|
|
1383
|
+
return chain(entity.physical === null ? null : chain(readSchema(connection), (schema) =>
|
|
1384
|
+
verifyPhysical(connection, planEntity(entity.name, entityMapping, explainMapping(model), dialect).physical, schema)), () =>
|
|
1385
|
+
chain(walkRows(connection, entityMapping.table, options.batchSize, (rows) => {
|
|
1365
1386
|
for (const row of rows) {
|
|
1366
|
-
const outcome = validate(mergeEntityRow(entityMapping, row, 'doc'));
|
|
1387
|
+
const outcome = validate === null ? true : validate(mergeEntityRow(entityMapping, row, 'doc'));
|
|
1367
1388
|
const valid = outcome === true || outcome?.valid === true;
|
|
1368
1389
|
if (!valid) {
|
|
1369
1390
|
throw refuse('JD0021',
|
|
@@ -1371,7 +1392,7 @@ function validateTargetState(connection, model, options) {
|
|
|
1371
1392
|
+ 'validate against the target schema — a narrowing needs a data transform');
|
|
1372
1393
|
}
|
|
1373
1394
|
}
|
|
1374
|
-
}, false, entityMapping), () => verifyEntity(i + 1));
|
|
1395
|
+
}, false, entityMapping), () => verifyEntity(i + 1)));
|
|
1375
1396
|
};
|
|
1376
1397
|
const verifyNext = (i) => {
|
|
1377
1398
|
if (i >= collections.length) return null;
|
|
@@ -1689,13 +1710,12 @@ export function migrate(target, migrations, options) {
|
|
|
1689
1710
|
// table before reading it, a DRY RUN probes for it instead and
|
|
1690
1711
|
// reads an absent one as an empty history — the promise a dry
|
|
1691
1712
|
// run makes is the reason it is safe to point at production
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
chain(connection.prepare(statements.select), (select) => select.all([])));
|
|
1713
|
+
let historyExists = false;
|
|
1714
|
+
const history = chain(connection.prepare(dialect.introspect.tableExists()), (probe) =>
|
|
1715
|
+
chain(probe.get([HISTORY_TABLE]), (row) => {
|
|
1716
|
+
historyExists = row !== undefined;
|
|
1717
|
+
return !historyExists ? [] : chain(connection.prepare(statements.select), (select) => select.all([]));
|
|
1718
|
+
}));
|
|
1699
1719
|
work = chain(history, (appliedRows) => {
|
|
1700
1720
|
// the list must agree with the history: same ids, same
|
|
1701
1721
|
// order, same checksums — an edited applied migration is
|
|
@@ -1723,6 +1743,7 @@ export function migrate(target, migrations, options) {
|
|
|
1723
1743
|
let expectedFrom = currentShape;
|
|
1724
1744
|
for (const migration of pending) {
|
|
1725
1745
|
checkMigrationDocument(migration);
|
|
1746
|
+
checkPreservationPlan(migration);
|
|
1726
1747
|
if (migration.from !== expectedFrom) {
|
|
1727
1748
|
throw refuse('JD0020',
|
|
1728
1749
|
`migration '${migration.id}' expects shape '${migration.from}' but the `
|
|
@@ -1741,6 +1762,8 @@ export function migrate(target, migrations, options) {
|
|
|
1741
1762
|
return { applied: [], skipped: appliedRows.map((row) => row.id), upToDate: true };
|
|
1742
1763
|
}
|
|
1743
1764
|
|
|
1765
|
+
if (pending.some((m) => m.physical) && options.shadow !== false)
|
|
1766
|
+
throw refuse('JD0021', 'physical preservation plans require shadow:false; qualify against an explicit copy/fresh-target fixture');
|
|
1744
1767
|
const shadowRun = options.shadow === false
|
|
1745
1768
|
? null
|
|
1746
1769
|
: replayOnShadow(options.shadowDriver ?? target.driver,
|
|
@@ -1824,11 +1847,14 @@ export function migrate(target, migrations, options) {
|
|
|
1824
1847
|
bracket && dialect.capabilities.foreignKeysAlwaysOn !== true
|
|
1825
1848
|
? connection.exec(dialect.pragma.foreignKeys(false)) : null,
|
|
1826
1849
|
() => chain(connection.exec(dialect.tx.beginImmediate), () => {
|
|
1827
|
-
const body = () => chain(
|
|
1850
|
+
const body = () => chain(migration.physical ? verifyPreservation(connection, migration.physical, false) : null, () =>
|
|
1851
|
+
chain(historyExists ? null : connection.exec(statements.create), () =>
|
|
1852
|
+
chain(runSteps(connection, migration, runOptions), () =>
|
|
1853
|
+
chain(migration.physical ? verifyPreservation(connection, migration.physical, true) : null, () =>
|
|
1828
1854
|
chain(last && options.model !== undefined
|
|
1829
1855
|
? chain(validateTargetState(connection, options.model,
|
|
1830
1856
|
{ compileSchema: options.compileSchema, batchSize }),
|
|
1831
|
-
() => (normalizeEntities(options.model).size === 0 ? null
|
|
1857
|
+
() => (normalizeEntities(options.model).size === 0 || migration.physical ? null
|
|
1832
1858
|
: chain(compareShapeToModel(target.driver, connection,
|
|
1833
1859
|
options.model, options.registerFunctions), (difference) => {
|
|
1834
1860
|
if (difference !== null) {
|
|
@@ -1841,15 +1867,10 @@ export function migrate(target, migrations, options) {
|
|
|
1841
1867
|
() => chain(connection.prepare(statements.insert), (insert) =>
|
|
1842
1868
|
insert.run([migration.id, runtime.now(), migration.from,
|
|
1843
1869
|
migration.to, migrationChecksum(migration),
|
|
1844
|
-
migration.steps.length]))));
|
|
1870
|
+
migration.steps.length])))))));
|
|
1845
1871
|
const restore = () => (bracket
|
|
1846
1872
|
&& dialect.capabilities.foreignKeysAlwaysOn !== true
|
|
1847
1873
|
? connection.exec(dialect.pragma.foreignKeys(true)) : null);
|
|
1848
|
-
const commit = () => chain(connection.exec(dialect.tx.commit), () =>
|
|
1849
|
-
chain(restore(), () => {
|
|
1850
|
-
applied.push(migration.id);
|
|
1851
|
-
return applyNext(i + 1);
|
|
1852
|
-
}));
|
|
1853
1874
|
const rollback = (error) =>
|
|
1854
1875
|
chain(connection.exec(dialect.tx.rollback), () =>
|
|
1855
1876
|
chain(restore(), () => { throw error; }));
|
|
@@ -1864,9 +1885,16 @@ export function migrate(target, migrations, options) {
|
|
|
1864
1885
|
catch (error) {
|
|
1865
1886
|
return rollback(error);
|
|
1866
1887
|
}
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1888
|
+
const settle = () => {
|
|
1889
|
+
let result;
|
|
1890
|
+
try { result = connection.exec(dialect.tx.commit); }
|
|
1891
|
+
catch (error) { return rollback(error); }
|
|
1892
|
+
return result instanceof Promise ? result.then(published, rollback) : published();
|
|
1893
|
+
};
|
|
1894
|
+
const published = () => chain(restore(), () => {
|
|
1895
|
+
historyExists = true; applied.push(migration.id); return applyNext(i + 1);
|
|
1896
|
+
});
|
|
1897
|
+
return outcome instanceof Promise ? outcome.then(settle, rollback) : settle();
|
|
1870
1898
|
}));
|
|
1871
1899
|
};
|
|
1872
1900
|
return chain(applyNext(0), () => ({
|
|
@@ -1886,3 +1914,89 @@ export function migrate(target, migrations, options) {
|
|
|
1886
1914
|
: finish(work);
|
|
1887
1915
|
})));
|
|
1888
1916
|
}
|
|
1917
|
+
|
|
1918
|
+
/** Plan an existing file's explicit preservation migration. Every source object
|
|
1919
|
+
* needs a disposition; source/target assertions preserve application-owned facts.
|
|
1920
|
+
* @param {any} connection @param {any} fromModel @param {any} toModel
|
|
1921
|
+
* @param {{ id: string, steps: any[], dispositions: Record<string, 'preserve'|'replace'|'drop'>,
|
|
1922
|
+
* assertions?: { sql: string, params?: any[], expected: any[] }[] }} options @returns {any} */
|
|
1923
|
+
export function planPhysicalMigration(connection, fromModel, toModel, options) {
|
|
1924
|
+
normalizeEntities(fromModel); normalizeEntities(toModel);
|
|
1925
|
+
if (!options || typeof options.id !== 'string' || !options.id || !Array.isArray(options.steps))
|
|
1926
|
+
throw refuse('JD0021', 'a physical plan requires id and explicit steps');
|
|
1927
|
+
return chain(preservationSchemaOf(connection), (source) => {
|
|
1928
|
+
const dispositions = options.dispositions ?? {};
|
|
1929
|
+
const keys = source.map((o) => `${o.type}:${o.name}`);
|
|
1930
|
+
if (Object.keys(dispositions).some((key) => !keys.includes(key)) || keys.some((key) => !['preserve', 'replace', 'drop'].includes(dispositions[key])))
|
|
1931
|
+
throw refuse('JD0021', 'every physical source object must have an explicit preserve, replace or drop disposition');
|
|
1932
|
+
const assertions = options.assertions ?? [];
|
|
1933
|
+
for (const assertion of assertions) {
|
|
1934
|
+
if (!assertion || typeof assertion.sql !== 'string' || !/^SELECT\b/i.test(assertion.sql.trim()) || !Array.isArray(assertion.expected))
|
|
1935
|
+
throw refuse('JD0021', 'preservation assertions require a SELECT and expected rows');
|
|
1936
|
+
}
|
|
1937
|
+
const migration = { $migration: MIGRATION_VERSION, id: options.id, from: shapeHash(fromModel), to: shapeHash(toModel),
|
|
1938
|
+
steps: options.steps, physical: { source, dispositions, assertions } };
|
|
1939
|
+
checkMigrationDocument(migration);
|
|
1940
|
+
checkPreservationPlan(migration);
|
|
1941
|
+
return migration;
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/** Validate saved plans again at execution, including SQL ownership boundaries. */
|
|
1946
|
+
function checkPreservationPlan(migration) {
|
|
1947
|
+
const physical = migration.physical;
|
|
1948
|
+
if (physical === undefined) return;
|
|
1949
|
+
const fail = () => { throw refuse('JD0021', 'invalid physical source, dispositions, assertions or steps'); };
|
|
1950
|
+
if (!physical || !Array.isArray(physical.source) || !physical.dispositions || !Array.isArray(physical.assertions)) fail();
|
|
1951
|
+
const keys = physical.source.map((object) => {
|
|
1952
|
+
if (!object || typeof object.name !== 'string' || !['table', 'view', 'index', 'trigger'].includes(object.type)) fail();
|
|
1953
|
+
return `${object.type}:${object.name}`;
|
|
1954
|
+
});
|
|
1955
|
+
if (new Set(keys).size !== keys.length || Object.keys(physical.dispositions).some((key) => !keys.includes(key))
|
|
1956
|
+
|| keys.some((key) => !['preserve', 'replace', 'drop'].includes(physical.dispositions[key]))) fail();
|
|
1957
|
+
for (const assertion of physical.assertions)
|
|
1958
|
+
if (!assertion || typeof assertion.sql !== 'string' || !/^SELECT\b/i.test(assertion.sql.trim())
|
|
1959
|
+
|| !Array.isArray(assertion.expected) || (assertion.params !== undefined && !Array.isArray(assertion.params))) fail();
|
|
1960
|
+
if (migration.steps.some((step) => !['ddl', 'sql', 'rebuild'].includes(step.kind))) fail();
|
|
1961
|
+
const fragments = migration.steps.flatMap((step) => step.kind === 'rebuild'
|
|
1962
|
+
? [...(step.create ?? []), step.copy, ...(step.indexes ?? [])] : [step.sql]);
|
|
1963
|
+
for (const sql of fragments) {
|
|
1964
|
+
const tokens = typeof sql === 'string' ? sqlTokens(sql) : [];
|
|
1965
|
+
const words = tokens.filter((t) => t.kind === 'word').map((t) => t.value.toUpperCase());
|
|
1966
|
+
if (!['CREATE', 'ALTER', 'DROP', 'INSERT', 'UPDATE', 'DELETE'].includes(words[0])
|
|
1967
|
+
|| words.some((w) => /^(?:COMMIT|ROLLBACK|SAVEPOINT|RELEASE|ATTACH|DETACH|PRAGMA|VACUUM)$/.test(w)))
|
|
1968
|
+
throw refuse('JD0021', 'physical steps cannot change transaction or connection ownership');
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
/** Preservation compares exact source programs, including whitespace in SQL literals. */
|
|
1973
|
+
function preservationSchemaOf(connection) {
|
|
1974
|
+
return chain(readSchema(connection), (schema) => schema.objects
|
|
1975
|
+
.filter((object) => !ENGINE_TABLES.has(object.name) && !ENGINE_TABLES.has(object.owner)));
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
/** Verify source identity before destructive steps, and every preserved object
|
|
1979
|
+
* and fact before publication. The migration transaction owns all these reads. */
|
|
1980
|
+
function verifyPreservation(connection, physical, after) {
|
|
1981
|
+
return chain(preservationSchemaOf(connection), (actual) => {
|
|
1982
|
+
if (!after && canonicalizeJson(actual) !== canonicalizeJson(physical.source))
|
|
1983
|
+
throw refuse('JD0020', 'the physical source schema changed after the plan was prepared');
|
|
1984
|
+
if (after) {
|
|
1985
|
+
for (const object of physical.source) {
|
|
1986
|
+
const key = `${object.type}:${object.name}`;
|
|
1987
|
+
const current = actual.find((o) => o.type === object.type && o.name === object.name);
|
|
1988
|
+
if (physical.dispositions[key] === 'preserve' && canonicalizeJson(current ?? null) !== canonicalizeJson(object))
|
|
1989
|
+
throw refuse('JD0023', `preserved object '${key}' was changed or lost`);
|
|
1990
|
+
if (physical.dispositions[key] === 'drop' && current) throw refuse('JD0023', `declared drop '${key}' remains`);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
const next = (i) => i >= physical.assertions.length ? null
|
|
1994
|
+
: chain(connection.prepare(physical.assertions[i].sql, { readOnly: true }), (s) =>
|
|
1995
|
+
chain(s.all(physical.assertions[i].params ?? []), (rows) => {
|
|
1996
|
+
if (canonicalizeJson(rows) !== canonicalizeJson(physical.assertions[i].expected))
|
|
1997
|
+
throw refuse('JD0023', `preservation assertion ${i} disagrees ${after ? 'after' : 'before'} migration`);
|
|
1998
|
+
return next(i + 1);
|
|
1999
|
+
}));
|
|
2000
|
+
return next(0);
|
|
2001
|
+
});
|
|
2002
|
+
}
|
package/src/model.js
CHANGED
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
28
28
|
|
|
29
29
|
import { DbCompileError } from './errors.js';
|
|
30
|
+
import { normalizePhysical } from './physical.js';
|
|
31
|
+
import { normalizeInvariants } from './invariants.js';
|
|
30
32
|
|
|
31
33
|
/** The closed `x-entity` vocabulary; anything else is `JD0030`. */
|
|
32
34
|
const ENTITY_MEMBERS = new Set(['key', 'unique', 'index', 'default', 'column', 'relation', 'version']);
|
|
@@ -343,9 +345,13 @@ export function normalizeEntities(model) {
|
|
|
343
345
|
keys,
|
|
344
346
|
relations,
|
|
345
347
|
version: versions.length === 1 ? versions[0].name : null,
|
|
348
|
+
physical: normalizePhysical(spec.physical, properties, keys, docPath),
|
|
349
|
+
invariants: normalizeInvariants(spec.invariants, `${docPath}/invariants`),
|
|
346
350
|
});
|
|
347
351
|
}
|
|
348
352
|
|
|
353
|
+
for (const entity of entities.values())
|
|
354
|
+
if (entity.physical !== null && entity.relations.length) throw modelError('physical join tables are declared as entities; relation navigation is not qualified for column layouts', entity.docPath);
|
|
349
355
|
resolveRelations(entities);
|
|
350
356
|
return entities;
|
|
351
357
|
}
|
|
@@ -556,6 +562,11 @@ export function explainMapping(model) {
|
|
|
556
562
|
const mapping = { entities: {}, joinTables: {} };
|
|
557
563
|
|
|
558
564
|
for (const entity of entities.values()) {
|
|
565
|
+
if (entity.physical !== null) {
|
|
566
|
+
mapping.entities[entity.name] = { ...entity.physical, document: false,
|
|
567
|
+
foreignKeys: [], indexes: [], version: entity.version, invariants: model.entities[entity.name].invariants ?? [] };
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
559
570
|
const columns = [];
|
|
560
571
|
const document = [];
|
|
561
572
|
const indexes = [];
|
|
@@ -626,6 +637,7 @@ export function explainMapping(model) {
|
|
|
626
637
|
indexes,
|
|
627
638
|
document,
|
|
628
639
|
version: entity.version ?? null,
|
|
640
|
+
...(entity.invariants.length ? { invariants: model.entities[entity.name].invariants } : {}),
|
|
629
641
|
};
|
|
630
642
|
}
|
|
631
643
|
|
package/src/mutation.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Bounded column mutation documents, lowered through the entity's writer plan. */
|
|
3
|
+
import { analyzeQuery } from '@jarenjs/json/query';
|
|
4
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
5
|
+
import { chain, attempt } from './driver.js';
|
|
6
|
+
import { DbCompileError, DbRuntimeError, wrapDriverError } from './errors.js';
|
|
7
|
+
import { entityShape, planEntityPredicate } from './plan.js';
|
|
8
|
+
import { createEntityPredicateEmitters } from './emit.js';
|
|
9
|
+
import { physicalSelection } from './physical.js';
|
|
10
|
+
import { utf8Length } from './cursor.js';
|
|
11
|
+
|
|
12
|
+
/** Compile once per document, execute within the existing guarded transaction.
|
|
13
|
+
* @param {any} connection @param {any} entity @param {any} mapping @param {any} core */
|
|
14
|
+
export function createEntityMutation(connection, entity, mapping, core) {
|
|
15
|
+
const dialect = connection.dialect;
|
|
16
|
+
const q = dialect.quoteIdentifier;
|
|
17
|
+
const plans = new Map();
|
|
18
|
+
const fail = (reason) => { throw new DbCompileError('JD0038', reason, entity.docPath); };
|
|
19
|
+
const column = (name) => {
|
|
20
|
+
const c = mapping.columns.find((entry) => entry.name === name);
|
|
21
|
+
if (!c) return fail(`unknown stored member '${name}'`);
|
|
22
|
+
return c;
|
|
23
|
+
};
|
|
24
|
+
const writableColumn = (name) => {
|
|
25
|
+
const c = column(name);
|
|
26
|
+
if (c.generated || core.plan.keys.includes(name) || name === entity.version)
|
|
27
|
+
fail(`'${name}' is an identity, revision or generated member`);
|
|
28
|
+
return c;
|
|
29
|
+
};
|
|
30
|
+
const comparison = (c, prefix = '') => {
|
|
31
|
+
const value = prefix + q(c.physical);
|
|
32
|
+
return c.storage === 'string' ? dialect.codepoint(value) : value;
|
|
33
|
+
};
|
|
34
|
+
const compile = (document) => {
|
|
35
|
+
if (entity.physical == null || dialect.name !== 'sqlite') fail('native mutations require a declared SQLite column layout');
|
|
36
|
+
core.plan.writable();
|
|
37
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) fail('a mutation is an object');
|
|
38
|
+
const allowed = { update: ['key', 'expectedRevision', 'set'], upsert: ['values', 'conflict', 'update'],
|
|
39
|
+
'insert-select': ['source', 'where', 'select', 'conflict', 'onConflict'] }[document.op];
|
|
40
|
+
if (!allowed) fail('op must be update, upsert or insert-select');
|
|
41
|
+
for (const key of Object.keys(document))
|
|
42
|
+
if (!['op', 'returning', 'maxRows', 'maxBytes', ...allowed].includes(key)) fail(`unknown mutation member '${key}'`);
|
|
43
|
+
const maxRows = document.maxRows ?? 100;
|
|
44
|
+
const maxBytes = document.maxBytes ?? 1_048_576;
|
|
45
|
+
if (![maxRows, maxBytes].every((n) => Number.isSafeInteger(n) && n > 0 && n < Number.MAX_SAFE_INTEGER)) fail('mutation row and byte bounds must be positive safe integers');
|
|
46
|
+
const returning = document.returning ?? mapping.columns.map((c) => c.name);
|
|
47
|
+
if (!Array.isArray(returning) || !returning.length || new Set(returning).size !== returning.length) fail('returning is a nonempty distinct member list');
|
|
48
|
+
returning.forEach(column);
|
|
49
|
+
// A before/after invariant needs the preimage. These plans promise one
|
|
50
|
+
// data statement; database invariants retain their trigger enforcement.
|
|
51
|
+
if (entity.invariants.some((rule) => rule.enforcement === 'store')) fail('store invariants require the entity writer with before/after images');
|
|
52
|
+
const params = [];
|
|
53
|
+
const param = (value) => { params.push(value); return dialect.parameterRef(params.length, 'v'); };
|
|
54
|
+
const table = q(mapping.table);
|
|
55
|
+
let sql;
|
|
56
|
+
let prefix = '';
|
|
57
|
+
if (document.op === 'update') {
|
|
58
|
+
if (!document.set || typeof document.set !== 'object' || Array.isArray(document.set) || !Object.keys(document.set).length) fail('update needs a nonempty set object');
|
|
59
|
+
const assignments = Object.entries(document.set).map(([name, value]) => {
|
|
60
|
+
const c = writableColumn(name);
|
|
61
|
+
const encoded = core.plan.encodeColumn(name, value);
|
|
62
|
+
return { name: q(c.physical), compare: comparison(c), value: param(encoded), encoded };
|
|
63
|
+
});
|
|
64
|
+
const parts = core.normalizeKey(document.key);
|
|
65
|
+
const where = core.plan.keys.map((key, i) => `${q(column(key).physical)} = ${param(core.plan.encodeColumn(key, parts[i]))}`);
|
|
66
|
+
if (entity.version !== null) {
|
|
67
|
+
if (!Number.isSafeInteger(document.expectedRevision) || document.expectedRevision < 0) fail('a versioned update needs expectedRevision');
|
|
68
|
+
where.push(`${q(column(entity.version).physical)} = ${param(document.expectedRevision)}`);
|
|
69
|
+
}
|
|
70
|
+
else if (document.expectedRevision !== undefined) fail('expectedRevision needs a declared version member');
|
|
71
|
+
where.push(`(${assignments.map((a) => `${a.compare} IS NOT ${param(a.encoded)}`).join(' OR ')})`);
|
|
72
|
+
const sets = assignments.map((a) => `${a.name} = ${a.value}`);
|
|
73
|
+
if (entity.version !== null) sets.push(`${q(column(entity.version).physical)} = ${q(column(entity.version).physical)} + 1`);
|
|
74
|
+
sql = `UPDATE ${table} SET ${sets.join(', ')} WHERE ${where.join(' AND ')}`;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
if (JSON.stringify(document.conflict) !== JSON.stringify(core.plan.keys)) fail('conflict must name the complete ordered primary key');
|
|
78
|
+
let names;
|
|
79
|
+
let source;
|
|
80
|
+
if (document.op === 'upsert') {
|
|
81
|
+
if (!document.values || typeof document.values !== 'object' || Array.isArray(document.values)) fail('upsert needs values');
|
|
82
|
+
const complete = core.complete(document.values, { updating: false });
|
|
83
|
+
const split = core.plan.split(complete);
|
|
84
|
+
names = split.values.map((v) => v.name);
|
|
85
|
+
if (core.plan.keys.some((key) => !names.includes(key))) fail('upsert requires every primary-key value');
|
|
86
|
+
source = `VALUES (${split.values.map((v) => param(v.value)).join(', ')})`;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
if (document.source !== entity.name || document.onConflict !== 'nothing') fail('insert-select supports same-entity sources with onConflict nothing');
|
|
90
|
+
if (!document.select || typeof document.select !== 'object' || Array.isArray(document.select)) fail('insert-select needs a projection');
|
|
91
|
+
names = Object.keys(document.select);
|
|
92
|
+
if (!names.length || core.plan.keys.some((key) => !names.includes(key))) fail('insert-select projects every primary-key member');
|
|
93
|
+
const values = names.map((name) => {
|
|
94
|
+
const target = column(name);
|
|
95
|
+
if (target.generated || name === entity.version) fail('insert-select leaves generated values and revisions database-owned');
|
|
96
|
+
const value = document.select[name];
|
|
97
|
+
if (typeof value === 'string' && value.startsWith('$it.')) {
|
|
98
|
+
const from = column(value.slice(4));
|
|
99
|
+
if (from.codec !== target.codec || from.null !== target.null) fail('insert-select requires identical source and target codecs');
|
|
100
|
+
return `${q('s')}.${q(from.physical)}`;
|
|
101
|
+
}
|
|
102
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 1 && Object.hasOwn(value, '$literal'))
|
|
103
|
+
return param(core.plan.encodeColumn(name, value.$literal));
|
|
104
|
+
return fail('insert-select values are singular member paths or $literal values');
|
|
105
|
+
});
|
|
106
|
+
const analyzed = analyzeQuery({ $for: { it: '$[*]' }, $where: document.where ?? true, $return: '$it' });
|
|
107
|
+
const predicate = planEntityPredicate(analyzed.root.where, analyzed.root.forBindings[0].slot, entityShape(entity, mapping));
|
|
108
|
+
if ('refusal' in predicate) fail(predicate.refusal.reason);
|
|
109
|
+
const emitter = createEntityPredicateEmitters(dialect, (slot) => {
|
|
110
|
+
if (!Object.hasOwn(slot, 'literal')) return fail('mutation predicates use literal values');
|
|
111
|
+
return param(slot.literal);
|
|
112
|
+
});
|
|
113
|
+
const where = emitter.emitPred(q('s'), `${q('s')}.${q('doc')}`, predicate.pred);
|
|
114
|
+
source = `SELECT ${values.join(', ')} FROM ${table} AS ${q('s')} WHERE ${where}`
|
|
115
|
+
+ ` ORDER BY ${core.plan.keys.map((key) => `${q('s')}.${q(column(key).physical)}`).join(', ')}`
|
|
116
|
+
+ ` LIMIT ${maxRows + 1}`;
|
|
117
|
+
const aliases = names.map((name, i) => q(`v${i}`));
|
|
118
|
+
prefix = `WITH ${q('_jaren_source')} (${aliases.join(', ')}) AS MATERIALIZED (${source}) `;
|
|
119
|
+
source = `SELECT ${aliases.join(', ')} FROM ${q('_jaren_source')} WHERE `
|
|
120
|
+
+ dialect.mutationRowGuard(`(SELECT COUNT(*) FROM ${q('_jaren_source')})`, maxRows);
|
|
121
|
+
}
|
|
122
|
+
sql = `INSERT INTO ${table} (${names.map((name) => q(column(name).physical)).join(', ')}) ${source}`
|
|
123
|
+
+ ` ON CONFLICT (${core.plan.keys.map((key) => q(column(key).physical)).join(', ')})`;
|
|
124
|
+
if (document.op === 'insert-select') sql += ' DO NOTHING';
|
|
125
|
+
else {
|
|
126
|
+
if (!Array.isArray(document.update) || !document.update.length || new Set(document.update).size !== document.update.length) fail('upsert update names distinct stored members');
|
|
127
|
+
const changes = document.update.map((name) => {
|
|
128
|
+
const c = writableColumn(name);
|
|
129
|
+
if (!names.includes(name)) fail('an upsert update member must be supplied in values');
|
|
130
|
+
return { name: q(c.physical), compare: comparison(c, `${table}.`) };
|
|
131
|
+
});
|
|
132
|
+
const sets = changes.map(({ name }) => `${name} = excluded.${name}`);
|
|
133
|
+
if (entity.version !== null) sets.push(`${q(column(entity.version).physical)} = ${table}.${q(column(entity.version).physical)} + 1`);
|
|
134
|
+
sql += ` DO UPDATE SET ${sets.join(', ')} WHERE ${changes.map(({ name, compare }) => `${compare} IS NOT excluded.${name}`).join(' OR ')}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
sql = prefix + sql + ` RETURNING ${physicalSelection(mapping, dialect)}`;
|
|
138
|
+
return { sql, params, returning, maxRows, maxBytes, statement: null };
|
|
139
|
+
};
|
|
140
|
+
return (document) => {
|
|
141
|
+
const key = canonicalizeJson(document);
|
|
142
|
+
let plan = plans.get(key);
|
|
143
|
+
if (!plan) {
|
|
144
|
+
plan = compile(document);
|
|
145
|
+
if (plans.size >= 64) plans.delete(plans.keys().next().value);
|
|
146
|
+
plans.set(key, plan);
|
|
147
|
+
}
|
|
148
|
+
return connection.transaction(() => {
|
|
149
|
+
plan.statement ??= connection.prepare(plan.sql);
|
|
150
|
+
return chain(plan.statement, (statement) => chain(attempt(() => statement.all(plan.params),
|
|
151
|
+
(error) => String(error?.message).includes('jaren-mutation-row-bound')
|
|
152
|
+
? new DbRuntimeError('JD2007', 'insert-select exceeded its source row bound', { cause: error })
|
|
153
|
+
: wrapDriverError(error, { collection: entity.name, docPath: entity.docPath })), (rows) => {
|
|
154
|
+
const stored = rows.map(core.plan.merge);
|
|
155
|
+
if (rows.length > plan.maxRows || utf8Length(JSON.stringify(stored)) > plan.maxBytes)
|
|
156
|
+
throw new DbRuntimeError('JD2007', 'native mutation exceeded its returned row or byte bound');
|
|
157
|
+
stored.forEach(core.validateOnly);
|
|
158
|
+
const returned = stored.map((row) => Object.fromEntries(plan.returning
|
|
159
|
+
.filter((name) => Object.hasOwn(row, name)).map((name) => [name, row[name]])));
|
|
160
|
+
return { mode: 'native', affected: rows.length, rows: returned,
|
|
161
|
+
admitted: { statements: 1, rows: rows.length, bytes: utf8Length(JSON.stringify(stored)) } };
|
|
162
|
+
}));
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
}
|