@jarenjs/db 0.49.2 → 0.66.1

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 (110) hide show
  1. package/ARCHITECTURE.md +420 -71
  2. package/README.md +711 -79
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +309 -45
  5. package/docs/LIVE-FORMAT.md +156 -19
  6. package/docs/MIGRATION-FORMAT.md +247 -40
  7. package/docs/MODEL-FORMAT.md +968 -86
  8. package/package.json +21 -8
  9. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  10. package/schemas/jaren-migration.schema.json +73 -0
  11. package/schemas/jaren-model.draft-07.schema.json +224 -162
  12. package/schemas/jaren-model.schema.json +224 -162
  13. package/src/algebra.js +227 -9
  14. package/src/backup.js +161 -0
  15. package/src/cancellation.js +48 -0
  16. package/src/capture.js +255 -44
  17. package/src/cli.js +337 -50
  18. package/src/cursor.js +411 -0
  19. package/src/dag-job.js +154 -21
  20. package/src/ddl.js +125 -11
  21. package/src/dialect.js +267 -112
  22. package/src/dialects/expression-read.js +158 -0
  23. package/src/dialects/postgres.js +618 -0
  24. package/src/dialects/rtree-ddl.js +129 -0
  25. package/src/dialects/sqlite.js +245 -12
  26. package/src/document-files.js +311 -0
  27. package/src/document-steps.js +422 -0
  28. package/src/documents.js +335 -0
  29. package/src/driver.js +503 -69
  30. package/src/drivers/bun.js +37 -1
  31. package/src/drivers/indexeddb-snapshot.js +149 -0
  32. package/src/drivers/node-pool.js +11 -0
  33. package/src/drivers/node-worker-endpoint.js +105 -0
  34. package/src/drivers/node-worker.js +204 -0
  35. package/src/drivers/node.js +41 -7
  36. package/src/drivers/postgres.js +331 -0
  37. package/src/drivers/wasm-oo1.js +97 -0
  38. package/src/drivers/wasm-session.js +67 -0
  39. package/src/drivers/wasm.js +18 -83
  40. package/src/drivers/worker-pool.js +183 -0
  41. package/src/drivers/worker-protocol.js +79 -0
  42. package/src/drivers/worker-queue.js +60 -0
  43. package/src/emit-model.js +14 -0
  44. package/src/emit.js +349 -51
  45. package/src/entity.js +102 -59
  46. package/src/errors.js +430 -2
  47. package/src/expression.js +284 -0
  48. package/src/graph.js +64 -8
  49. package/src/index.js +48 -19
  50. package/src/introspect.js +583 -0
  51. package/src/jobs.js +870 -99
  52. package/src/json-bytes.js +58 -0
  53. package/src/live-time.js +12 -3
  54. package/src/live.js +11 -1
  55. package/src/maintenance.js +175 -0
  56. package/src/migrate.js +606 -333
  57. package/src/model.js +241 -8
  58. package/src/plan.js +1238 -160
  59. package/src/pragmas.js +314 -0
  60. package/src/profile.js +151 -3
  61. package/src/query.js +1748 -312
  62. package/src/residual.js +17 -0
  63. package/src/series.js +12 -4
  64. package/src/store.js +1672 -276
  65. package/src/tracker.js +367 -68
  66. package/src/udf.js +88 -7
  67. package/types/index.d.ts +1246 -32
  68. package/types/node-pool.d.ts +28 -0
  69. package/types/node-worker.d.ts +54 -0
  70. package/types/node.d.ts +72 -3
  71. package/types/postgres.d.ts +46 -0
  72. package/types/typed.d.ts +81 -3
  73. package/types/wasm.d.ts +21 -0
  74. package/dist/types/algebra.d.ts +0 -230
  75. package/dist/types/app.d.ts +0 -49
  76. package/dist/types/capture.d.ts +0 -85
  77. package/dist/types/cli.d.ts +0 -2
  78. package/dist/types/dag-job.d.ts +0 -40
  79. package/dist/types/ddl.d.ts +0 -229
  80. package/dist/types/derive.d.ts +0 -250
  81. package/dist/types/dialect.d.ts +0 -154
  82. package/dist/types/dialects/sqlite.d.ts +0 -9
  83. package/dist/types/driver.d.ts +0 -110
  84. package/dist/types/drivers/bun.d.ts +0 -47
  85. package/dist/types/drivers/node.d.ts +0 -37
  86. package/dist/types/drivers/wasm.d.ts +0 -65
  87. package/dist/types/emit-model.d.ts +0 -44
  88. package/dist/types/emit.d.ts +0 -75
  89. package/dist/types/entity.d.ts +0 -23
  90. package/dist/types/errors.d.ts +0 -170
  91. package/dist/types/graph.d.ts +0 -28
  92. package/dist/types/index.d.ts +0 -37
  93. package/dist/types/jobs.d.ts +0 -140
  94. package/dist/types/knn.d.ts +0 -69
  95. package/dist/types/live-time.d.ts +0 -141
  96. package/dist/types/live.d.ts +0 -64
  97. package/dist/types/migrate.d.ts +0 -170
  98. package/dist/types/model.d.ts +0 -36
  99. package/dist/types/patch-sql.d.ts +0 -37
  100. package/dist/types/plan.d.ts +0 -142
  101. package/dist/types/profile.d.ts +0 -80
  102. package/dist/types/query.d.ts +0 -112
  103. package/dist/types/residual.d.ts +0 -64
  104. package/dist/types/series.d.ts +0 -227
  105. package/dist/types/store.d.ts +0 -60
  106. package/dist/types/tracker.d.ts +0 -43
  107. package/dist/types/typed.d.ts +0 -15
  108. package/dist/types/types.d.ts +0 -26
  109. package/dist/types/udf.d.ts +0 -75
  110. package/dist/types/window.d.ts +0 -52
package/src/migrate.js CHANGED
@@ -23,15 +23,28 @@
23
23
 
24
24
  import { canonicalizeJson } from '@jarenjs/json/canonical';
25
25
  import { hashContent } from '@jarenjs/core/string';
26
+ import { resolveRuntime } from '@jarenjs/core/runtime';
27
+ import { refuseCancelled } from './cancellation.js';
28
+ import { setObjectMember } from '@jarenjs/core/object';
26
29
  import { compileJsonQuery } from '@jarenjs/json/query';
27
30
  import { compileJsltStylesheet } from '@jarenjs/json/jslt';
28
31
 
29
32
  import { DbCompileError } from './errors.js';
30
33
  import { chain, toPromise } from './driver.js';
31
34
  import { normalizeModel } from './store.js';
35
+ import { CHANGES_TABLE, CHANGES_STATE_TABLE } from './capture.js';
36
+ import { JOBS_TABLE, JOB_CHECKPOINTS_TABLE } from './jobs.js';
32
37
  import { planCollection, verifyShape, planEntity, planJoinTable } from './ddl.js';
33
38
  import { normalizeEntities, explainMapping } from './model.js';
34
39
  import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
40
+ import { mergeEntityRow } from './graph.js';
41
+ import { entityCore } from './entity.js';
42
+ import {
43
+ MIGRATION_VERSION, isPerDocumentAssertion, compileDocumentStep, checkMigrationDocument,
44
+ normalizeAssertionBounds, ASSERTION_BOUNDS_DEFAULT, createAssertionBoundGuard,
45
+ } from './document-steps.js';
46
+
47
+ export { MIGRATION_VERSION, isPerDocumentAssertion, ASSERTION_BOUNDS_DEFAULT };
35
48
 
36
49
  /**
37
50
  * The physical mapping a connection's driver imposes on derived index
@@ -41,22 +54,30 @@ import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
41
54
  * @param {any} connection
42
55
  * @returns {{ derived: 'virtual' | 'stored', rtree: boolean }}
43
56
  */
