@jarenjs/db 0.49.2 → 0.56.0

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 (70) hide show
  1. package/ARCHITECTURE.md +27 -15
  2. package/README.md +141 -41
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +38 -9
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +232 -30
  7. package/package.json +4 -5
  8. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  9. package/schemas/jaren-migration.schema.json +73 -0
  10. package/src/capture.js +66 -28
  11. package/src/cli.js +225 -44
  12. package/src/ddl.js +23 -3
  13. package/src/dialects/sqlite.js +2 -1
  14. package/src/driver.js +63 -16
  15. package/src/drivers/wasm.js +1 -0
  16. package/src/emit-model.js +14 -0
  17. package/src/emit.js +10 -3
  18. package/src/entity.js +92 -47
  19. package/src/errors.js +25 -0
  20. package/src/index.js +2 -2
  21. package/src/jobs.js +40 -5
  22. package/src/live-time.js +12 -3
  23. package/src/live.js +11 -1
  24. package/src/migrate.js +397 -191
  25. package/src/model.js +173 -8
  26. package/src/plan.js +135 -38
  27. package/src/query.js +138 -13
  28. package/src/store.js +221 -66
  29. package/src/tracker.js +173 -48
  30. package/types/index.d.ts +152 -10
  31. package/types/node.d.ts +3 -1
  32. package/types/typed.d.ts +58 -2
  33. package/types/wasm.d.ts +7 -0
  34. package/dist/types/algebra.d.ts +0 -230
  35. package/dist/types/app.d.ts +0 -49
  36. package/dist/types/capture.d.ts +0 -85
  37. package/dist/types/cli.d.ts +0 -2
  38. package/dist/types/dag-job.d.ts +0 -40
  39. package/dist/types/ddl.d.ts +0 -229
  40. package/dist/types/derive.d.ts +0 -250
  41. package/dist/types/dialect.d.ts +0 -154
  42. package/dist/types/dialects/sqlite.d.ts +0 -9
  43. package/dist/types/driver.d.ts +0 -110
  44. package/dist/types/drivers/bun.d.ts +0 -47
  45. package/dist/types/drivers/node.d.ts +0 -37
  46. package/dist/types/drivers/wasm.d.ts +0 -65
  47. package/dist/types/emit-model.d.ts +0 -44
  48. package/dist/types/emit.d.ts +0 -75
  49. package/dist/types/entity.d.ts +0 -23
  50. package/dist/types/errors.d.ts +0 -170
  51. package/dist/types/graph.d.ts +0 -28
  52. package/dist/types/index.d.ts +0 -37
  53. package/dist/types/jobs.d.ts +0 -140
  54. package/dist/types/knn.d.ts +0 -69
  55. package/dist/types/live-time.d.ts +0 -141
  56. package/dist/types/live.d.ts +0 -64
  57. package/dist/types/migrate.d.ts +0 -170
  58. package/dist/types/model.d.ts +0 -36
  59. package/dist/types/patch-sql.d.ts +0 -37
  60. package/dist/types/plan.d.ts +0 -142
  61. package/dist/types/profile.d.ts +0 -80
  62. package/dist/types/query.d.ts +0 -112
  63. package/dist/types/residual.d.ts +0 -64
  64. package/dist/types/series.d.ts +0 -227
  65. package/dist/types/store.d.ts +0 -60
  66. package/dist/types/tracker.d.ts +0 -43
  67. package/dist/types/typed.d.ts +0 -15
  68. package/dist/types/types.d.ts +0 -26
  69. package/dist/types/udf.d.ts +0 -75
  70. package/dist/types/window.d.ts +0 -52
package/src/migrate.js CHANGED
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { canonicalizeJson } from '@jarenjs/json/canonical';
25
25
  import { hashContent } from '@jarenjs/core/string';
26
+ import { setObjectMember } from '@jarenjs/core/object';
26
27
  import { compileJsonQuery } from '@jarenjs/json/query';
27
28
  import { compileJsltStylesheet } from '@jarenjs/json/jslt';
28
29
 
@@ -32,6 +33,8 @@ import { normalizeModel } from './store.js';
32
33
  import { planCollection, verifyShape, planEntity, planJoinTable } from './ddl.js';
33
34
  import { normalizeEntities, explainMapping } from './model.js';
34
35
  import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
36
+ import { mergeEntityRow } from './graph.js';
37
+ import { entityCore } from './entity.js';
35
38
 
36
39
  /**
37
40
  * The physical mapping a connection's driver imposes on derived index
@@ -64,7 +67,41 @@ export const HISTORY_TABLE = '_jaren_migrations';
64
67
  * @returns {string}
65
68
  */
