@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.
- package/ARCHITECTURE.md +27 -15
- package/README.md +141 -41
- package/docs/JOBS-FORMAT.md +24 -8
- package/docs/LIVE-FORMAT.md +38 -9
- package/docs/MIGRATION-FORMAT.md +118 -36
- package/docs/MODEL-FORMAT.md +232 -30
- package/package.json +4 -5
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/src/capture.js +66 -28
- package/src/cli.js +225 -44
- package/src/ddl.js +23 -3
- package/src/dialects/sqlite.js +2 -1
- package/src/driver.js +63 -16
- package/src/drivers/wasm.js +1 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +10 -3
- package/src/entity.js +92 -47
- package/src/errors.js +25 -0
- package/src/index.js +2 -2
- package/src/jobs.js +40 -5
- package/src/live-time.js +12 -3
- package/src/live.js +11 -1
- package/src/migrate.js +397 -191
- package/src/model.js +173 -8
- package/src/plan.js +135 -38
- package/src/query.js +138 -13
- package/src/store.js +221 -66
- package/src/tracker.js +173 -48
- package/types/index.d.ts +152 -10
- package/types/node.d.ts +3 -1
- package/types/typed.d.ts +58 -2
- package/types/wasm.d.ts +7 -0
- package/dist/types/algebra.d.ts +0 -230
- package/dist/types/app.d.ts +0 -49
- package/dist/types/capture.d.ts +0 -85
- package/dist/types/cli.d.ts +0 -2
- package/dist/types/dag-job.d.ts +0 -40
- package/dist/types/ddl.d.ts +0 -229
- package/dist/types/derive.d.ts +0 -250
- package/dist/types/dialect.d.ts +0 -154
- package/dist/types/dialects/sqlite.d.ts +0 -9
- package/dist/types/driver.d.ts +0 -110
- package/dist/types/drivers/bun.d.ts +0 -47
- package/dist/types/drivers/node.d.ts +0 -37
- package/dist/types/drivers/wasm.d.ts +0 -65
- package/dist/types/emit-model.d.ts +0 -44
- package/dist/types/emit.d.ts +0 -75
- package/dist/types/entity.d.ts +0 -23
- package/dist/types/errors.d.ts +0 -170
- package/dist/types/graph.d.ts +0 -28
- package/dist/types/index.d.ts +0 -37
- package/dist/types/jobs.d.ts +0 -140
- package/dist/types/knn.d.ts +0 -69
- package/dist/types/live-time.d.ts +0 -141
- package/dist/types/live.d.ts +0 -64
- package/dist/types/migrate.d.ts +0 -170
- package/dist/types/model.d.ts +0 -36
- package/dist/types/patch-sql.d.ts +0 -37
- package/dist/types/plan.d.ts +0 -142
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -112
- package/dist/types/residual.d.ts +0 -64
- package/dist/types/series.d.ts +0 -227
- package/dist/types/store.d.ts +0 -60
- package/dist/types/tracker.d.ts +0 -43
- package/dist/types/typed.d.ts +0 -15
- package/dist/types/types.d.ts +0 -26
- package/dist/types/udf.d.ts +0 -75
- 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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
594
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
/**
|
|
948
|
-
|
|
949
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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
|
-
|
|
1435
|
-
|
|
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
|
-
|
|
1438
|
-
+ '
|
|
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
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1647
|
+
if (pending.length === 0) {
|
|
1648
|
+
return { applied: [], skipped: appliedRows.map((row) => row.id), upToDate: true };
|
|
1649
|
+
}
|
|
1444
1650
|
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
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
|
-
|
|
1501
|
-
|
|
1502
|
-
const
|
|
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
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
-
|
|
1552
|
-
|
|
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
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
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(
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
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) {
|