44
- function mappingFor(connection) {
57
+ function mappingFor(connection, expressions = undefined) {
58
+ const registered = connection.capabilities?.deterministicIndexableFunctions === true;
45
59
  return {
46
- derived: connection.capabilities?.deterministicIndexableFunctions === true
47
- ? 'virtual' : 'stored',
60
+ derived: registered ? 'virtual' : 'stored',
48
61
  // the same reasoning for the R*Tree mapping: a build without the
49
62
  // module plans (and verifies) the B-tree shape
50
63
  rtree: connection.capabilities?.rtree === true,
64
+ // and the same for a declared index expression: this connection
65
+ // either computes it or calls the engine's own immutable function
66
+ expressions,
67
+ registered,
51
68
  };
52
69
  }
53
70
 
54
- /** The migration format version. */
55
- export const MIGRATION_VERSION = '0.1';
56
-
57
71
  /** The history table name (outside the model's identifier namespace
58
72
  * conventions on purpose — a collection cannot collide with it). */
59
73
  export const HISTORY_TABLE = '_jaren_migrations';
74
+ /** The tables the engine owns beside a model's: never a shape-drift finding. */
75
+ /** The tables this package owns. A model never declared one, so one
76
+ * found in a database is the engine's own bookkeeping rather than
77
+ * anybody's drift — the drift check skips them and the introspector
78
+ * does not derive them. */
79
+ export const ENGINE_TABLES = new Set([HISTORY_TABLE, CHANGES_TABLE, CHANGES_STATE_TABLE,
80
+ JOBS_TABLE, JOB_CHECKPOINTS_TABLE]);
60
81
 
61
82
  /**
62
83
  * The signature-grade identity of a model SHAPE.
@@ -64,7 +85,41 @@ export const HISTORY_TABLE = '_jaren_migrations';
64
85
  * @returns {string}
65
86
  */
66
87
  export function shapeHash(model) {
67
- return hashContent(canonicalizeJson(model));
88
+ return hashContent(canonicalizeJson(withoutRenameHints(model)));
89
+ }
90
+
91
+ /**
92
+ * The model without its `x-rename` hints. A hint is a PLANNING
93
+ * instruction, not shape: two models that differ only by the hint
94
+ * describe the same database, and hashing the hint made an empty
95
+ * migration necessary just to move the recorded shape once the hint
96
+ * was removed.
97
+ * @param {any} model
98
+ * @returns {any}
99
+ */
100
+ function withoutRenameHints(model) {
101
+ if (model === null || typeof model !== 'object') return model;
102
+ const out = {};
103
+ for (const key of Object.keys(model)) setObjectMember(out, key, model[key]);
104
+ for (const member of ['collections', 'entities']) {
105
+ const declared = model[member];
106
+ if (declared === null || typeof declared !== 'object' || Array.isArray(declared)) continue;
107
+ const stripped = {};
108
+ for (const name of Object.keys(declared)) {
109
+ const spec = declared[name];
110
+ if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)
111
+ && Object.hasOwn(spec, 'x-rename')) {
112
+ const copy = { ...spec };
113
+ delete copy['x-rename'];
114
+ setObjectMember(stripped, name, copy);
115
+ }
116
+ else {
117
+ setObjectMember(stripped, name, spec);
118
+ }
119
+ }
120
+ out[member] = stripped;
121
+ }
122
+ return out;
68
123
  }
69
124
 
70
125
  /**
@@ -125,7 +180,8 @@ function deriveStep(collection, plan, columnNames, note) {
125
180
  * @param {any} fromModel
126
181
  * @param {any} toModel
127
182
  * @param {{ id?: string, dialect?: any,
128
- * derived?: 'virtual' | 'stored', rtree?: boolean }} [options]
183
+ * derived?: 'virtual' | 'stored', rtree?: boolean,
184
+ * expressions?: Record<string, any> }} [options]
129
185
  * @returns {{ migration: any, report: {
130
186
  * renamed: { from: string, to: string }[],
131
187
  * added: string[], removed: string[],
@@ -136,9 +192,13 @@ export function planMigration(fromModel, toModel, options = undefined) {
136
192
  const dialect = options?.dialect ?? null;
137
193
  if (dialect === null || typeof dialect !== 'object')
138
194
  throw new TypeError('planMigration needs { dialect } (the store dialect renders the DDL)');
139
- const mapping = { derived: options?.derived ?? 'virtual', rtree: options?.rtree !== false };
140
- const fromCollections = normalizeModel(fromModel);
141
- const toCollections = normalizeModel(toModel);
195
+ const mapping = { derived: options?.derived ?? 'virtual', rtree: options?.rtree !== false,
196
+ // a model that declares an index EXPRESSION resolves its functions
197
+ // here too: a plan is DDL, and DDL over a function this planner was
198
+ // not told about is DDL nobody can apply
199
+ expressions: options?.expressions, registered: options?.derived !== 'stored' };
200
+ const fromCollections = normalizeModel(fromModel, options?.expressions);
201
+ const toCollections = normalizeModel(toModel, options?.expressions);
142
202
 
143
203
  const steps = [];
144
204
  const report = {
@@ -153,6 +213,10 @@ export function planMigration(fromModel, toModel, options = undefined) {
153
213
  const hint = toModel.collections[name]?.['x-rename'];
154
214
  if (hint === undefined) continue;
155
215
  if (!fromCollections.has(hint)) {
216
+ // the hint's work is done once the from-model already declares the
217
+ // target and no longer the source: planning a model against itself
218
+ // must yield nothing, not refuse the hint it still carries
219
+ if (fromCollections.has(name)) continue;
156
220
  throw new TypeError(
157
221
  `x-rename on '${name}' names '${hint}', which the from-model does not declare`);
158
222
  }
@@ -423,6 +487,7 @@ function planEntityChanges(fromModel, toModel, dialect, steps, report) {
423
487
  const hint = toModel.entities[name]?.['x-rename'];
424
488
  if (hint === undefined) continue;
425
489
  if (!fromEntities.has(hint)) {
490
+ if (fromEntities.has(name)) continue; // a satisfied hint (see the collections)
426
491
  throw new TypeError(
427
492
  `x-rename on entity '${name}' names '${hint}', which the from-model does not declare`);
428
493
  }
@@ -434,30 +499,62 @@ function planEntityChanges(fromModel, toModel, dialect, steps, report) {
434
499
  report.renamed.push({ from: hint, to: name });
435
500
  steps.push({ kind: 'ddl', sql: dialect.ddl.renameTable(hint, name),
436
501
  note: `rename entity '${hint}' to '${name}'` });
437
- for (const joinName of Object.keys(fromMapping.joinTables)) {
438
- const pair = joinName.split('_');
439
- if (!pair.includes(hint)) continue;
440
- const renamedPair = pair.map((part) => (part === hint ? name : part)).sort();
441
- const newJoin = renamedPair.join('_');
502
+ }
503
+ const consumedOldEntities = new Set(renamedFrom.values());
504
+ // a join table's endpoints come from the MAPPING, never from splitting
505
+ // its name: an entity named with an underscore, or a `through` name,
506
+ // does not split into its endpoints — and a split that guessed wrong
507
+ // renamed the table to a name nothing declares, created the declared
508
+ // one empty, and dropped the memberships as "destructive"
509
+ const implicitJoinName = (join) => [join.left.entity, join.right.entity].sort().join('_');
510
+ const renamedEntity = (entityName) => {
511
+ for (const [to, from] of renamedFrom) if (from === entityName) return to;
512
+ return entityName;
513
+ };
514
+ const renamedJoinName = (joinName) => {
515
+ const join = fromMapping.joinTables[joinName];
516
+ if (joinName !== implicitJoinName(join)) return joinName; // a `through` name stays
517
+ return [renamedEntity(join.left.entity), renamedEntity(join.right.entity)].sort().join('_');
518
+ };
519
+ for (const [joinName, join] of Object.entries(fromMapping.joinTables)) {
520
+ if (![join.left, join.right].some((side) => renamedEntity(side.entity) !== side.entity)) continue;
521
+ const newJoin = renamedJoinName(joinName);
522
+ const target = toMapping.joinTables[newJoin];
523
+ if (target === undefined) continue; // the relation is gone: the drop below names it
524
+ // the fresh build orders the endpoint columns by the SORTED entity
525
+ // names, and a rename can flip that order — then the primary key's
526
+ // column order would differ from a fresh build's and the shape
527
+ // check would refuse the migrated database, so the table is rebuilt
528
+ // in the target order with its rows copied; when the order holds,
529
+ // renaming the table and the column is enough
530
+ const renamedColumns = [join.left, join.right]
531
+ .map((side) => ({ from: side.column, to: `${renamedEntity(side.entity)}_key` }));
532
+ const targetOrder = [target.left.column, target.right.column];
533
+ const sameOrder = renamedColumns.every((column, i) => column.to === targetOrder[i]);
534
+ if (sameOrder) {
442
535
  if (newJoin !== joinName) {
443
536
  steps.push({ kind: 'ddl', sql: dialect.ddl.renameTable(joinName, newJoin),
444
537
  note: `rename join table '${joinName}' with its endpoint` });
445
- steps.push({ kind: 'ddl',
446
- sql: dialect.ddl.renameColumn(newJoin, `${hint}_key`, `${name}_key`),
447
- note: `rename the endpoint column '${hint}_key' with its entity` });
448
538
  }
539
+ for (const column of renamedColumns) {
540
+ if (column.from === column.to) continue;
541
+ steps.push({ kind: 'ddl', sql: dialect.ddl.renameColumn(newJoin, column.from, column.to),
542
+ note: `rename the endpoint column '${column.from}' with its entity` });
543
+ }
544
+ continue;
449
545
  }
546
+ const q = dialect.quoteIdentifier;
547
+ const sourceOf = (toColumn) => renamedColumns.find((column) => column.to === toColumn).from;
548
+ for (const sql of planJoinTable(newJoin, target, toMapping, dialect).createSql) {
549
+ steps.push({ kind: 'ddl', sql, note: `rebuild join table '${joinName}' as '${newJoin}' in its endpoint order` });
550
+ }
551
+ steps.push({ kind: 'sql',
552
+ sql: `INSERT INTO ${q(newJoin)} (${targetOrder.map(q).join(', ')}) `
553
+ + `SELECT ${targetOrder.map((column) => q(sourceOf(column))).join(', ')} FROM ${q(joinName)}`,
554
+ note: `copy the memberships of '${joinName}' into '${newJoin}'` });
555
+ steps.push({ kind: 'ddl', sql: dialect.ddl.dropTable(joinName),
556
+ note: `drop '${joinName}' — its memberships now live in '${newJoin}'` });
450
557
  }
451
- const consumedOldEntities = new Set(renamedFrom.values());
452
- const renamedJoinName = (joinName) => {
453
- const pair = joinName.split('_');
454
- return pair
455
- .map((part) => {
456
- for (const [to, from] of renamedFrom) if (from === part) return to;
457
- return part;
458
- })
459
- .sort().join('_');
460
- };
461
558
 
462
559
  // ————— per-entity strategies —————
463
560
  for (const [name, toEntity] of toEntities) {
@@ -589,9 +686,26 @@ function planEntityChanges(fromModel, toModel, dialect, steps, report) {
589
686
  }
590
687
  }
591
688
 
592
- // ————— dropped entities —————
593
- for (const [name] of fromEntities) {
594
- if (toEntities.has(name) || consumedOldEntities.has(name)) continue;
689
+ // ————— dropped entities, children before parents —————
690
+ // foreign keys are enforced while a migration runs (node:sqlite has
691
+ // them on by default), so a parent with RESTRICT children cannot go
692
+ // first: the dropped set is ordered so that every entity referencing
693
+ // another dropped entity is dropped before it
694
+ const dropped = [...fromEntities.keys()]
695
+ .filter((name) => !toEntities.has(name) && !consumedOldEntities.has(name));
696
+ const droppedSet = new Set(dropped);
697
+ const references = (name) => new Set(fromMapping.entities[name].foreignKeys
698
+ .map((fk) => fk.references).filter((target) => droppedSet.has(target) && target !== name));
699
+ const dropOrder = [];
700
+ const placed = new Set();
701
+ while (dropOrder.length < dropped.length) {
702
+ // ready: every dropped entity that no other UNPLACED dropped entity references
703
+ const ready = dropped.filter((name) => !placed.has(name)
704
+ && !dropped.some((other) => !placed.has(other) && other !== name && references(other).has(name)));
705
+ if (ready.length === 0) { dropOrder.push(...dropped.filter((name) => !placed.has(name))); break; }
706
+ for (const name of ready) { placed.add(name); dropOrder.push(name); }
707
+ }
708
+ for (const name of dropOrder) {
595
709
  report.removed.push(name);
596
710
  report.destructive = true;
597
711
  steps.push({ kind: 'ddl', sql: dialect.ddl.dropTable(name),
@@ -650,6 +764,12 @@ function renderRebuild(name, fromName, fm, tm, fromMapping, toMapping, dialect,
650
764
  const sources = [];
651
765
  let docExpr = q('doc');
652
766
  const lost = [];
767
+ // a SQL NULL is an ABSENT member (§9.3): folding it in as JSON null
768
+ // turned every row without the value into a narrowing the target
769
+ // schema refused
770
+ const foldColumn = (expression, columnName, fold) =>
771
+ `CASE WHEN ${q(columnName)} IS NULL THEN ${expression} `
772
+ + `ELSE ${dialect.jsonSet(expression, pathText(columnName), fold)} END`;
653
773
  for (const { name: columnName, column } of ordered) {
654
774
  targets.push(q(columnName));
655
775
  if (fromHas(columnName)) {
@@ -659,9 +779,10 @@ function renderRebuild(name, fromName, fm, tm, fromMapping, toMapping, dialect,
659
779
  if (column !== null && column.source === 'epoch(document)'
660
780
  && fromColumn !== undefined && fromColumn.source !== 'epoch(document)') {
661
781
  // plain text column becomes a derived instant: derive from the
662
- // old column and keep the string in the document
782
+ // old column and keep the string in the document — an absent
783
+ // string stays absent (§9.3), never a JSON null
663
784
  sources.push(dialect.epochFromRfc3339(q(columnName)));
664
- docExpr = dialect.jsonSet(docExpr, pathText(columnName), q(columnName));
785
+ docExpr = foldColumn(docExpr, columnName, q(columnName));
665
786
  }
666
787
  else if (sameStorage) {
667
788
  sources.push(q(columnName));
@@ -686,12 +807,25 @@ function renderRebuild(name, fromName, fm, tm, fromMapping, toMapping, dialect,
686
807
  const fold = fromColumn.storage === 'boolean'
687
808
  ? dialect.jsonEncode(`CASE WHEN ${q(columnName)} = 1 THEN 'true' ELSE 'false' END`)
688
809
  : q(columnName);
689
- docExpr = dialect.jsonSet(docExpr, pathText(columnName), fold);
810
+ docExpr = foldColumn(docExpr, columnName, fold);
690
811
  }
691
812
  else if (fromColumn.source !== 'epoch(document)') {
692
813
  lost.push(columnName);
693
814
  }
694
815
  }
816
+ // an INFERRED foreign-key column (no property of its own) that the
817
+ // target no longer carries: its values are lost unless the target
818
+ // declares the property, in which case they fold into the document —
819
+ // walking the mapped columns alone dropped it without a word
820
+ for (const columnName of fromFkOnly) {
821
+ if (ordered.some((entry) => entry.name === columnName)) continue;
822
+ if (toMapping.entities[name] !== undefined && tm.document.includes(columnName)) {
823
+ docExpr = foldColumn(docExpr, columnName, q(columnName));
824
+ }
825
+ else {
826
+ lost.push(columnName);
827
+ }
828
+ }
695
829
  // properties that moved INTO columns leave the document
696
830
  for (const { name: columnName, column } of ordered) {
697
831
  if (!fromHas(columnName) && (column === null || column.source !== 'epoch(document)'))
@@ -718,15 +852,19 @@ function renderRebuild(name, fromName, fm, tm, fromMapping, toMapping, dialect,
718
852
  * equality compares against, and the tests.
719
853
  * @param {any} connection
720
854
  * @param {any} model
855
+ * @param {Record<string, any>} [expressions] - the host's declared
856
+ * index-expression functions, resolved into the DDL the same way the
857
+ * open path resolves them
721
858
  * @returns {any} value-or-promise
722
859
  */