66
69
  export function shapeHash(model) {
67
- return hashContent(canonicalizeJson(model));
70
+ return hashContent(canonicalizeJson(withoutRenameHints(model)));
71
+ }
72
+
73
+ /**
74
+ * The model without its `x-rename` hints. A hint is a PLANNING
75
+ * instruction, not shape: two models that differ only by the hint
76
+ * describe the same database, and hashing the hint made an empty
77
+ * migration necessary just to move the recorded shape once the hint
78
+ * was removed.
79
+ * @param {any} model
80
+ * @returns {any}
81
+ */
82
+ function withoutRenameHints(model) {
83
+ if (model === null || typeof model !== 'object') return model;
84
+ const out = {};
85
+ for (const key of Object.keys(model)) setObjectMember(out, key, model[key]);
86
+ for (const member of ['collections', 'entities']) {
87
+ const declared = model[member];
88
+ if (declared === null || typeof declared !== 'object' || Array.isArray(declared)) continue;
89
+ const stripped = {};
90
+ for (const name of Object.keys(declared)) {
91
+ const spec = declared[name];
92
+ if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)
93
+ && Object.hasOwn(spec, 'x-rename')) {
94
+ const copy = { ...spec };
95
+ delete copy['x-rename'];
96
+ setObjectMember(stripped, name, copy);
97
+ }
98
+ else {
99
+ setObjectMember(stripped, name, spec);
100
+ }
101
+ }
102
+ out[member] = stripped;
103
+ }
104
+ return out;
68
105
  }
69
106
 
