@jarenjs/db 0.73.0 → 0.83.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/ARCHITECTURE.md +70 -7
  2. package/README.md +69 -6
  3. package/docs/HOSTS.md +17 -0
  4. package/docs/JOBS-FORMAT.md +26 -0
  5. package/docs/LIVE-FORMAT.md +52 -13
  6. package/docs/MIGRATION-FORMAT.md +34 -0
  7. package/docs/MODEL-FORMAT.md +163 -15
  8. package/docs/NATIVE-PLANS.md +111 -0
  9. package/docs/REPLICATION-FORMAT.md +19 -13
  10. package/docs/SEARCH.md +55 -0
  11. package/package.json +8 -4
  12. package/schemas/jaren-migration.draft-07.schema.json +54 -5
  13. package/schemas/jaren-migration.schema.json +49 -0
  14. package/schemas/jaren-model.authoring.schema.json +360 -0
  15. package/schemas/jaren-model.draft-07.schema.json +128 -0
  16. package/schemas/jaren-model.schema.json +128 -0
  17. package/src/algebra.js +26 -4
  18. package/src/backup.js +12 -7
  19. package/src/cursor.js +27 -4
  20. package/src/dag-job.js +2 -1
  21. package/src/ddl.js +13 -0
  22. package/src/derive.js +14 -3
  23. package/src/dialect.js +12 -0
  24. package/src/dialects/check-read.js +151 -0
  25. package/src/dialects/invariant-sql.js +117 -0
  26. package/src/dialects/postgres.js +28 -4
  27. package/src/dialects/sqlite.js +23 -3
  28. package/src/driver.js +1 -0
  29. package/src/drivers/bun.js +22 -4
  30. package/src/emit.js +133 -25
  31. package/src/entity.js +98 -41
  32. package/src/errors.js +8 -0
  33. package/src/graph.js +8 -1
  34. package/src/index.js +3 -0
  35. package/src/introspect.js +81 -12
  36. package/src/invariants.js +45 -0
  37. package/src/jobs.js +39 -6
  38. package/src/live-nested.js +27 -10
  39. package/src/live.js +51 -136
  40. package/src/migrate.js +136 -22
  41. package/src/model.js +12 -0
  42. package/src/mutation.js +165 -0
  43. package/src/physical.js +147 -0
  44. package/src/plan.js +275 -64
  45. package/src/query.js +175 -78
  46. package/src/search.js +144 -0
  47. package/src/sql.js +60 -0
  48. package/src/store.js +49 -13
  49. package/src/tracker.js +63 -39
  50. package/src/window.js +1 -0
  51. package/types/index.d.ts +59 -4
  52. package/types/search.d.ts +20 -0
  53. package/types/typed.d.ts +1 -0