723
- export function createModelShape(connection, model) {
860
+ export function createModelShape(connection, model, expressions = undefined) {
724
861
  const dialect = connection.dialect;
725
862
  /** @type {string[]} */
726
863
  const statements = [];
727
- for (const collection of normalizeModel(model).values()) {
864
+ for (const collection of normalizeModel(model, expressions).values()) {
728
865
  statements.push(
729
- ...planCollection(collection.name, collection, dialect, mappingFor(connection)).createSql);
866
+ ...planCollection(collection.name, collection, dialect,
867
+ mappingFor(connection, expressions)).createSql);
730
868
  }
731
869
  const entities = normalizeEntities(model);
732
870
  if (entities.size > 0) {
@@ -759,7 +897,9 @@ export function schemaShapeOf(connection) {
759
897
  const dialect = connection.dialect;
760
898
  return chain(connection.prepare(dialect.introspect.schemaDump()), (statement) =>
761
899
  chain(statement.all([]), (rows) => rows
762
- .filter((row) => row.name !== HISTORY_TABLE && row.owner !== HISTORY_TABLE)
900
+ // the engine's own tables history, the change log and its state
901
+ // row, the job queue — are never a model's drift
902
+ .filter((row) => !ENGINE_TABLES.has(String(row.name)) && !ENGINE_TABLES.has(String(row.owner)))
763
903
  .map((row) => ({
764
904
  type: String(row.type),
765
905
  name: String(row.name),
@@ -824,14 +964,22 @@ function normalizeSchemaSql(sql) {
824
964
  * @param {((connection: any) => any) | undefined} registerFunctions
825
965
  * @returns {any} value-or-promise of `string | null`
826
966
  */
827
- export function compareShapeToModel(driver, connection, model, registerFunctions) {
967
+ export function compareShapeToModel(driver, connection, model, registerFunctions, expressions) {
968
+ // The comparison IS a text comparison: it builds the model's shape in
969
+ // a reference database and compares the two engines' stored CREATE
970
+ // statements. An engine that keeps none has nothing to compare, and
971
+ // says so here rather than reading an undefined statement — the
972
+ // structural drift check (columns, indexes, foreign-key tuples) runs
973
+ // per collection and per entity either way, and `declaredSqlText` is
974
+ // what tells a reader which half they got.
975
+ if (connection.dialect.capabilities.declaredSqlText !== true) return null;
828
976
  return chain(driver.open(':memory:', {}), (reference) =>
829
977
  chain(chain(registerDeriveFunctions(reference),
830
978
  () => (registerFunctions !== undefined ? registerFunctions(reference) : null)), () => {
831
979
  const finish = (result) => chain(reference.close(), () => result);
832
980
  let outcome;
833
981
  try {
834
- outcome = chain(createModelShape(reference, model), () =>
982
+ outcome = chain(createModelShape(reference, model, expressions), () =>
835
983
  chain(schemaShapeOf(reference), (wanted) =>
836
984
  chain(schemaShapeOf(connection), (actual) => {
837
985
  const wantedText = JSON.stringify(wanted);
@@ -864,54 +1012,6 @@ export function compareShapeToModel(driver, connection, model, registerFunctions
864
1012
  }));
865
1013
  }
866
1014
 
867
- const STEP_KINDS = new Set(['ddl', 'jslt', 'query', 'sql', 'rebuild', 'derive']);
868
-
869
- /**
870
- * Structural validation of one migration document, including the
871
- * draft refusal (`JD0021`).
872
- * @param {any} migration
873
- */
874
- function checkMigrationDocument(migration) {
875
- if (migration === null || typeof migration !== 'object'
876
- || migration.$migration !== MIGRATION_VERSION
877
- || typeof migration.id !== 'string' || migration.id === ''
878
- || typeof migration.from !== 'string' || typeof migration.to !== 'string'
879
- || !Array.isArray(migration.steps)) {
880
- throw refuse('JD0023',
881
- `migration '${migration?.id ?? '<unknown>'}' is not a valid ${MIGRATION_VERSION} migration document`);
882
- }
883
- for (let i = 0; i < migration.steps.length; i++) {
884
- const step = migration.steps[i];
885
- if (step === null || typeof step !== 'object' || !STEP_KINDS.has(step.kind)) {
886
- throw refuse('JD0023',
887
- `migration '${migration.id}' step ${i} has no recognised kind`);
888
- }
889
- if (step.kind === 'rebuild'
890
- && (typeof step.table !== 'string' || !Array.isArray(step.create)
891
- || typeof step.copy !== 'string' || !Array.isArray(step.indexes))) {
892
- throw refuse('JD0023',
893
- `migration '${migration.id}' step ${i} is a rebuild without its rendered `
894
- + 'table/create/copy/indexes');
895
- }
896
- if (step.kind === 'sql' && typeof step.sql !== 'string') {
897
- throw refuse('JD0023',
898
- `migration '${migration.id}' step ${i} is a sql step without sql text`);
899
- }
900
- if (step.kind === 'derive'
901
- && (typeof step.collection !== 'string' || !Array.isArray(step.columns)
902
- || step.columns.length === 0)) {
903
- throw refuse('JD0023',
904
- `migration '${migration.id}' step ${i} is a derive backfill without its columns`);
905
- }
906
- if (step.kind === 'jslt' && step.draft === true) {
907
- throw refuse('JD0021',
908
- `migration '${migration.id}' step ${i} is a DRAFT transform for collection `
909
- + `'${step.collection}' — the planner cannot infer a data transform; fill in `
910
- + 'the stylesheet (or delete the step for a pure widening) and remove "draft"');
911
- }
912
- }
913
- }
914
-
915
1015
  /**
916
1016
  * The batched row walk shared by transforms and post-validation:
917
1017
  * `SELECT rowid, json(doc) ... WHERE rowid > ? ORDER BY rowid LIMIT ?`
@@ -921,38 +1021,67 @@ function checkMigrationDocument(migration) {
921
1021
  * @param {number} batchSize
922
1022
  * @param {(rows: { rid: any, doc: string, key: any }[]) => any} handle
923
1023
  * value-or-promise per batch
1024
+ * @param {boolean} [keyed]
1025
+ * @param {any} [entityMapping]
1026
+ * @param {() => void} [check] - run before every batch: the
1027
+ * cancellation boundary a data step has between its batches
924
1028
  * @returns {any}
925
1029
  */
926
- function walkRows(connection, table, batchSize, handle, keyed = true) {
1030
+ function walkRows(connection, table, batchSize, handle, keyed = true, entityMapping = null,
1031
+ check = undefined) {
927
1032
  // entity tables carry no 'key' column — the transform walk goes by
928
- // row identity alone; only the collection walks select the key
1033
+ // row identity alone; only the collection walks select the key. An
1034
+ // entity's mapped columns ride beside the document so the row can be
1035
+ // read WHOLE (`mergeEntityRow`): a transform or an assertion that saw
1036
+ // the rest-document alone could not see `id` or `name` at all
929
1037
  const dialect = connection.dialect;
930
1038
  const q = dialect.quoteIdentifier;
931
1039
  const rid = dialect.rowIdentity();
932
1040
  const keySelect = keyed ? `, ${q('key')} AS ${q('k')}` : '';
933
- const sql = `SELECT ${rid} AS ${q('rid')}, ${dialect.jsonText(q('doc'))} AS ${q('doc')}`
934
- + `${keySelect} FROM ${q(table)} WHERE ${rid} > ${dialect.parameterRef(1, 'after')} `
935
- + `ORDER BY ${rid} ${dialect.limitClause(batchSize, undefined)}`;
936
- return chain(connection.prepare(sql), (statement) => {
937
- const nextBatch = (after) =>
938
- chain(statement.all([after]), (rows) => {
1041
+ const columnSelect = entityMapping === null ? '' : entityColumnsOf(entityMapping)
1042
+ .map((column) => `, ${q(column)}`).join('');
1043
+ const select = `SELECT ${rid} AS ${q('rid')}, ${dialect.jsonText(q('doc'))} AS ${q('doc')}`
1044
+ + `${keySelect}${columnSelect} FROM ${q(table)}`;
1045
+ const ordered = ` ORDER BY ${rid} ${dialect.limitClause(batchSize, undefined)}`;
1046
+ // An INTEGER PRIMARY KEY can be negative. The first batch has no
1047
+ // lower bound; subsequent batches seek from a row actually read.
1048
+ return chain(connection.prepare(select + ordered), (first) => chain(connection.prepare(
1049
+ `${select} WHERE ${rid} > ${dialect.parameterRef(1, 'after')}${ordered}`), (statement) => {
1050
+ const nextBatch = (after) => {
1051
+ if (check !== undefined) check();
1052
+ return chain(after === undefined ? first.all([]) : statement.all([after]), (rows) => {
939
1053
  if (rows.length === 0) return null;
940
1054
  return chain(handle(rows), () =>
941
1055
  nextBatch(rows[rows.length - 1].rid));
942
1056
  });
943
- return nextBatch(-1);
944
- });
1057
+ };
1058
+ return nextBatch(undefined);
1059
+ }));
945
1060
  }
946
1061
 
947
- /** All documents of a collection (the assertion steps' working set
948
- * a documented whole-collection read). */
949
- function allDocs(connection, table) {
950
- const dialect = connection.dialect;
951
- const q = dialect.quoteIdentifier;
952
- const sql = `SELECT ${dialect.jsonText(q('doc'))} AS ${q('doc')} FROM ${q(table)} `
953
- + `ORDER BY ${dialect.rowIdentity()}`;
954
- return chain(connection.prepare(sql), (statement) =>
955
- chain(statement.all([]), (rows) => rows.map((row) => JSON.parse(row.doc))));
1062
+ /** The physical columns an entity row carries beside its document. */
1063
+ function entityColumnsOf(entityMapping) {
1064
+ const names = new Set(entityMapping.columns.map((column) => column.name));
1065
+ for (const fk of entityMapping.foreignKeys) names.add(fk.column);
1066
+ return [...names];
1067
+ }
1068
+
1069
+ /**
1070
+ * The entity mapping a migration step over `table` runs under, or
1071
+ * `null` for a collection. The TARGET model maps the table: a chain's
1072
+ * intermediate shapes are hashes only, so an entity transform belongs
1073
+ * to the last migration of a chain (MIGRATION-FORMAT §9); without a
1074
+ * target model the step sees the rest-document, as it always did.
1075
+ * @param {any} options
1076
+ * @param {string} table
1077
+ * @returns {{ entity: any, mapping: any } | null}
1078
+ */
1079
+ function entityStepMapping(options, table) {
1080
+ if (options.model === undefined) return null;
1081
+ const entities = normalizeEntities(options.model);
1082
+ const entity = entities.get(table);
1083
+ if (entity === undefined) return null;
1084
+ return { entity, mapping: explainMapping(options.model).entities[table] };
956
1085
  }
957
1086
 
958
1087
  /**
@@ -967,6 +1096,9 @@ function runSteps(connection, migration, options) {
967
1096
  const q = dialect.quoteIdentifier;
968
1097
  const step = (i) => {
969
1098
  if (i >= migration.steps.length) return null;
1099
+ // the cancellation boundary between steps: a refusal here rolls the
1100
+ // migration in flight back whole, as any step failure does
1101
+ if (options.check !== undefined) options.check();
970
1102
  const current = migration.steps[i];
971
1103
  const fail = (reason, cause) => {
972
1104
  throw refuse('JD0023',
@@ -1001,6 +1133,7 @@ function runSteps(connection, migration, options) {
1001
1133
  ];
1002
1134
  const runNext = (j) => {
1003
1135
  if (j >= statements.length) {
1136
+ if (dialect.capabilities.foreignKeysAlwaysOn === true) return null;
1004
1137
  return chain(connection.prepare(dialect.pragma.foreignKeyCheck()),
1005
1138
  (checkStatement) => chain(checkStatement.all([]), (violations) => {
1006
1139
  if (violations.length > 0) {
@@ -1046,16 +1179,48 @@ function runSteps(connection, migration, options) {
1046
1179
  collection: current.collection,
1047
1180
  derived: derivedRows,
1048
1181
  });
1049
- }, false), () => derivedRows));
1182
+ }, false, null, options.check), () => derivedRows));
1050
1183
  }
1051
1184
  if (current.kind === 'jslt') {
1052
- let transform;
1053
- try {
1054
- transform = compileJsltStylesheet(current.stylesheet);
1055
- }
1056
- catch (cause) {
1057
- return fail(`the stylesheet does not compile: ${/** @type {Error} */ (cause).message}`,
1058
- /** @type {Error} */ (cause));
1185
+ const stepEntity = entityStepMapping(options, current.collection);
1186
+ const operation = compileDocumentStep(current, i, {
1187
+ migrationId: migration.id,
1188
+ compileJslt: compileJsltStylesheet,
1189
+ compileQuery: compileJsonQuery,
1190
+ keys: stepEntity === null ? [] : stepEntity.mapping.keys,
1191
+ });
1192
+ if (stepEntity !== null) {
1193
+ // an entity row is transformed WHOLE: the mapped columns fold in
1194
+ // before the stylesheet and split out after it, through the
1195
+ // entity's own split — a column-mapped member the stylesheet
1196
+ // wrote used to land in the document and be shadowed on read
1197
+ const core = entityCore(connection, stepEntity.entity, stepEntity.mapping, null,
1198
+ options.runtime);
1199
+ const columns = entityColumnsOf(stepEntity.mapping);
1200
+ const assignments = [
1201
+ ...columns.map((column, i) => `${q(column)} = ${dialect.parameterRef(i + 1, 'v')}`),
1202
+ `${q('doc')} = ${dialect.jsonEncode(dialect.parameterRef(columns.length + 1, 'doc'))}`,
1203
+ ];
1204
+ const updateSql = `UPDATE ${q(current.collection)} SET ${assignments.join(', ')} `
1205
+ + `WHERE ${dialect.rowIdentity()} = ${dialect.parameterRef(columns.length + 2, 'rid')}`;
1206
+ let transformed = 0;
1207
+ return chain(connection.prepare(updateSql), (update) =>
1208
+ chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
1209
+ for (const row of rows) {
1210
+ const whole = mergeEntityRow(stepEntity.mapping, row, 'doc');
1211
+ const next = operation.apply(whole, row.rid);
1212
+ const { values, rest } = core.plan.split(next);
1213
+ const byName = new Map(values.map((value) => [value.name, value.value]));
1214
+ update.run([...columns.map((column) => byName.get(column) ?? null),
1215
+ JSON.stringify(rest), row.rid]);
1216
+ transformed++;
1217
+ }
1218
+ options.onProgress?.({
1219
+ migration: migration.id,
1220
+ collection: current.collection,
1221
+ transformed,
1222
+ });
1223
+ }, false, stepEntity.mapping, options.check), () => transformed));
1059
1224
  }
1060
1225
  const updateSql = `UPDATE ${q(current.collection)} SET ${q('doc')} = `
1061
1226
  + `${dialect.jsonEncode(dialect.parameterRef(1, 'doc'))} `
@@ -1065,9 +1230,7 @@ function runSteps(connection, migration, options) {
1065
1230
  return chain(connection.prepare(updateSql), (update) =>
1066
1231
  chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
1067
1232
  for (const row of rows) {
1068
- const next = transform(JSON.parse(row.doc));
1069
- if (next === null || typeof next !== 'object' || Array.isArray(next))
1070
- fail(`the transform produced a non-document for row ${row.rid}`);
1233
+ const next = operation.apply(JSON.parse(row.doc), row.rid);
1071
1234
  update.run([JSON.stringify(next), row.rid]);
1072
1235
  transformed++;
1073
1236
  }
@@ -1076,29 +1239,66 @@ function runSteps(connection, migration, options) {
1076
1239
  collection: current.collection,
1077
1240
  transformed,
1078
1241
  });
1079
- }, keyed), () => transformed));
1242
+ }, keyed, null, options.check), () => transformed));
1080
1243
  }
1081
1244
  // kind === 'query': the assertion step
1082
- let compiled;
1083
- try {
1084
- compiled = compileJsonQuery(current.assert);
1245
+ const assertionMapping = entityStepMapping(options, current.collection)?.mapping ?? null;
1246
+ const operation = compileDocumentStep(current, i, {
1247
+ migrationId: migration.id,
1248
+ compileJslt: compileJsltStylesheet,
1249
+ compileQuery: compileJsonQuery,
1250
+ });
1251
+ const assertOver = operation.assert;
1252
+ const readDoc = (row) => (assertionMapping === null
1253
+ ? JSON.parse(row.doc)
1254
+ : mergeEntityRow(assertionMapping, row, 'doc'));
1255
+
1256
+ if (operation.fold !== null) {
1257
+ // an associative aggregate: each batch is answered by the engine
1258
+ // and the partial answers combine, so the collection is never
1259
+ // held. The whole-collection read this replaces is the one
1260
+ // statement a cross-document assertion used to cost.
1261
+ let accumulated = operation.fold.start();
1262
+ let folded = 0;
1263
+ return chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
1264
+ accumulated = operation.fold.combine(accumulated, rows.map(readDoc));
1265
+ folded += rows.length;
1266
+ options.onProgress?.({
1267
+ migration: migration.id,
1268
+ collection: current.collection,
1269
+ asserted: folded,
1270
+ });
1271
+ }, false, assertionMapping, options.check), () => operation.fold.finish(accumulated));
1085
1272
  }
1086
- catch (cause) {
1087
- return fail(`the assertion does not compile: ${/** @type {Error} */ (cause).message}`,
1088
- /** @type {Error} */ (cause));
1273
+
1274
+ if (!operation.perDocument) {
1275
+ // materializing: the answer needs every document at once. That is
1276
+ // a cost, so it is bounded and the bound is crossed BEFORE the
1277
+ // excess is held — the walk stops at the row that would break it
1278
+ const guard = createAssertionBoundGuard(options.assertionBounds, current.collection,
1279
+ `the assertion of migration '${migration.id}' step ${i}`);
1280
+ const gathered = [];
1281
+ return chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
1282
+ for (const row of rows) {
1283
+ const doc = readDoc(row);
1284
+ guard.admit(doc, typeof row.doc === 'string' ? row.doc : undefined);
1285
+ gathered.push(doc);
1286
+ }
1287
+ }, false, assertionMapping, options.check), () => assertOver(gathered));
1089
1288
  }
1090
- return chain(allDocs(connection, current.collection), (docs) => {
1091
- if (current.expect === 'ebv') {
1092
- if (!compiled.ebv(docs)) fail('the EBV assertion answered false');
1093
- return null;
1094
- }
1095
- const result = compiled(docs);
1096
- if (result !== undefined) {
1097
- const count = Array.isArray(result) ? result.length : 1;
1098
- fail(`the assertion expected an empty sequence, got ${count} item(s)`);
1099
- }
1100
- return null;
1101
- });
1289
+ // per-document: walk in keyset batches like every other step,
1290
+ // failing fast at the first batch that violates
1291
+ let asserted = 0;
1292
+ return walkRows(connection, current.collection, options.batchSize, (rows) => {
1293
+ const docs = rows.map(readDoc);
1294
+ assertOver(docs);
1295
+ asserted += docs.length;
1296
+ options.onProgress?.({
1297
+ migration: migration.id,
1298
+ collection: current.collection,
1299
+ asserted,
1300
+ });
1301
+ }, false, assertionMapping, options.check);
1102
1302
  }), () => step(i + 1));
1103
1303
  };
1104
1304
  return step(0);
@@ -1116,10 +1316,9 @@ function runSteps(connection, migration, options) {
1116
1316
  * @returns {any} value-or-promise
1117
1317
  */
1118
1318
  function validateTargetState(connection, model, options) {
1119
- const collections = [...normalizeModel(model).values()];
1319
+ const collections = [...normalizeModel(model, options.expressions).values()];
1120
1320
  const entities = [...normalizeEntities(model).values()];
1121
1321
  const dialect = connection.dialect;
1122
- const q = dialect.quoteIdentifier;
1123
1322
  // entity tables carry no 'key' column; the batched walk goes by row
1124
1323
  // identity and validates every stored document against the target
1125
1324
  const verifyEntity = (i) => {
@@ -1129,32 +1328,27 @@ function validateTargetState(connection, model, options) {
1129
1328
  ? options.compileSchema(entity.schema)
1130
1329
  : null;
1131
1330
  if (validate === null) return verifyEntity(i + 1);
1132
- const rid = dialect.rowIdentity();
1133
- const sql = `SELECT ${rid} AS ${q('rid')}, ${dialect.jsonText(q('doc'))} AS ${q('doc')} `
1134
- + `FROM ${q(entity.name)} WHERE ${rid} > ${dialect.parameterRef(1, 'after')} `
1135
- + `ORDER BY ${rid} ${dialect.limitClause(options.batchSize, undefined)}`;
1136
- return chain(connection.prepare(sql), (statement) => {
1137
- const nextBatch = (after) =>
1138
- chain(statement.all([after]), (rows) => {
1139
- if (rows.length === 0) return null;
1140
- for (const row of rows) {
1141
- const outcome = validate(JSON.parse(row.doc));
1142
- const valid = outcome === true || outcome?.valid === true;
1143
- if (!valid) {
1144
- throw refuse('JD0021',
1145
- `entity '${entity.name}': a stored document (row ${row.rid}) does not `
1146
- + 'validate against the target schema a narrowing needs a data transform');
1147
- }
1148
- }
1149
- return nextBatch(rows[rows.length - 1].rid);
1150
- });
1151
- return nextBatch(-1);
1152
- });
1331
+ // the WHOLE document — mapped columns folded in — is what the target
1332
+ // schema judges; the rest-document alone failed every entity whose
1333
+ // required members are columns, so a pure widening could not land
1334
+ const entityMapping = explainMapping(model).entities[entity.name];
1335
+ return chain(walkRows(connection, entity.name, options.batchSize, (rows) => {
1336
+ for (const row of rows) {
1337
+ const outcome = validate(mergeEntityRow(entityMapping, row, 'doc'));
1338
+ const valid = outcome === true || outcome?.valid === true;
1339
+ if (!valid) {
1340
+ throw refuse('JD0021',
1341
+ `entity '${entity.name}': a stored document (row ${row.rid}) does not `
1342
+ + 'validate against the target schema — a narrowing needs a data transform');
1343
+ }
1344
+ }
1345
+ }, false, entityMapping), () => verifyEntity(i + 1));
1153
1346
  };
1154
1347
  const verifyNext = (i) => {
1155
1348
  if (i >= collections.length) return null;
1156
1349
  const collection = collections[i];
1157
- const plan = planCollection(collection.name, collection, dialect, mappingFor(connection));
1350
+ const plan = planCollection(collection.name, collection, dialect,
1351
+ mappingFor(connection, options.expressions));
1158
1352
  const validate = options.compileSchema !== undefined
1159
1353
  ? options.compileSchema(collection.schema)
1160
1354
  : null;
@@ -1212,8 +1406,13 @@ function replayOnShadow(driver, shadowPath, baseline, migrations, model, options
1212
1406
  (options.registerFunctions !== undefined ? options.registerFunctions(shadow) : null));
1213
1407
  const apply = (i) => {
1214
1408
  if (i >= migrations.length) return null;
1409
+ // a rebuild moves rows between tables while their keys point at
1410
+ // the old one, so the switch comes off around it — on an engine
1411
+ // that HAS a switch. One that always enforces cannot rebuild that
1412
+ // way, and `alterTableFull` is why it never has to
1215
1413
  const bracket = migrations[i].steps.some(
1216
- (candidate) => candidate.kind === 'rebuild');
1414
+ (candidate) => candidate.kind === 'rebuild')
1415
+ && shadow.dialect.capabilities.foreignKeysAlwaysOn !== true;
1217
1416
  return chain(
1218
1417
  bracket ? shadow.exec(shadow.dialect.pragma.foreignKeys(false)) : null,
1219
1418
  () => chain(runSteps(shadow, migrations[i], options), () =>
@@ -1221,13 +1420,13 @@ function replayOnShadow(driver, shadowPath, baseline, migrations, model, options
1221
1420
  () => apply(i + 1))));
1222
1421
  };
1223
1422
  const run = () => chain(registered, () =>
1224
- chain(createModelShape(shadow, baseline), () => chain(apply(0), () => {
1423
+ chain(createModelShape(shadow, baseline, options.expressions), () => chain(apply(0), () => {
1225
1424
  if (model === undefined) return null;
1226
- const target = [...normalizeModel(model).values()];
1425
+ const target = [...normalizeModel(model, options.expressions).values()];
1227
1426
  const verifyNext = (i) => {
1228
1427
  if (i >= target.length) return null;
1229
1428
  const plan = planCollection(target[i].name, target[i], shadow.dialect,
1230
- mappingFor(shadow));
1429
+ mappingFor(shadow, options.expressions));
1231
1430
  return chain(
1232
1431
  verifyShape(shadow, plan, target[i].name, target[i].docPath),
1233
1432
  () => verifyNext(i + 1));
@@ -1289,18 +1488,33 @@ function historyStatements(dialect) {
1289
1488
  }
1290
1489
 
1291
1490
  /**
1292
- * Report a database's migration state without touching it: what is
1293
- * applied, what is pending, whether an applied migration was edited,
1294
- * and once the chain is fully applied whether the physical shape
1295
- * DRIFTED from the model (someone changed the database by hand, §12).
1491
+ * Report a database's migration state without touching it the
1492
+ * history table is probed, never created, so a fresh file stays byte
1493
+ * for byte what it was: what is applied, what is pending, whether an
1494
+ * applied migration was edited, and once the chain is fully applied —
1495
+ * whether the physical shape DRIFTED from the model (someone changed
1496
+ * the database by hand, §12).
1296
1497
  * @param {{ driver: any, path?: string }} target
1297
1498
  * @param {any[]} migrations - the full ordered list
1298
- * @param {{ baseline: any, model?: any,
1299
- * registerFunctions?: (connection: any) => any }} options
1499
+ * @param {{ model?: any, registerFunctions?: (connection: any) => any,
1500
+ * signal?: AbortSignal, deadline?: number,
1501
+ * runtime?: Partial<import('@jarenjs/core/runtime').Runtime> }} options
1502
+ * - `signal`/`deadline` refuse a call already cancelled (`JD2080`) or
1503
+ * past its deadline (`JD2075`) on the runtime record's clock
1300
1504
  * @returns {Promise<{ applied: string[], pending: string[],
1301
1505
  * drift: string | null, upToDate: boolean }>}
1302
1506
  */
1303
- export function migrationStatus(target, migrations, options) {
1507
+ export function migrationStatus(target, migrations, options = {}) {
1508
+ // a call already cancelled, or past its deadline on the caller's
1509
+ // clock, opens nothing
1510
+ try {
1511
+ refuseCancelled({ signal: options.signal, deadline: options.deadline },
1512
+ resolveRuntime(options.runtime).now,
1513
+ { abortCode: 'JD2080', aborted: 'it ran', passed: 'the status read ran', ran: 'no step ran' });
1514
+ }
1515
+ catch (error) {
1516
+ return Promise.reject(error);
1517
+ }
1304
1518
  return toPromise(chain(
1305
1519
  target.driver.open(target.path ?? ':memory:', {}),
1306
1520
  (connection) => {
@@ -1310,10 +1524,15 @@ export function migrationStatus(target, migrations, options) {
1310
1524
  const failClosed = (error) => chain(connection.close(), () => { throw error; });
1311
1525
  let work;
1312
1526
  try {
1527
+ // §6's "writes NOTHING" holds for a status read too: the history
1528
+ // table is probed, never created, and an absent one reads as an
1529
+ // empty history — the same promise the dry run makes
1313
1530
  work = chain(registerDeriveFunctions(connection), () =>
1314
- chain(connection.exec(statements.create), () =>
1315
- chain(connection.prepare(statements.select), (select) =>
1316
- chain(select.all([]), (rows) => {
1531
+ chain(chain(connection.prepare(dialect.introspect.tableExists()), (probe) =>
1532
+ chain(probe.get([HISTORY_TABLE]), (present) => (present === undefined
1533
+ ? []
1534
+ : chain(connection.prepare(statements.select), (select) => select.all([]))))),
1535
+ (rows) => chain(rows, () => {
1317
1536
  for (let i = 0; i < rows.length; i++) {
1318
1537
  const doc = migrations[i];
1319
1538
  if (doc === undefined || doc.id !== rows[i].id
@@ -1335,7 +1554,7 @@ export function migrationStatus(target, migrations, options) {
1335
1554
  (difference) => ({
1336
1555
  applied, pending, drift: difference, upToDate: difference === null,
1337
1556
  }));
1338
- }))));
1557
+ })));
1339
1558
  }
1340
1559
  catch (error) {
1341
1560
  return failClosed(error);
@@ -1360,7 +1579,17 @@ export function migrationStatus(target, migrations, options) {
1360
1579
  * @param {any[]} migrations
1361
1580
  * @param {{ baseline: any, model?: any, compileSchema?: Function,
1362
1581
  * dryRun?: boolean, batchSize?: number, onProgress?: Function,
1363
- * shadow?: boolean, shadowPath?: string }} options
1582
+ * shadow?: boolean, shadowPath?: string, shadowDriver?: any,
1583
+ * signal?: AbortSignal, deadline?: number,
1584
+ * runtime?: Partial<import('@jarenjs/core/runtime').Runtime> }} options
1585
+ * `signal` and `deadline` cancel between migrations, steps and
1586
+ * batches (`JD2080` / `JD2075`, the deadline read against `runtime`'s
1587
+ * clock); a cancelled migration rolls back whole and the completed
1588
+ * ones stand.
1589
+ * `runtime` is the host's runtime record: the clock every applied
1590
+ * migration is stamped with, and the clock and identifiers an entity
1591
+ * step's `default: 'now'` / `default: 'uuid'` fill; the platform's own
1592
+ * when absent
1364
1593
  * @returns {Promise<any>}
1365
1594
  */
1366
1595
  export function migrate(target, migrations, options) {
@@ -1375,11 +1604,40 @@ export function migrate(target, migrations, options) {
1375
1604
  'migrate needs { baseline }: the model the store was first created with '
1376
1605
  + '(the chain anchor and the shadow starting shape)');
1377
1606
  const batchSize = options.batchSize ?? 500;
1607
+ const runtime = resolveRuntime(options.runtime);
1608
+ /**
1609
+ * The cancellation boundary: between migrations, between steps and
1610
+ * between the batches of a data step, on the runtime record's clock.
1611
+ * Nothing interrupts a statement that has started; a refusal inside a
1612
+ * migration rolls that migration back whole and the completed ones
1613
+ * stand, so a rerun resumes from the recorded position.
1614
+ */
1615
+ const check = () => refuseCancelled({ signal: options.signal, deadline: options.deadline }, runtime.now, {
1616
+ abortCode: 'JD2080', aborted: 'its next step', passed: 'its next step',
1617
+ ran: 'no further step ran; a migration in flight rolled back whole and a rerun resumes '
1618
+ + 'from the recorded position',
1619
+ });
1378
1620
  const runOptions = {
1379
1621
  batchSize,
1622
+ assertionBounds: normalizeAssertionBounds(options.assertionBounds),
1380
1623
  onProgress: options.onProgress,
1381
1624
  registerFunctions: options.registerFunctions,
1625
+ // the host's declared index-expression functions ride to every
1626
+ // planner and every connection this run opens — the shadow's
1627
+ // baseline, the reference database and the real store all resolve a
1628
+ // declared expression against the same declarations the open path
1629
+ // was given, or they would plan DDL nobody can apply
1630
+ expressions: options.expressions,
1631
+ model: options.model,
1632
+ runtime,
1633
+ check,
1382
1634
  };
1635
+ try {
1636
+ check();
1637
+ }
1638
+ catch (error) {
1639
+ return Promise.reject(error);
1640
+ }
1383
1641
 
1384
1642
  return toPromise(chain(
1385
1643
  target.driver.open(target.path ?? ':memory:', { timeout: target.busyTimeout ?? 5000 }),
@@ -1395,174 +1653,189 @@ export function migrate(target, migrations, options) {
1395
1653
 
1396
1654
  let work;
1397
1655
  try {
1398
- work = chain(connection.exec(statements.create), () =>
1399
- chain(connection.prepare(statements.select), (select) =>
1400
- chain(select.all([]), (appliedRows) => {
1401
- // the list must agree with the history: same ids, same
1402
- // order, same checksums an edited applied migration is
1403
- // always a bug worth failing on
1404
- for (let i = 0; i < appliedRows.length; i++) {
1405
- const row = appliedRows[i];
1406
- const doc = migrations[i];
1407
- if (doc === undefined || doc.id !== row.id) {
1408
- throw refuse('JD0022',
1409
- `history position ${i} records '${row.id}' but the migration list has `
1410
- + `'${doc?.id ?? '<nothing>'}' the list must contain every applied `
1411
- + 'migration, in order');
1412
- }
1413
- if (migrationChecksum(doc) !== row.checksum) {
1414
- throw refuse('JD0022',
1415
- `migration '${row.id}' differs from the document recorded in the `
1416
- + 'history an applied migration must never be edited');
1417
- }
1656
+ // §6's "writes NOTHING": an apply creates the empty history
1657
+ // table before reading it, a DRY RUN probes for it instead and
1658
+ // reads an absent one as an empty history — the promise a dry
1659
+ // run makes is the reason it is safe to point at production
1660
+ const history = options.dryRun === true
1661
+ ? chain(connection.prepare(dialect.introspect.tableExists()), (probe) =>
1662
+ chain(probe.get([HISTORY_TABLE]), (row) => (row === undefined
1663
+ ? []
1664
+ : chain(connection.prepare(statements.select), (select) => select.all([])))))
1665
+ : chain(connection.exec(statements.create), () =>
1666
+ chain(connection.prepare(statements.select), (select) => select.all([])));
1667
+ work = chain(history, (appliedRows) => {
1668
+ // the list must agree with the history: same ids, same
1669
+ // order, same checksums — an edited applied migration is
1670
+ // always a bug worth failing on
1671
+ for (let i = 0; i < appliedRows.length; i++) {
1672
+ const row = appliedRows[i];
1673
+ const doc = migrations[i];
1674
+ if (doc === undefined || doc.id !== row.id) {
1675
+ throw refuse('JD0022',
1676
+ `history position ${i} records '${row.id}' but the migration list has `
1677
+ + `'${doc?.id ?? '<nothing>'}' — the list must contain every applied `
1678
+ + 'migration, in order');
1418
1679
  }
1419
- const pending = migrations.slice(appliedRows.length);
1420
- const currentShape = appliedRows.length > 0
1421
- ? appliedRows[appliedRows.length - 1].to_hash
1422
- : shapeHash(options.baseline);
1423
-
1424
- let expectedFrom = currentShape;
1425
- for (const migration of pending) {
1426
- checkMigrationDocument(migration);
1427
- if (migration.from !== expectedFrom) {
1428
- throw refuse('JD0020',
1429
- `migration '${migration.id}' expects shape '${migration.from}' but the `
1430
- + `database is at '${expectedFrom}' — refusing to run against the wrong shape`);
1431
- }
1432
- expectedFrom = migration.to;
1680
+ if (migrationChecksum(doc) !== row.checksum) {
1681
+ throw refuse('JD0022',
1682
+ `migration '${row.id}' differs from the document recorded in the `
1683
+ + 'history — an applied migration must never be edited');
1433
1684
  }
1434
- if (options.model !== undefined && pending.length > 0
1435
- && expectedFrom !== shapeHash(options.model)) {
1685
+ }
1686
+ const pending = migrations.slice(appliedRows.length);
1687
+ const currentShape = appliedRows.length > 0
1688
+ ? appliedRows[appliedRows.length - 1].to_hash
1689
+ : shapeHash(options.baseline);
1690
+
1691
+ let expectedFrom = currentShape;
1692
+ for (const migration of pending) {
1693
+ checkMigrationDocument(migration);
1694
+ if (migration.from !== expectedFrom) {
1436
1695
  throw refuse('JD0020',
1437
- "the last migration's to-hash is not the target model's shape the "
1438
- + 'migration chain and the code disagree about where this ends');
1696
+ `migration '${migration.id}' expects shape '${migration.from}' but the `
1697
+ + `database is at '${expectedFrom}' refusing to run against the wrong shape`);
1439
1698
  }
1699
+ expectedFrom = migration.to;
1700
+ }
1701
+ if (options.model !== undefined && pending.length > 0
1702
+ && expectedFrom !== shapeHash(options.model)) {
1703
+ throw refuse('JD0020',
1704
+ "the last migration's to-hash is not the target model's shape — the "
1705
+ + 'migration chain and the code disagree about where this ends');
1706
+ }
1440
1707
 
1441
- if (pending.length === 0) {
1442
- return { applied: [], skipped: appliedRows.map((row) => row.id), upToDate: true };
1443
- }
1708
+ if (pending.length === 0) {
1709
+ return { applied: [], skipped: appliedRows.map((row) => row.id), upToDate: true };
1710
+ }
1444
1711
 
1445
- const shadowRun = options.shadow === false
1446
- ? null
1447
- : replayOnShadow(target.driver, options.shadowPath ?? ':memory:',
1448
- options.baseline, migrations, options.model, runOptions);
1449
-
1450
- return chain(shadowRun, () => {
1451
- if (options.dryRun === true) {
1452
- const rendered = [];
1453
- const counts = {};
1454
- const collect = (i) => {
1455
- if (i >= pending.length) return null;
1456
- const migration = pending[i];
1457
- for (const migrationStep of migration.steps) {
1458
- if (migrationStep.kind === 'ddl') rendered.push(migrationStep.sql);
1459
- else if (migrationStep.kind === 'sql') {
1460
- rendered.push(`-- data step (sql): ${migrationStep.note ?? ''}`);
1461
- rendered.push(migrationStep.sql);
1462
- }
1463
- else if (migrationStep.kind === 'rebuild') {
1464
- rendered.push(`-- rebuild '${migrationStep.table}' (§10 procedure)`);
1465
- rendered.push(...migrationStep.create, migrationStep.copy,
1466
- dialect.ddl.dropTable(migrationStep.table),
1467
- dialect.ddl.renameTable(`${migrationStep.table}__rebuild`,
1468
- migrationStep.table),
1469
- ...migrationStep.indexes,
1470
- dialect.pragma.foreignKeyCheck());
1471
- }
1472
- else if (migrationStep.kind === 'jslt')
1473
- rendered.push(`-- jslt transform over '${migrationStep.collection}'`);
1474
- else rendered.push(`-- assert over '${migrationStep.collection}'`);
1475
- }
1476
- const jsltCollections = [...new Set(migration.steps
1477
- .filter((s) => s.kind === 'jslt').map((s) => s.collection))];
1478
- const count = (j) => {
1479
- if (j >= jsltCollections.length) return null;
1480
- const table = jsltCollections[j];
1481
- const countSql = `SELECT COUNT(*) AS ${dialect.quoteIdentifier('n')} `
1482
- + `FROM ${dialect.quoteIdentifier(table)}`;
1483
- return chain(connection.prepare(countSql), (statement) =>
1484
- chain(statement.get([]), (row) => {
1485
- counts[table] = row.n;
1486
- return count(j + 1);
1487
- }));
1488
- };
1489
- return chain(count(0), () => collect(i + 1));
1490
- };
1491
- return chain(collect(0), () => ({
1492
- dryRun: true,
1493
- pending: pending.map((migration) => migration.id),
1494
- statements: rendered,
1495
- counts,
1496
- shadowValidated: options.shadow !== false,
1497
- }));
1498
- }
1712
+ const shadowRun = options.shadow === false
1713
+ ? null
1714
+ : replayOnShadow(options.shadowDriver ?? target.driver,
1715
+ options.shadowPath ?? ':memory:',
1716
+ options.baseline, migrations, options.model, runOptions);
1499
1717
 
1500
- // the real run: one exclusive transaction per migration
1501
- const applied = [];
1502
- const applyNext = (i) => {
1718
+ return chain(shadowRun, () => {
1719
+ if (options.dryRun === true) {
1720
+ const rendered = [];
1721
+ const counts = {};
1722
+ const collect = (i) => {
1503
1723
  if (i >= pending.length) return null;
1504
1724
  const migration = pending[i];
1505
- const last = i === pending.length - 1;
1506
- // the §10 procedure's pragma bracket, literally: the
1507
- // foreign_keys pragma is a no-op inside a transaction,
1508
- // and node:sqlite enables enforcement BY DEFAULT — a
1509
- // parent-table rebuild could not even DROP without this
1510
- const bracket = migration.steps.some(
1511
- (candidate) => candidate.kind === 'rebuild');
1512
- return chain(
1513
- bracket ? connection.exec(dialect.pragma.foreignKeys(false)) : null,
1514
- () => chain(connection.exec(dialect.tx.beginImmediate), () => {
1515
- const body = () => chain(runSteps(connection, migration, runOptions), () =>
1516
- chain(last && options.model !== undefined
1517
- ? chain(validateTargetState(connection, options.model,
1518
- { compileSchema: options.compileSchema, batchSize }),
1519
- () => (normalizeEntities(options.model).size === 0 ? null
1520
- : chain(compareShapeToModel(target.driver, connection,
1521
- options.model, options.registerFunctions), (difference) => {
1522
- if (difference !== null) {
1523
- throw refuse('JD0023',
1524
- `the migrated shape does not equal the target model's: ${difference}`);
1525
- }
1526
- return null;
1527
- })))
1528
- : null,
1529
- () => chain(connection.prepare(statements.insert), (insert) =>
1530
- insert.run([migration.id, Date.now(), migration.from,
1531
- migration.to, migrationChecksum(migration),
1532
- migration.steps.length]))));
1533
- const restore = () => (bracket
1534
- ? connection.exec(dialect.pragma.foreignKeys(true)) : null);
1535
- const commit = () => chain(connection.exec(dialect.tx.commit), () =>
1536
- chain(restore(), () => {
1537
- applied.push(migration.id);
1538
- return applyNext(i + 1);
1539
- }));
1540
- const rollback = (error) =>
1541
- chain(connection.exec(dialect.tx.rollback), () =>
1542
- chain(restore(), () => { throw error; }));
1543
- // only body() may route to this migration's rollback:
1544
- // commit() chains the NEXT migration, whose failure
1545
- // rolls ITSELF back — catching it here would roll
1546
- // back a transaction that already committed
1547
- let outcome;
1548
- try {
1549
- outcome = body();
1725
+ for (const migrationStep of migration.steps) {
1726
+ if (migrationStep.kind === 'ddl') rendered.push(migrationStep.sql);
1727
+ else if (migrationStep.kind === 'sql') {
1728
+ rendered.push(`-- data step (sql): ${migrationStep.note ?? ''}`);
1729
+ rendered.push(migrationStep.sql);
1550
1730
  }
1551
- catch (error) {
1552
- return rollback(error);
1731
+ else if (migrationStep.kind === 'rebuild') {
1732
+ rendered.push(`-- rebuild '${migrationStep.table}' (§10 procedure)`);
1733
+ rendered.push(...migrationStep.create, migrationStep.copy,
1734
+ dialect.ddl.dropTable(migrationStep.table),
1735
+ dialect.ddl.renameTable(`${migrationStep.table}__rebuild`,
1736
+ migrationStep.table),
1737
+ ...migrationStep.indexes);
1738
+ if (dialect.capabilities.foreignKeysAlwaysOn !== true)
1739
+ rendered.push(dialect.pragma.foreignKeyCheck());
1553
1740
  }
1554
- return outcome instanceof Promise
1555
- ? outcome.then(commit, rollback)
1556
- : commit();
1557
- }));
1741
+ else if (migrationStep.kind === 'jslt')
1742
+ rendered.push(`-- jslt transform over '${migrationStep.collection}'`);
1743
+ else rendered.push(`-- assert over '${migrationStep.collection}'`);
1744
+ }
1745
+ const jsltCollections = [...new Set(migration.steps
1746
+ .filter((s) => s.kind === 'jslt').map((s) => s.collection))];
1747
+ const count = (j) => {
1748
+ if (j >= jsltCollections.length) return null;
1749
+ const table = jsltCollections[j];
1750
+ const countSql = `SELECT COUNT(*) AS ${dialect.quoteIdentifier('n')} `
1751
+ + `FROM ${dialect.quoteIdentifier(table)}`;
1752
+ return chain(connection.prepare(countSql), (statement) =>
1753
+ chain(statement.get([]), (row) => {
1754
+ counts[table] = row.n;
1755
+ return count(j + 1);
1756
+ }));
1757
+ };
1758
+ return chain(count(0), () => collect(i + 1));
1558
1759
  };
1559
- return chain(applyNext(0), () => ({
1560
- applied,
1561
- skipped: appliedRows.map((row) => row.id),
1562
- shape: expectedFrom,
1760
+ return chain(collect(0), () => ({
1761
+ dryRun: true,
1762
+ pending: pending.map((migration) => migration.id),
1763
+ statements: rendered,
1764
+ counts,
1765
+ shadowValidated: options.shadow !== false,
1563
1766
  }));
1564
- });
1565
- })));
1767
+ }
1768
+
1769
+ // the real run: one exclusive transaction per migration
1770
+ const applied = [];
1771
+ const applyNext = (i) => {
1772
+ if (i >= pending.length) return null;
1773
+ // the boundary between migrations
1774
+ check();
1775
+ const migration = pending[i];
1776
+ const last = i === pending.length - 1;
1777
+ // the §10 procedure's pragma bracket, literally: the
1778
+ // foreign_keys pragma is a no-op inside a transaction,
1779
+ // and node:sqlite enables enforcement BY DEFAULT — a
1780
+ // parent-table rebuild could not even DROP without this
1781
+ const bracket = migration.steps.some(
1782
+ (candidate) => candidate.kind === 'rebuild');
1783
+ return chain(
1784
+ bracket && dialect.capabilities.foreignKeysAlwaysOn !== true
1785
+ ? connection.exec(dialect.pragma.foreignKeys(false)) : null,
1786
+ () => chain(connection.exec(dialect.tx.beginImmediate), () => {
1787
+ const body = () => chain(runSteps(connection, migration, runOptions), () =>
1788
+ chain(last && options.model !== undefined
1789
+ ? chain(validateTargetState(connection, options.model,
1790
+ { compileSchema: options.compileSchema, batchSize }),
1791
+ () => (normalizeEntities(options.model).size === 0 ? null
1792
+ : chain(compareShapeToModel(target.driver, connection,
1793
+ options.model, options.registerFunctions), (difference) => {
1794
+ if (difference !== null) {
1795
+ throw refuse('JD0023',
1796
+ `the migrated shape does not equal the target model's: ${difference}`);
1797
+ }
1798
+ return null;
1799
+ })))
1800
+ : null,
1801
+ () => chain(connection.prepare(statements.insert), (insert) =>
1802
+ insert.run([migration.id, runtime.now(), migration.from,
1803
+ migration.to, migrationChecksum(migration),
1804
+ migration.steps.length]))));
1805
+ const restore = () => (bracket
1806
+ && dialect.capabilities.foreignKeysAlwaysOn !== true
1807
+ ? connection.exec(dialect.pragma.foreignKeys(true)) : null);
1808
+ const commit = () => chain(connection.exec(dialect.tx.commit), () =>
1809
+ chain(restore(), () => {
1810
+ applied.push(migration.id);
1811
+ return applyNext(i + 1);
1812
+ }));
1813
+ const rollback = (error) =>
1814
+ chain(connection.exec(dialect.tx.rollback), () =>
1815
+ chain(restore(), () => { throw error; }));
1816
+ // only body() may route to this migration's rollback:
1817
+ // commit() chains the NEXT migration, whose failure
1818
+ // rolls ITSELF back — catching it here would roll
1819
+ // back a transaction that already committed
1820
+ let outcome;
1821
+ try {
1822
+ outcome = body();
1823
+ }
1824
+ catch (error) {
1825
+ return rollback(error);
1826
+ }
1827
+ return outcome instanceof Promise
1828
+ ? outcome.then(commit, rollback)
1829
+ : commit();
1830
+ }));
1831
+ };
1832
+ return chain(applyNext(0), () => ({
1833
+ applied,
1834
+ skipped: appliedRows.map((row) => row.id),
1835
+ shape: expectedFrom,
1836
+ }));
1837
+ });
1838
+ });
1566
1839
 
1567
1840
  }
1568
1841
  catch (error) {