@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
@@ -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
- const ready = attempt(() => bracket(() => chain(connection.exec(CREATE_JOBS), upgradeColumns)),
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 created (${error?.message ?? String(error)}) — `
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
- result = await handlers[job.kind](job.payload,
1147
- { job, checkpoints: attempt.checkpoints, signal: attempt.signal });
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
- if (outcome !== null) checkBound(strategy.entries(outcome.rows));
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(walkRows(connection, entity.name, options.batchSize, (rows) => {
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
- const history = options.dryRun === true
1693
- ? chain(connection.prepare(dialect.introspect.tableExists()), (probe) =>
1694
- chain(probe.get([HISTORY_TABLE]), (row) => (row === undefined
1695
- ? []
1696
- : chain(connection.prepare(statements.select), (select) => select.all([])))))
1697
- : chain(connection.exec(statements.create), () =>
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(runSteps(connection, migration, runOptions), () =>
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
- return outcome instanceof Promise
1868
- ? outcome.then(commit, rollback)
1869
- : commit();
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
 
@@ -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
+ }