package/src/live.js CHANGED
@@ -112,11 +112,11 @@ function documentsSource(binding, where) {
112
112
 
113
113
  /**
114
114
  * Recognise the canonical single-level group form (§7): one binding
115
- * over `$[*]`, optional `$where`, `$groupby` with ONE binding, and a
115
+ * over `$[*]`, optional `$where`, one or more group keys, and a
116
116
  * `$return` object whose members are the group key (`$g` plain or
117
117
  * defaulted) or a single-member aggregate over the group sequence.
118
118
  * @param {any} inner
119
- * @returns {null | { binding: string, group: string, groupExpr: any,
119
+ * @returns {null | { binding: string, groups: string[], key: any,
120
120
  * where: any, members: { name: string, kind: 'key' | 'aggregate',
121
121
  * fn?: string, operand?: any, defaulted?: boolean }[] }}
122
122
  */
@@ -126,18 +126,30 @@ function recogniseGroupForm(inner) {
126
126
  const allowed = new Set(['$for', '$where', '$groupby', '$return']);
127
127
  if (!Object.keys(inner).every((key) => allowed.has(key))) return null;
128
128
  const groupNames = Object.keys(inner.$groupby);
129
- if (groupNames.length !== 1) return null;
130
- const group = groupNames[0];
129
+ if (groupNames.length === 0) return null;
130
+ // Subset evaluation must never read the global collection root.
131
+ const local = (node) => {
132
+ if (typeof node === 'string') return node !== '$' && !/^\$(?:\.|\[)/.test(node);
133
+ if (Array.isArray(node)) return node.every(local);
134
+ if (!isJsonObject(node) || Object.hasOwn(node, '$const')) return true;
135
+ return Object.values(node).every(local);
136
+ };
137
+ if (![inner.$where, inner.$groupby, inner.$return].every(local)) return null;
138
+ const scalar = groupNames.some((name) => inner.$return === `$${name}`)
139
+ || (isJsonObject(inner.$return) && Object.keys(inner.$return).length === 1
140
+ && AGGREGATE_MEMBERS.has(Object.keys(inner.$return)[0]));
141
+ if (scalar) return { binding, groups: groupNames,
142
+ key: groupNames.map((name) => [inner.$groupby[name]]), where: inner.$where, members: [] };
131
143
  if (!isJsonObject(inner.$return)) return null;
132
144
  const members = [];
133
145
  for (const name of Object.keys(inner.$return)) {
134
146
  const expr = inner.$return[name];
135
- if (expr === `$${group}`) {
147
+ if (groupNames.some((group) => expr === `$${group}`)) {
136
148
  members.push({ name, kind: 'key', defaulted: false });
137
149
  continue;
138
150
  }
139
151
  if (isJsonObject(expr) && Array.isArray(expr.$default)
140
- && expr.$default.length === 2 && expr.$default[0] === `$${group}`
152
+ && expr.$default.length === 2 && groupNames.some((group) => expr.$default[0] === `$${group}`)
141
153
  && expr.$default[1] === null && Object.keys(expr).length === 1) {
142
154
  members.push({ name, kind: 'key', defaulted: true });
143
155
  continue;
@@ -150,7 +162,8 @@ function recogniseGroupForm(inner) {
150
162
  }
151
163
  return null;
152
164
  }
153
- return { binding, group, groupExpr: inner.$groupby[group], where: inner.$where, members };
165
+ return { binding, groups: groupNames, key: groupNames.map((name) => [inner.$groupby[name]]),
166
+ where: inner.$where, members };
154
167
  }
155
168
 
156
169
  /**
@@ -276,10 +289,16 @@ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null)
276
289
 
277
290
  if (aggregate !== null) {
278
291
  if (windowed) return rerun('a windowed aggregate maintains no accumulator');
292
+ if (recogniseGroupForm(inner) !== null) {
293
+ const grouped = classifyLiveQuery(inner, queryShape, keyed, eventTime);
294
+ if (grouped.strategy === 'group') return { ...grouped, aggregate: aggregate.name };
295
+ }
279
296
  const planned = planQuery({ [aggregate.name]: inner }, queryShape, {});
280
297
  if (planned.mode !== 'native' || planned.plan.aggregate === null) {
281
298
  return rerun(refinedOnly(planned, false) ? SPATIAL_RERUN.aggregate : plannerReason(planned));
282
299
  }
300
+ if (planned.plan.group !== null || planned.plan.bucket !== null)
301
+ return rerun('an aggregate over groups needs group-level maintenance');
283
302
  if (!keyed) return rerun('rows without a document key cannot be tracked');
284
303
  return {
285
304
  strategy: 'accumulator',
@@ -300,19 +319,9 @@ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null)
300
319
  : `the group filter did not translate: ${plannerReason(planned)}`);
301
320
  }
302
321
  if (!keyed) return rerun('rows without a document key cannot be tracked');
303
- const rowDocument = {
304
- $for: { [group.binding]: '$[*]' },
305
- ...(group.where !== undefined ? { $where: group.where } : {}),
306
- $return: {
307
- k: [group.groupExpr],
308
- ...Object.fromEntries(group.members
309
- .filter((member) => member.kind === 'aggregate')
310
- .map((member) => [member.name, [member.operand]])),
311
- },
312
- };
313
322
  return {
314
- strategy: 'group', group, carrier, rowDocument,
315
- deps: memberDeps(analyzeQuery([rowDocument]).root),
323
+ strategy: 'group', inner, binding: group.binding, key: group.key, carrier,
324
+ deps: memberDeps(analyzeQuery(inner).root),
316
325
  };
317
326
  }
318
327
 
@@ -324,6 +333,16 @@ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null)
324
333
  return rerun('a k-nearest ranking re-runs (the vector column cuts the candidates and the engine orders them)');
325
334
 
326
335
  const planned = planQuery(inner, queryShape, {});
336
+ const distinct = isJsonObject(inner) ? inner.$distinct : null;
337
+ if (!windowed && keyed && planned.mode === 'native' && isJsonObject(distinct)
338
+ && distinct.$orderby === undefined && bindingNameOf(distinct) !== null) {
339
+ const binding = bindingNameOf(distinct);
340
+ const name = binding === '_distinct' ? '_distinctKey' : '_distinct';
341
+ const grouped = { ...distinct, $groupby: { [name]: distinct.$return }, $return: `$${name}` };
342
+ return { strategy: 'distinct', inner: grouped, binding, key: [distinct.$return],
343
+ carrier: documentsSource(binding, distinct.$where),
344
+ deps: memberDeps(analyzeQuery(grouped).root) };
345
+ }
327
346
  if (planned.mode === 'set') {
328
347
  // §7's spatial rows: the fetch is SQL-narrowed by the pushed box or
329
348
  // cell range and the exact predicate is what per-row re-evaluation
@@ -346,6 +365,13 @@ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null)
346
365
  }
347
366
  if (!keyed) return rerun('rows without a document key cannot be tracked');
348
367
 
368
+ // SQL can group a complete selection without proving that evaluating
369
+ // the document once per changed row maintains its result. Canonical
370
+ // maintained groups were handled above; the remaining groups and
371
+ // distinct projections need set-level invalidation.
372
+ if (planned.plan.group !== null || planned.plan.bucket !== null)
373
+ return rerun('this grouping or distinct projection needs set-level maintenance');
374
+
349
375
  if (planned.plan.order !== null) {
350
376
  if (offset > 0 || (planned.plan.window?.offset ?? 0) > 0) {
351
377
  return rerun('an offset window re-runs');
@@ -523,6 +549,7 @@ function rowsStrategy(description, context) {
523
549
  return flatten();
524
550
  }),
525
551
  entries: () => flatten().length,
552
+ close: () => itemsByKey.clear(),
526
553
  apply(record, previousRows) {
527
554
  const touched = touchedKeys(record, context.name, description.deps);
528
555
  if (touched === null) return null;
@@ -602,6 +629,7 @@ function windowStrategy(description, context) {
602
629
  return visibleRows();
603
630
  }),
604
631
  entries: () => sortedWindow.size(),
632
+ close: () => sortedWindow.clear(),
605
633
  apply(record, previousRows) {
606
634
  const touched = touchedKeys(record, context.name, description.deps);
607
635
  if (touched === null) return null;
@@ -681,6 +709,7 @@ function accumulatorStrategy(description, context) {
681
709
  return rowsOf();
682
710
  }),
683
711
  entries: () => contributions.size,
712
+ close: () => contributions.clear(),
684
713
  stats: () => ({ ...stats }),
685
714
  apply(record, previousRows) {
686
715
  const touched = touchedKeys(record, context.name, description.deps);
@@ -713,121 +742,6 @@ function accumulatorStrategy(description, context) {
713
742
  };
714
743
  }
715
744
 
716
- /**
717
- * Canonical single-level `groupBy` with aggregate returns: §7's
718
- * per-group deltas — the accumulator machinery once per group, groups
719
- * in first-appearance order.
720
- * @param {any} description
721
- * @param {any} context
722
- */
723
- function groupStrategy(description, context) {
724
- const { group, rowDocument, carrier } = description;
725
- const evaluate = compileJsonQuery([rowDocument]);
726
-
727
- /** @type {Map<string, { key: any[], rows: Map<string, any>, row: any }>}
728
- * group token → per-row contributions, in first-appearance order */
729
- const groups = new Map();
730
-
731
- const contributionOf = (doc) => {
732
- const evaluated = /** @type {any[]} */ (evaluate([doc], context.externals));
733
- return evaluated.length === 0 ? undefined : evaluated[0];
734
- };
735
- const buildRow = (entry) => {
736
- /** @type {any} */
737
- const row = {};
738
- for (const member of group.members) {
739
- if (member.kind === 'key') {
740
- if (entry.key.length > 0) row[member.name] = entry.key[0];
741
- else if (member.defaulted) row[member.name] = null;
742
- continue;
743
- }
744
- let sum = 0;
745
- let count = 0;
746
- let extreme;
747
- let n = 0;
748
- for (const contribution of entry.rows.values()) {
749
- const items = /** @type {any[]} */ (contribution[member.name] ?? []);
750
- n += items.length;
751
- for (const value of items) {
752
- sum += value;
753
- count += 1;
754
- if (extreme === undefined
755
- || (member.fn === 'min' ? value < extreme : value > extreme)) extreme = value;
756
- }
757
- }
758
- if (member.fn === 'count') row[member.name] = n;
759
- else if (member.fn === 'sum') row[member.name] = sum;
760
- else if (count > 0) row[member.name] = member.fn === 'avg' ? sum / count : extreme;
761
- // an empty avg/min/max leaves the member absent, the engine's
762
- // empty-sequence rule
763
- }
764
- return row;
765
- };
766
- const rowsOf = () => [...groups.values()].map((entry) => entry.row);
767
-
768
- const placeRow = (token, doc, changedGroups) => {
769
- const contribution = doc === undefined ? undefined : contributionOf(doc);
770
- for (const [groupToken, entry] of groups) {
771
- if (!entry.rows.has(token)) continue;
772
- if (contribution !== undefined
773
- && stableStringify(entry.rows.get(token)) === stableStringify(contribution)) {
774
- return; // unchanged in place
775
- }
776
- entry.rows.delete(token);
777
- changedGroups.add(groupToken);
778
- break;
779
- }
780
- if (contribution === undefined) return;
781
- const groupToken = stableStringify(contribution.k) ?? '';
782
- let entry = groups.get(groupToken);
783
- if (entry === undefined) {
784
- entry = { key: contribution.k, rows: new Map(), row: null };
785
- groups.set(groupToken, entry);
786
- }
787
- entry.rows.set(token, contribution);
788
- changedGroups.add(groupToken);
789
- };
790
- const settle = (changedGroups) => {
791
- for (const groupToken of changedGroups) {
792
- const entry = groups.get(groupToken);
793
- if (entry === undefined) continue;
794
- if (entry.rows.size === 0) groups.delete(groupToken);
795
- else entry.row = sharedRow(entry.row, buildRow(entry));
796
- }
797
- };
798
-
799
- return {
800
- init: () => chain(context.execute([carrier], { externals: context.externals }),
801
- (docs) => {
802
- const changedGroups = new Set();
803
- for (const doc of /** @type {any[]} */ (docs)) {
804
- placeRow(context.keyOf(doc), doc, changedGroups);
805
- }
806
- settle(changedGroups);
807
- return rowsOf();
808
- }),
809
- entries: () => {
810
- let total = groups.size;
811
- for (const entry of groups.values()) total += entry.rows.size;
812
- return total;
813
- },
814
- apply(record, previousRows) {
815
- const touched = touchedKeys(record, context.name, description.deps);
816
- if (touched === null) return null;
817
- const changedGroups = new Set();
818
- for (const [token, change] of touched) {
819
- const doc = change.kind === 'delete'
820
- ? undefined
821
- : change.kind === 'insert' ? change.doc : context.readRow(token);
822
- placeRow(token, doc, changedGroups);
823
- }
824
- if (changedGroups.size === 0) return null;
825
- settle(changedGroups);
826
- return diffAgainst(previousRows, rowsOf());
827
- },
828
- };
829
- }
830
-
831
745
  /**
832
746
  * Everything outside the table: re-run the WHOLE query on
833
747
  * invalidation and diff against the previous result with value-equal
@@ -903,7 +817,8 @@ export function createLiveRegistry(bounds) {
903
817
  };
904
818
  const STRATEGIES = {
905
819
  rows: rowsStrategy, window: windowStrategy, accumulator: accumulatorStrategy,
906
- group: groupStrategy, bucket: bucketStrategy, rolling: rollingStrategy, join: joinStrategy, graph: joinStrategy,
820
+ group: nestedGroupStrategy, distinct: nestedGroupStrategy,
821
+ bucket: bucketStrategy, rolling: rollingStrategy, join: joinStrategy, graph: joinStrategy,
907
822
  'nested-group': nestedGroupStrategy,
908
823
  };
909
824
  const strategy = (STRATEGIES[classification.strategy] ?? rerunStrategy)(
@@ -955,7 +870,7 @@ export function createLiveRegistry(bounds) {
955
870
  outcome = ops.length === 0 && !late ? null
956
871
  : { ops, rows, ...(late ? { late: outcome.late } : {}) };
957
872
  }
958
- if (outcome !== null) checkBound(strategy.entries(outcome.rows));
873
+ checkBound(strategy.entries(outcome?.rows ?? state.result.rows));
959
874
  }
960
875
  catch (error) {
961
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