70
107
  /**
@@ -153,6 +190,10 @@ export function planMigration(fromModel, toModel, options = undefined) {
153
190
  const hint = toModel.collections[name]?.['x-rename'];
154
191
  if (hint === undefined) continue;
155
192
  if (!fromCollections.has(hint)) {
193
+ // the hint's work is done once the from-model already declares the
194
+ // target and no longer the source: planning a model against itself
195
+ // must yield nothing, not refuse the hint it still carries
196
+ if (fromCollections.has(name)) continue;
156
197
  throw new TypeError(
157
198
  `x-rename on '${name}' names '${hint}', which the from-model does not declare`);
158
199
  }
@@ -423,6 +464,7 @@ function planEntityChanges(fromModel, toModel, dialect, steps, report) {
423
464
  const hint = toModel.entities[name]?.['x-rename'];
424
465
  if (hint === undefined) continue;
425
466
  if (!fromEntities.has(hint)) {
467
+ if (fromEntities.has(name)) continue; // a satisfied hint (see the collections)
426
468
  throw new TypeError(
427
469
  `x-rename on entity '${name}' names '${hint}', which the from-model does not declare`);
428
470
  }
@@ -434,30 +476,62 @@ function planEntityChanges(fromModel, toModel, dialect, steps, report) {
434
476
  report.renamed.push({ from: hint, to: name });
435
477
  steps.push({ kind: 'ddl', sql: dialect.ddl.renameTable(hint, name),
436
478
  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('_');
479
+ }
480
+ const consumedOldEntities = new Set(renamedFrom.values());
481
+ // a join table's endpoints come from the MAPPING, never from splitting
482
+ // its name: an entity named with an underscore, or a `through` name,
483
+ // does not split into its endpoints — and a split that guessed wrong
484
+ // renamed the table to a name nothing declares, created the declared
485
+ // one empty, and dropped the memberships as "destructive"
486
+ const implicitJoinName = (join) => [join.left.entity, join.right.entity].sort().join('_');
487
+ const renamedEntity = (entityName) => {
488
+ for (const [to, from] of renamedFrom) if (from === entityName) return to;
489
+ return entityName;
490
+ };
491
+ const renamedJoinName = (joinName) => {
492
+ const join = fromMapping.joinTables[joinName];
493
+ if (joinName !== implicitJoinName(join)) return joinName; // a `through` name stays
494
+ return [renamedEntity(join.left.entity), renamedEntity(join.right.entity)].sort().join('_');
495
+ };
496
+ for (const [joinName, join] of Object.entries(fromMapping.joinTables)) {
497
+ if (![join.left, join.right].some((side) => renamedEntity(side.entity) !== side.entity)) continue;
498
+ const newJoin = renamedJoinName(joinName);
499
+ const target = toMapping.joinTables[newJoin];
500
+ if (target === undefined) continue; // the relation is gone: the drop below names it
501
+ // the fresh build orders the endpoint columns by the SORTED entity
502
+ // names, and a rename can flip that order — then the primary key's
503
+ // column order would differ from a fresh build's and the shape
504
+ // check would refuse the migrated database, so the table is rebuilt
505
+ // in the target order with its rows copied; when the order holds,
506
+ // renaming the table and the column is enough
507
+ const renamedColumns = [join.left, join.right]
508
+ .map((side) => ({ from: side.column, to: `${renamedEntity(side.entity)}_key` }));
509
+ const targetOrder = [target.left.column, target.right.column];
510
+ const sameOrder = renamedColumns.every((column, i) => column.to === targetOrder[i]);
511
+ if (sameOrder) {
442
512
  if (newJoin !== joinName) {
443
513
  steps.push({ kind: 'ddl', sql: dialect.ddl.renameTable(joinName, newJoin),
444
514
  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
515
  }
516
+ for (const column of renamedColumns) {
517
+ if (column.from === column.to) continue;
518
+ steps.push({ kind: 'ddl', sql: dialect.ddl.renameColumn(newJoin, column.from, column.to),
519
+ note: `rename the endpoint column '${column.from}' with its entity` });
520
+ }
521
+ continue;
449
522
  }
523
+ const q = dialect.quoteIdentifier;
524
+ const sourceOf = (toColumn) => renamedColumns.find((column) => column.to === toColumn).from;
525
+ for (const sql of planJoinTable(newJoin, target, toMapping, dialect).createSql) {
526
+ steps.push({ kind: 'ddl', sql, note: `rebuild join table '${joinName}' as '${newJoin}' in its endpoint order` });
527
+ }
528
+ steps.push({ kind: 'sql',
529
+ sql: `INSERT INTO ${q(newJoin)} (${targetOrder.map(q).join(', ')}) `
530
+ + `SELECT ${targetOrder.map((column) => q(sourceOf(column))).join(', ')} FROM ${q(joinName)}`,
531
+ note: `copy the memberships of '${joinName}' into '${newJoin}'` });
532
+ steps.push({ kind: 'ddl', sql: dialect.ddl.dropTable(joinName),
533
+ note: `drop '${joinName}' — its memberships now live in '${newJoin}'` });
450
534
  }
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
535
 
462
536
  // ————— per-entity strategies —————
463
537
  for (const [name, toEntity] of toEntities) {
@@ -589,9 +663,26 @@ function planEntityChanges(fromModel, toModel, dialect, steps, report) {
589
663
  }
590
664
  }
591
665
 
592
- // ————— dropped entities —————
593
- for (const [name] of fromEntities) {
594
- if (toEntities.has(name) || consumedOldEntities.has(name)) continue;
666
+ // ————— dropped entities, children before parents —————
667
+ // foreign keys are enforced while a migration runs (node:sqlite has
668
+ // them on by default), so a parent with RESTRICT children cannot go
669
+ // first: the dropped set is ordered so that every entity referencing
670
+ // another dropped entity is dropped before it
671
+ const dropped = [...fromEntities.keys()]
672
+ .filter((name) => !toEntities.has(name) && !consumedOldEntities.has(name));
673
+ const droppedSet = new Set(dropped);
674
+ const references = (name) => new Set(fromMapping.entities[name].foreignKeys
675
+ .map((fk) => fk.references).filter((target) => droppedSet.has(target) && target !== name));
676
+ const dropOrder = [];
677
+ const placed = new Set();
678
+ while (dropOrder.length < dropped.length) {
679
+ // ready: every dropped entity that no other UNPLACED dropped entity references
680
+ const ready = dropped.filter((name) => !placed.has(name)
681
+ && !dropped.some((other) => !placed.has(other) && other !== name && references(other).has(name)));
682
+ if (ready.length === 0) { dropOrder.push(...dropped.filter((name) => !placed.has(name))); break; }
683
+ for (const name of ready) { placed.add(name); dropOrder.push(name); }
684
+ }
685
+ for (const name of dropOrder) {
595
686
  report.removed.push(name);
596
687
  report.destructive = true;
597
688
  steps.push({ kind: 'ddl', sql: dialect.ddl.dropTable(name),
@@ -650,6 +741,12 @@ function renderRebuild(name, fromName, fm, tm, fromMapping, toMapping, dialect,
650
741
  const sources = [];
651
742
  let docExpr = q('doc');
652
743
  const lost = [];
744
+ // a SQL NULL is an ABSENT member (§9.3): folding it in as JSON null
745
+ // turned every row without the value into a narrowing the target
746
+ // schema refused
747
+ const foldColumn = (expression, columnName, fold) =>
748
+ `CASE WHEN ${q(columnName)} IS NULL THEN ${expression} `
749
+ + `ELSE ${dialect.jsonSet(expression, pathText(columnName), fold)} END`;
653
750
  for (const { name: columnName, column } of ordered) {
654
751
  targets.push(q(columnName));
655
752
  if (fromHas(columnName)) {
@@ -659,9 +756,10 @@ function renderRebuild(name, fromName, fm, tm, fromMapping, toMapping, dialect,
659
756
  if (column !== null && column.source === 'epoch(document)'
660
757
  && fromColumn !== undefined && fromColumn.source !== 'epoch(document)') {
661
758
  // plain text column becomes a derived instant: derive from the
662
- // old column and keep the string in the document
759
+ // old column and keep the string in the document — an absent
760
+ // string stays absent (§9.3), never a JSON null
663
761
  sources.push(dialect.epochFromRfc3339(q(columnName)));
664
- docExpr = dialect.jsonSet(docExpr, pathText(columnName), q(columnName));
762
+ docExpr = foldColumn(docExpr, columnName, q(columnName));
665
763
  }
666
764
  else if (sameStorage) {
667
765
  sources.push(q(columnName));
@@ -686,12 +784,25 @@ function renderRebuild(name, fromName, fm, tm, fromMapping, toMapping, dialect,
686
784
  const fold = fromColumn.storage === 'boolean'
687
785
  ? dialect.jsonEncode(`CASE WHEN ${q(columnName)} = 1 THEN 'true' ELSE 'false' END`)
688
786
  : q(columnName);
689
- docExpr = dialect.jsonSet(docExpr, pathText(columnName), fold);
787
+ docExpr = foldColumn(docExpr, columnName, fold);
690
788
  }
691
789
  else if (fromColumn.source !== 'epoch(document)') {
692
790
  lost.push(columnName);
693
791
  }
694
792
  }
793
+ // an INFERRED foreign-key column (no property of its own) that the
794
+ // target no longer carries: its values are lost unless the target
795
+ // declares the property, in which case they fold into the document —
796
+ // walking the mapped columns alone dropped it without a word
797
+ for (const columnName of fromFkOnly) {
798
+ if (ordered.some((entry) => entry.name === columnName)) continue;
799
+ if (toMapping.entities[name] !== undefined && tm.document.includes(columnName)) {
800
+ docExpr = foldColumn(docExpr, columnName, q(columnName));
801
+ }
802
+ else {
803
+ lost.push(columnName);
804
+ }
805
+ }
695
806
  // properties that moved INTO columns leave the document
696
807
  for (const { name: columnName, column } of ordered) {
697
808
  if (!fromHas(columnName) && (column === null || column.source !== 'epoch(document)'))
@@ -923,15 +1034,20 @@ function checkMigrationDocument(migration) {
923
1034
  * value-or-promise per batch
924
1035
  * @returns {any}
925
1036
  */
926
- function walkRows(connection, table, batchSize, handle, keyed = true) {
1037
+ function walkRows(connection, table, batchSize, handle, keyed = true, entityMapping = null) {
927
1038
  // entity tables carry no 'key' column — the transform walk goes by
928
- // row identity alone; only the collection walks select the key
1039
+ // row identity alone; only the collection walks select the key. An
1040
+ // entity's mapped columns ride beside the document so the row can be
1041
+ // read WHOLE (`mergeEntityRow`): a transform or an assertion that saw
1042
+ // the rest-document alone could not see `id` or `name` at all
929
1043
  const dialect = connection.dialect;
930
1044
  const q = dialect.quoteIdentifier;
931
1045
  const rid = dialect.rowIdentity();
932
1046
  const keySelect = keyed ? `, ${q('key')} AS ${q('k')}` : '';
1047
+ const columnSelect = entityMapping === null ? '' : entityColumnsOf(entityMapping)
1048
+ .map((column) => `, ${q(column)}`).join('');
933
1049
  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')} `
1050
+ + `${keySelect}${columnSelect} FROM ${q(table)} WHERE ${rid} > ${dialect.parameterRef(1, 'after')} `
935
1051
  + `ORDER BY ${rid} ${dialect.limitClause(batchSize, undefined)}`;
936
1052
  return chain(connection.prepare(sql), (statement) => {
937
1053
  const nextBatch = (after) =>
@@ -944,15 +1060,44 @@ function walkRows(connection, table, batchSize, handle, keyed = true) {
944
1060
  });
945
1061
  }
946
1062
 
947
- /** All documents of a collection (the assertion steps' working set
948
- * a documented whole-collection read). */
949
- function allDocs(connection, table) {
1063
+ /** The physical columns an entity row carries beside its document. */
1064
+ function entityColumnsOf(entityMapping) {
1065
+ const names = new Set(entityMapping.columns.map((column) => column.name));
1066
+ for (const fk of entityMapping.foreignKeys) names.add(fk.column);
1067
+ return [...names];
1068
+ }
1069
+
1070
+ /**
1071
+ * The entity mapping a migration step over `table` runs under, or
1072
+ * `null` for a collection. The TARGET model maps the table: a chain's
1073
+ * intermediate shapes are hashes only, so an entity transform belongs
1074
+ * to the last migration of a chain (MIGRATION-FORMAT §9); without a
1075
+ * target model the step sees the rest-document, as it always did.
1076
+ * @param {any} options
1077
+ * @param {string} table
1078
+ * @returns {{ entity: any, mapping: any } | null}
1079
+ */
1080
+ function entityStepMapping(options, table) {
1081
+ if (options.model === undefined) return null;
1082
+ const entities = normalizeEntities(options.model);
1083
+ const entity = entities.get(table);
1084
+ if (entity === undefined) return null;
1085
+ return { entity, mapping: explainMapping(options.model).entities[table] };
1086
+ }
1087
+
1088
+ /** All documents of a collection or entity (the assertion steps' working
1089
+ * set — a documented whole-collection read), entities read WHOLE. */
1090
+ function allDocs(connection, table, entityMapping = null) {
950
1091
  const dialect = connection.dialect;
951
1092
  const q = dialect.quoteIdentifier;
952
- const sql = `SELECT ${dialect.jsonText(q('doc'))} AS ${q('doc')} FROM ${q(table)} `
1093
+ const columnSelect = entityMapping === null ? '' : entityColumnsOf(entityMapping)
1094
+ .map((column) => `, ${q(column)}`).join('');
1095
+ const sql = `SELECT ${dialect.jsonText(q('doc'))} AS ${q('doc')}${columnSelect} FROM ${q(table)} `
953
1096
  + `ORDER BY ${dialect.rowIdentity()}`;
954
1097
  return chain(connection.prepare(sql), (statement) =>
955
- chain(statement.all([]), (rows) => rows.map((row) => JSON.parse(row.doc))));
1098
+ chain(statement.all([]), (rows) => rows.map((row) => (entityMapping === null
1099
+ ? JSON.parse(row.doc)
1100
+ : mergeEntityRow(entityMapping, row, 'doc')))));
956
1101
  }
957
1102
 
958
1103
  /**
@@ -1057,6 +1202,51 @@ function runSteps(connection, migration, options) {
1057
1202
  return fail(`the stylesheet does not compile: ${/** @type {Error} */ (cause).message}`,
1058
1203
  /** @type {Error} */ (cause));
1059
1204
  }
1205
+ const stepEntity = entityStepMapping(options, current.collection);
1206
+ if (stepEntity !== null) {
1207
+ // an entity row is transformed WHOLE: the mapped columns fold in
1208
+ // before the stylesheet and split out after it, through the
1209
+ // entity's own split — a column-mapped member the stylesheet
1210
+ // wrote used to land in the document and be shadowed on read
1211
+ const core = entityCore(connection, stepEntity.entity, stepEntity.mapping, null);
1212
+ const columns = entityColumnsOf(stepEntity.mapping);
1213
+ const assignments = [
1214
+ ...columns.map((column, i) => `${q(column)} = ${dialect.parameterRef(i + 1, 'v')}`),
1215
+ `${q('doc')} = ${dialect.jsonEncode(dialect.parameterRef(columns.length + 1, 'doc'))}`,
1216
+ ];
1217
+ const updateSql = `UPDATE ${q(current.collection)} SET ${assignments.join(', ')} `
1218
+ + `WHERE ${dialect.rowIdentity()} = ${dialect.parameterRef(columns.length + 2, 'rid')}`;
1219
+ let transformed = 0;
1220
+ return chain(connection.prepare(updateSql), (update) =>
1221
+ chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
1222
+ for (const row of rows) {
1223
+ const whole = mergeEntityRow(stepEntity.mapping, row, 'doc');
1224
+ const next = transform(whole);
1225
+ if (next === null || typeof next !== 'object' || Array.isArray(next))
1226
+ fail(`the transform produced a non-document for row ${row.rid}`);
1227
+ // the key is the row's identity, not the document's to
1228
+ // change: a body that leaves it out keeps it, a body
1229
+ // that rewrites it is refused, as a collection's key is
1230
+ for (const key of stepEntity.mapping.keys) {
1231
+ if (next[key] === undefined) next[key] = whole[key];
1232
+ else if (next[key] !== whole[key]) {
1233
+ fail(`the transform changed the key member '${key}' of row ${row.rid} — `
1234
+ + `key changes are not supported in ${MIGRATION_VERSION}`);
1235
+ }
1236
+ }
1237
+ const { values, rest } = core.plan.split(next);
1238
+ const byName = new Map(values.map((value) => [value.name, value.value]));
1239
+ update.run([...columns.map((column) => byName.get(column) ?? null),
1240
+ JSON.stringify(rest), row.rid]);
1241
+ transformed++;
1242
+ }
1243
+ options.onProgress?.({
1244
+ migration: migration.id,
1245
+ collection: current.collection,
1246
+ transformed,
1247
+ });
1248
+ }, false, stepEntity.mapping), () => transformed));
1249
+ }
1060
1250
  const updateSql = `UPDATE ${q(current.collection)} SET ${q('doc')} = `
1061
1251
  + `${dialect.jsonEncode(dialect.parameterRef(1, 'doc'))} `
1062
1252
  + `WHERE ${dialect.rowIdentity()} = ${dialect.parameterRef(2, 'rid')}`;
@@ -1087,7 +1277,8 @@ function runSteps(connection, migration, options) {
1087
1277
  return fail(`the assertion does not compile: ${/** @type {Error} */ (cause).message}`,
1088
1278
  /** @type {Error} */ (cause));
1089
1279
  }
1090
- return chain(allDocs(connection, current.collection), (docs) => {
1280
+ return chain(allDocs(connection, current.collection,
1281
+ entityStepMapping(options, current.collection)?.mapping ?? null), (docs) => {
1091
1282
  if (current.expect === 'ebv') {
1092
1283
  if (!compiled.ebv(docs)) fail('the EBV assertion answered false');
1093
1284
  return null;
@@ -1129,8 +1320,13 @@ function validateTargetState(connection, model, options) {
1129
1320
  ? options.compileSchema(entity.schema)
1130
1321
  : null;
1131
1322
  if (validate === null) return verifyEntity(i + 1);
1323
+ // the WHOLE document — mapped columns folded in — is what the target
1324
+ // schema judges; the rest-document alone failed every entity whose
1325
+ // required members are columns, so a pure widening could not land
1326
+ const entityMapping = explainMapping(model).entities[entity.name];
1132
1327
  const rid = dialect.rowIdentity();
1133
- const sql = `SELECT ${rid} AS ${q('rid')}, ${dialect.jsonText(q('doc'))} AS ${q('doc')} `
1328
+ const columnSelect = entityColumnsOf(entityMapping).map((column) => `, ${q(column)}`).join('');
1329
+ const sql = `SELECT ${rid} AS ${q('rid')}, ${dialect.jsonText(q('doc'))} AS ${q('doc')}${columnSelect} `
1134
1330
  + `FROM ${q(entity.name)} WHERE ${rid} > ${dialect.parameterRef(1, 'after')} `
1135
1331
  + `ORDER BY ${rid} ${dialect.limitClause(options.batchSize, undefined)}`;
1136
1332
  return chain(connection.prepare(sql), (statement) => {
@@ -1138,7 +1334,7 @@ function validateTargetState(connection, model, options) {
1138
1334
  chain(statement.all([after]), (rows) => {
1139
1335
  if (rows.length === 0) return null;
1140
1336
  for (const row of rows) {
1141
- const outcome = validate(JSON.parse(row.doc));
1337
+ const outcome = validate(mergeEntityRow(entityMapping, row, 'doc'));
1142
1338
  const valid = outcome === true || outcome?.valid === true;
1143
1339
  if (!valid) {
1144
1340
  throw refuse('JD0021',
@@ -1379,6 +1575,7 @@ export function migrate(target, migrations, options) {
1379
1575
  batchSize,
1380
1576
  onProgress: options.onProgress,
1381
1577
  registerFunctions: options.registerFunctions,
1578
+ model: options.model,
1382
1579
  };
1383
1580
 
1384
1581
  return toPromise(chain(
@@ -1395,174 +1592,183 @@ export function migrate(target, migrations, options) {
1395
1592
 
1396
1593
  let work;
1397
1594
  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
- }
1595
+ // §6's "writes NOTHING": an apply creates the empty history
1596
+ // table before reading it, a DRY RUN probes for it instead and
1597
+ // reads an absent one as an empty history — the promise a dry
1598
+ // run makes is the reason it is safe to point at production
1599
+ const history = options.dryRun === true
1600
+ ? chain(connection.prepare(dialect.introspect.tableExists()), (probe) =>
1601
+ chain(probe.get([HISTORY_TABLE]), (row) => (row === undefined
1602
+ ? []
1603
+ : chain(connection.prepare(statements.select), (select) => select.all([])))))
1604
+ : chain(connection.exec(statements.create), () =>
1605
+ chain(connection.prepare(statements.select), (select) => select.all([])));
1606
+ work = chain(history, (appliedRows) => {
1607
+ // the list must agree with the history: same ids, same
1608
+ // order, same checksums — an edited applied migration is
1609
+ // always a bug worth failing on
1610
+ for (let i = 0; i < appliedRows.length; i++) {
1611
+ const row = appliedRows[i];
1612
+ const doc = migrations[i];
1613
+ if (doc === undefined || doc.id !== row.id) {
1614
+ throw refuse('JD0022',
1615
+ `history position ${i} records '${row.id}' but the migration list has `
1616
+ + `'${doc?.id ?? '<nothing>'}' — the list must contain every applied `
1617
+ + 'migration, in order');
1418
1618
  }
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;
1619
+ if (migrationChecksum(doc) !== row.checksum) {
1620
+ throw refuse('JD0022',
1621
+ `migration '${row.id}' differs from the document recorded in the `
1622
+ + 'history — an applied migration must never be edited');
1433
1623
  }
1434
- if (options.model !== undefined && pending.length > 0
1435
- && expectedFrom !== shapeHash(options.model)) {
1624
+ }
1625
+ const pending = migrations.slice(appliedRows.length);
1626
+ const currentShape = appliedRows.length > 0
1627
+ ? appliedRows[appliedRows.length - 1].to_hash
1628
+ : shapeHash(options.baseline);
1629
+
1630
+ let expectedFrom = currentShape;
1631
+ for (const migration of pending) {
1632
+ checkMigrationDocument(migration);
1633
+ if (migration.from !== expectedFrom) {
1436
1634
  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');
1635
+ `migration '${migration.id}' expects shape '${migration.from}' but the `
1636
+ + `database is at '${expectedFrom}' refusing to run against the wrong shape`);
1439
1637
  }
1638
+ expectedFrom = migration.to;
1639
+ }
1640
+ if (options.model !== undefined && pending.length > 0
1641
+ && expectedFrom !== shapeHash(options.model)) {
1642
+ throw refuse('JD0020',
1643
+ "the last migration's to-hash is not the target model's shape — the "
1644
+ + 'migration chain and the code disagree about where this ends');
1645
+ }
1440
1646
 
1441
- if (pending.length === 0) {
1442
- return { applied: [], skipped: appliedRows.map((row) => row.id), upToDate: true };
1443
- }
1647
+ if (pending.length === 0) {
1648
+ return { applied: [], skipped: appliedRows.map((row) => row.id), upToDate: true };
1649
+ }
1444
1650
 
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
- }
1651
+ const shadowRun = options.shadow === false
1652
+ ? null
1653
+ : replayOnShadow(target.driver, options.shadowPath ?? ':memory:',
1654
+ options.baseline, migrations, options.model, runOptions);
1499
1655
 
1500
- // the real run: one exclusive transaction per migration
1501
- const applied = [];
1502
- const applyNext = (i) => {
1656
+ return chain(shadowRun, () => {
1657
+ if (options.dryRun === true) {
1658
+ const rendered = [];
1659
+ const counts = {};
1660
+ const collect = (i) => {
1503
1661
  if (i >= pending.length) return null;
1504
1662
  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();
1663
+ for (const migrationStep of migration.steps) {
1664
+ if (migrationStep.kind === 'ddl') rendered.push(migrationStep.sql);
1665
+ else if (migrationStep.kind === 'sql') {
1666
+ rendered.push(`-- data step (sql): ${migrationStep.note ?? ''}`);
1667
+ rendered.push(migrationStep.sql);
1550
1668
  }
1551
- catch (error) {
1552
- return rollback(error);
1669
+ else if (migrationStep.kind === 'rebuild') {
1670
+ rendered.push(`-- rebuild '${migrationStep.table}' (§10 procedure)`);
1671
+ rendered.push(...migrationStep.create, migrationStep.copy,
1672
+ dialect.ddl.dropTable(migrationStep.table),
1673
+ dialect.ddl.renameTable(`${migrationStep.table}__rebuild`,
1674
+ migrationStep.table),
1675
+ ...migrationStep.indexes,
1676
+ dialect.pragma.foreignKeyCheck());
1553
1677
  }
1554
- return outcome instanceof Promise
1555
- ? outcome.then(commit, rollback)
1556
- : commit();
1557
- }));
1678
+ else if (migrationStep.kind === 'jslt')
1679
+ rendered.push(`-- jslt transform over '${migrationStep.collection}'`);
1680
+ else rendered.push(`-- assert over '${migrationStep.collection}'`);
1681
+ }
1682
+ const jsltCollections = [...new Set(migration.steps
1683
+ .filter((s) => s.kind === 'jslt').map((s) => s.collection))];
1684
+ const count = (j) => {
1685
+ if (j >= jsltCollections.length) return null;
1686
+ const table = jsltCollections[j];
1687
+ const countSql = `SELECT COUNT(*) AS ${dialect.quoteIdentifier('n')} `
1688
+ + `FROM ${dialect.quoteIdentifier(table)}`;
1689
+ return chain(connection.prepare(countSql), (statement) =>
1690
+ chain(statement.get([]), (row) => {
1691
+ counts[table] = row.n;
1692
+ return count(j + 1);
1693
+ }));
1694
+ };
1695
+ return chain(count(0), () => collect(i + 1));
1558
1696
  };
1559
- return chain(applyNext(0), () => ({
1560
- applied,
1561
- skipped: appliedRows.map((row) => row.id),
1562
- shape: expectedFrom,
1697
+ return chain(collect(0), () => ({
1698
+ dryRun: true,
1699
+ pending: pending.map((migration) => migration.id),
1700
+ statements: rendered,
1701
+ counts,
1702
+ shadowValidated: options.shadow !== false,
1563
1703
  }));
1564
- });
1565
- })));
1704
+ }
1705
+
1706
+ // the real run: one exclusive transaction per migration
1707
+ const applied = [];
1708
+ const applyNext = (i) => {
1709
+ if (i >= pending.length) return null;
1710
+ const migration = pending[i];
1711
+ const last = i === pending.length - 1;
1712
+ // the §10 procedure's pragma bracket, literally: the
1713
+ // foreign_keys pragma is a no-op inside a transaction,
1714
+ // and node:sqlite enables enforcement BY DEFAULT — a
1715
+ // parent-table rebuild could not even DROP without this
1716
+ const bracket = migration.steps.some(
1717
+ (candidate) => candidate.kind === 'rebuild');
1718
+ return chain(
1719
+ bracket ? connection.exec(dialect.pragma.foreignKeys(false)) : null,
1720
+ () => chain(connection.exec(dialect.tx.beginImmediate), () => {
1721
+ const body = () => chain(runSteps(connection, migration, runOptions), () =>
1722
+ chain(last && options.model !== undefined
1723
+ ? chain(validateTargetState(connection, options.model,
1724
+ { compileSchema: options.compileSchema, batchSize }),
1725
+ () => (normalizeEntities(options.model).size === 0 ? null
1726
+ : chain(compareShapeToModel(target.driver, connection,
1727
+ options.model, options.registerFunctions), (difference) => {
1728
+ if (difference !== null) {
1729
+ throw refuse('JD0023',
1730
+ `the migrated shape does not equal the target model's: ${difference}`);
1731
+ }
1732
+ return null;
1733
+ })))
1734
+ : null,
1735
+ () => chain(connection.prepare(statements.insert), (insert) =>
1736
+ insert.run([migration.id, Date.now(), migration.from,
1737
+ migration.to, migrationChecksum(migration),
1738
+ migration.steps.length]))));
1739
+ const restore = () => (bracket
1740
+ ? connection.exec(dialect.pragma.foreignKeys(true)) : null);
1741
+ const commit = () => chain(connection.exec(dialect.tx.commit), () =>
1742
+ chain(restore(), () => {
1743
+ applied.push(migration.id);
1744
+ return applyNext(i + 1);
1745
+ }));
1746
+ const rollback = (error) =>
1747
+ chain(connection.exec(dialect.tx.rollback), () =>
1748
+ chain(restore(), () => { throw error; }));
1749
+ // only body() may route to this migration's rollback:
1750
+ // commit() chains the NEXT migration, whose failure
1751
+ // rolls ITSELF back — catching it here would roll
1752
+ // back a transaction that already committed
1753
+ let outcome;
1754
+ try {
1755
+ outcome = body();
1756
+ }
1757
+ catch (error) {
1758
+ return rollback(error);
1759
+ }
1760
+ return outcome instanceof Promise
1761
+ ? outcome.then(commit, rollback)
1762
+ : commit();
1763
+ }));
1764
+ };
1765
+ return chain(applyNext(0), () => ({
1766
+ applied,
1767
+ skipped: appliedRows.map((row) => row.id),
1768
+ shape: expectedFrom,
1769
+ }));
1770
+ });
1771
+ });
1566
1772
 
1567
1773
  }
1568
1774
  catch (error) {