db-migrate 1.3.0 → 1.4.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/lib/dml.js CHANGED
@@ -16,15 +16,22 @@ const { recoveryMode, describeFailure } = require('./recovery');
16
16
  * first, reverting restores them from there. The rows are then
17
17
  * changed in batches by their key, the progress is kept in the
18
18
  * state, so an interrupted run continues where it stopped.
19
+ * delete in soft mode: the rows are not deleted, but get a timestamp in a
20
+ * column of the user and a mark in their flag, reverting removes
21
+ * both again. purge deletes such rows for good.
19
22
  * runSql: reverted by the SQL given with { revert }
20
23
  *
21
- * The records are { t: 4, a: action, c: args }, or { t: 3 } for a step run
22
- * with { irreversible: true }, which can not be reverted.
24
+ * Each step, and each batch of a step, runs inside a transaction, unless
25
+ * the migration opts out with _meta.transactions = false.
26
+ *
27
+ * The records are { t: 4, a: action, c: args }, or { t: 3 } for a step which
28
+ * can not be reverted, run with { irreversible: true } or a purge.
23
29
  */
24
30
 
25
31
  const FLAG = '__dbmigrate__flag';
26
32
  const BATCH = 1000;
27
33
  const TYPES = ['dml'];
34
+ const PG = ['pg', 'cockroachdb', 'postgres', 'postgresql'];
28
35
 
29
36
  const SCHEMA_INSTRUCTIONS = [
30
37
  'createTable',
@@ -43,10 +50,14 @@ const SCHEMA_INSTRUCTIONS = [
43
50
  'renameCollection'
44
51
  ];
45
52
 
46
- const isDml = file => (file.get()._meta || {}).type === 'dml';
53
+ const meta = file => file.get()._meta || {};
54
+
55
+ const isDml = file => meta(file).type === 'dml';
56
+
57
+ const isBackground = file => isDml(file) && meta(file).background === true;
47
58
 
48
59
  const checkType = file => {
49
- const type = (file.get()._meta || {}).type;
60
+ const { type, background } = meta(file);
50
61
 
51
62
  if (type !== undefined && TYPES.indexOf(type) === -1) {
52
63
  throw new Error(
@@ -55,8 +66,27 @@ const checkType = file => {
55
66
  ' or leave it out for a schema migration'
56
67
  );
57
68
  }
69
+
70
+ if (background === true && type !== 'dml') {
71
+ throw new Error(
72
+ `Migration "${file.name}" runs in the background, which only dml ` +
73
+ "migrations can, set _meta.type to 'dml'"
74
+ );
75
+ }
58
76
  };
59
77
 
78
+ /**
79
+ * Stops a worker between two batches, the job is continued later.
80
+ */
81
+ class Stopped extends Error {
82
+ constructor () {
83
+ super('stopped');
84
+ this.stopped = true;
85
+ }
86
+ }
87
+
88
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
89
+
60
90
  const backupName = (file, op) =>
61
91
  '__dbm_backup_' +
62
92
  crypto
@@ -65,6 +95,12 @@ const backupName = (file, op) =>
65
95
  .digest('hex')
66
96
  .slice(0, 16);
67
97
 
98
+ // the mark of a soft delete in the flag of a row
99
+ const deleted = (file, op) => `|del:${file.name}#${op}`;
100
+
101
+ // a LIKE pattern matching text literally, with ! as escape character
102
+ const like = text => text.replace(/[!%_]/g, c => '!' + c);
103
+
68
104
  const isObject = x =>
69
105
  x !== null &&
70
106
  typeof x === 'object' &&
@@ -136,13 +172,31 @@ const insertRows = args => {
136
172
  throw new Error('insert needs the rows to insert');
137
173
  };
138
174
 
175
+ /**
176
+ * The progress of a migration run in the foreground is kept in the row of
177
+ * the migration lock, see State.
178
+ */
179
+ const foreground = (pdriver, internals) => ({
180
+ step: op => State.step(pdriver, op, internals),
181
+ learned: op => State.learned(pdriver, op, internals),
182
+ done: op => State.done(pdriver, op, internals)
183
+ });
184
+
139
185
  class Dml {
140
- constructor (context, file, internals, pdriver, recovery) {
186
+ /**
187
+ * progress: records the steps started, learned and done
188
+ * work: options of a background worker, batch and pause between batches,
189
+ * stopping() tells to stop after the current batch
190
+ */
191
+ constructor (context, file, internals, pdriver, recovery, progress, work = {}) {
141
192
  this.driver = context._driver;
142
193
  this.file = file;
143
194
  this.internals = internals;
144
195
  this.pdriver = pdriver;
145
196
  this.recovery = recovery;
197
+ this.progress = progress || foreground(pdriver, internals);
198
+ this.work = work;
199
+ this.transactional = meta(file).transactions !== false;
146
200
  this.op = 0;
147
201
  this.current = null;
148
202
 
@@ -165,6 +219,78 @@ class Dml {
165
219
  return t ? t.columns || t : null;
166
220
  }
167
221
 
222
+ /**
223
+ * Reads rows with parameters written as ?, all of pg takes $1, $2, ...
224
+ */
225
+ select (sql, params = []) {
226
+ if (!params.length) {
227
+ return this.driver.all(sql);
228
+ }
229
+
230
+ if (PG.indexOf(this.driver._dbmDriver) !== -1) {
231
+ let i = 0;
232
+ sql = sql.replace(/\?/g, () => `$${++i}`);
233
+ }
234
+
235
+ // with a callback, all of db-migrate-sqlite3 before 1.2.1 drops the
236
+ // parameters without one
237
+ return Promise.fromCallback(cb => this.driver.all(sql, params, cb));
238
+ }
239
+
240
+ /**
241
+ * sql followed by a text parameter. CONCAT of PostgreSQL can not tell the
242
+ * type of a parameter, || of MySQL is OR.
243
+ */
244
+ concat (sql) {
245
+ return this.driver._dbmDriver === 'mysql'
246
+ ? `CONCAT(${sql}, ?)`
247
+ : `(${sql} || CAST(? AS TEXT))`;
248
+ }
249
+
250
+ /**
251
+ * Runs fn inside a transaction, rolled back if it fails.
252
+ */
253
+ async tx (fn) {
254
+ if (!this.transactional || this.internals.dryRun) {
255
+ return fn();
256
+ }
257
+
258
+ await this.driver.runSql('BEGIN');
259
+
260
+ let ret;
261
+ try {
262
+ ret = await fn();
263
+ } catch (err) {
264
+ try {
265
+ await this.driver.runSql('ROLLBACK');
266
+ } catch (rollbackErr) {
267
+ log.verbose('[dml] rollback failed', rollbackErr.message);
268
+ }
269
+
270
+ throw err;
271
+ }
272
+
273
+ await this.driver.runSql('COMMIT');
274
+ return ret;
275
+ }
276
+
277
+ /**
278
+ * Between batches, a worker in the background pauses and stops if asked.
279
+ */
280
+ async between () {
281
+ if (this.work.stopping && this.work.stopping()) {
282
+ throw new Stopped();
283
+ }
284
+
285
+ if (this.work.pause) {
286
+ await delay(this.work.pause);
287
+ }
288
+ }
289
+
290
+ batchSize (options) {
291
+ return Number(options.batch) || Number(this.work.batch) || BATCH;
292
+ }
293
+
168
294
  /**
169
295
  * The key of a table, to change and restore its rows by. Taken from the
170
296
  * schema, for other tables it is passed with { key }.
@@ -179,10 +305,12 @@ class Dml {
179
305
 
180
306
  if (!key.length) {
181
307
  throw new Error(
182
- `${action}("${table}") needs the primary key of "${table}" to revert ` +
183
- 'it, which is unknown to the schema of db-migrate. Pass it with ' +
184
- "{ key: 'id' }, or pass { irreversible: true } to change the rows " +
185
- 'without being able to revert it.'
308
+ `${action}("${table}") needs the primary key of "${table}", which is ` +
309
+ "unknown to the schema of db-migrate. Pass it with { key: 'id' }" +
310
+ (action === 'purge'
311
+ ? '.'
312
+ : ', or pass { irreversible: true } to change the rows without ' +
313
+ 'being able to revert it.')
186
314
  );
187
315
  }
188
316
 
@@ -251,6 +379,31 @@ class Dml {
251
379
  return { sql, params };
252
380
  }
253
381
 
382
+ /**
383
+ * Changes the rows matching condition in batches by their key, until none
384
+ * matches anymore. Changing a row has to make it stop matching, so this
385
+ * continues by itself after an interruption.
386
+ */
387
+ async untilDone (table, key, condition, options, change) {
388
+ const size = this.batchSize(options);
389
+ const order = key.map(k => this.ddl(k)).join(', ');
390
+
391
+ for (;;) {
392
+ const rows = await this.select(
393
+ `SELECT ${order} FROM ${this.ddl(table)} WHERE ${condition.sql} ` +
394
+ `ORDER BY ${order} LIMIT ${size}`,
395
+ condition.params
396
+ );
397
+
398
+ if (!rows.length) {
399
+ return;
400
+ }
401
+
402
+ await this.tx(() => change(this.byKey(rows, key)));
403
+ await this.between();
404
+ }
405
+ }
406
+
254
407
  /**
255
408
  * Runs one instruction as a step: records what reverts it, executes it and
256
409
  * records it as done. The step of an interrupted run is continued, with
@@ -272,7 +425,7 @@ class Dml {
272
425
  return;
273
426
  }
274
427
 
275
- await State.step(this.pdriver, op, internals);
428
+ await this.progress.step(op);
276
429
 
277
430
  let entry = recovery && op <= recovery.learned
278
431
  ? mod.s.find(e => e.n === op)
@@ -288,22 +441,29 @@ class Dml {
288
441
  entry = record(op);
289
442
  entry.n = op;
290
443
  mod.s.push(entry);
291
- await State.update(this.pdriver, this.file, mod, internals);
292
- await State.learned(this.pdriver, op, internals);
444
+ await this.save();
445
+ await this.progress.learned(op);
293
446
  }
294
447
 
295
448
  await execute(entry, op);
296
449
 
297
450
  internals.migrationDone = op;
298
- await State.done(this.pdriver, op, internals);
451
+ await this.progress.done(op);
452
+ await this.between();
299
453
  }
300
454
 
455
+ /**
456
+ * The records of the migration, its schema stays as it is.
457
+ */
301
458
  save () {
302
- return State.update(
303
- this.pdriver,
304
- this.file,
305
- this.internals.modSchema,
306
- this.internals
459
+ if (this.internals.dryRun) {
460
+ return Promise.resolve();
461
+ }
462
+
463
+ return this.pdriver._updateKV(
464
+ this.internals.migrationState,
465
+ this.file.name,
466
+ JSON.stringify(this.internals.modSchema)
307
467
  );
308
468
  }
309
469
 
@@ -342,26 +502,27 @@ class Dml {
342
502
  irreversible
343
503
  ? { t: 3, a: 'insert', c: [table] }
344
504
  : { t: 4, a: 'insert', c: [table, `${this.file.name}#${op}`] },
345
- async entry => {
346
- if (!rows.length) {
347
- return;
348
- }
505
+ entry =>
506
+ this.tx(async () => {
507
+ if (!rows.length) {
508
+ return;
509
+ }
349
510
 
350
- if (irreversible) {
351
- return this.driver.insert(table, rows);
352
- }
511
+ if (irreversible) {
512
+ return this.driver.insert(table, rows);
513
+ }
353
514
 
354
- const flag = entry.c[1];
355
- // rows of an interrupted run of this step
356
- await this.driver.runSql(
357
- `DELETE FROM ${this.ddl(table)} WHERE ${this.ddl(FLAG)} = ?`,
358
- [flag]
359
- );
360
- await this.driver.insert(
361
- table,
362
- rows.map(row => Object.assign({}, row, { [FLAG]: flag }))
363
- );
364
- }
515
+ const flag = entry.c[1];
516
+ // rows of an interrupted run of this step
517
+ await this.driver.runSql(
518
+ `DELETE FROM ${this.ddl(table)} WHERE ${this.ddl(FLAG)} = ?`,
519
+ [flag]
520
+ );
521
+ await this.driver.insert(
522
+ table,
523
+ rows.map(row => Object.assign({}, row, { [FLAG]: flag }))
524
+ );
525
+ })
365
526
  );
366
527
  }
367
528
 
@@ -404,28 +565,46 @@ class Dml {
404
565
  },
405
566
  async entry => {
406
567
  if (irreversible || this.internals.dryRun) {
407
- return this.driver.runSql(
408
- `UPDATE ${this.ddl(table)} SET ${assign} WHERE ${condition.sql}`,
409
- values.concat(condition.params)
568
+ return this.tx(() =>
569
+ this.driver.runSql(
570
+ `UPDATE ${this.ddl(table)} SET ${assign} WHERE ${condition.sql}`,
571
+ values.concat(condition.params)
572
+ )
410
573
  );
411
574
  }
412
575
 
413
576
  await this.backup(entry, table, key.concat(columns), condition);
414
- await this.batches(entry, options, rows => {
415
- const match = this.byKey(rows, key);
416
- return this.driver.runSql(
577
+ await this.batches(entry, options, match =>
578
+ this.driver.runSql(
417
579
  `UPDATE ${this.ddl(table)} SET ${assign} WHERE ${match.sql}`,
418
580
  values.concat(match.params)
419
- );
420
- });
581
+ )
582
+ );
421
583
  }
422
584
  );
423
585
  }
424
586
 
425
587
  /**
426
588
  * delete(table, where, [options]), delete the rows matching where.
589
+ *
590
+ * mode 'copy' (default) copies the rows to a backup table first. mode
591
+ * 'soft' keeps the rows, sets column to the current time, or value, and
592
+ * marks them in their flag. The application filters them by column, until
593
+ * purge deletes them.
427
594
  */
428
595
  delete (table, where, options = {}) {
596
+ const mode = options.mode || 'copy';
597
+
598
+ if (mode === 'soft') {
599
+ return this.softDelete(table, where, options);
600
+ }
601
+
602
+ if (mode !== 'copy') {
603
+ throw new Error(
604
+ `delete("${table}") has no mode "${mode}", use 'copy' or 'soft'`
605
+ );
606
+ }
607
+
429
608
  const condition = this.where(where);
430
609
  const irreversible = options.irreversible === true;
431
610
  const key = irreversible ? null : this.key(table, options, 'delete');
@@ -439,20 +618,117 @@ class Dml {
439
618
  : { t: 4, a: 'delete', c: [table, backupName(this.file, op), key] },
440
619
  async entry => {
441
620
  if (irreversible || this.internals.dryRun) {
621
+ return this.tx(() =>
622
+ this.driver.runSql(
623
+ `DELETE FROM ${this.ddl(table)} WHERE ${condition.sql}`,
624
+ condition.params
625
+ )
626
+ );
627
+ }
628
+
629
+ await this.backup(entry, table, null, condition);
630
+ await this.batches(entry, options, match =>
631
+ this.driver.runSql(
632
+ `DELETE FROM ${this.ddl(table)} WHERE ${match.sql}`,
633
+ match.params
634
+ )
635
+ );
636
+ }
637
+ );
638
+ }
639
+
640
+ softDelete (table, where, options) {
641
+ const column = options.column;
642
+ const columns = this.columns(table);
643
+
644
+ if (typeof column !== 'string') {
645
+ throw new Error(
646
+ `delete("${table}") in soft mode needs the column marking the rows ` +
647
+ "as deleted, { mode: 'soft', column: 'deleted_at' }"
648
+ );
649
+ }
650
+
651
+ if (!columns || !columns[FLAG]) {
652
+ throw new Error(
653
+ `delete("${table}") in soft mode marks the rows in their ${FLAG} ` +
654
+ 'column, which only tables created by v2 migrations have'
655
+ );
656
+ }
657
+
658
+ const condition = this.where(where);
659
+ const key = this.key(table, options, 'delete');
660
+ const time = options.value === undefined;
661
+
662
+ return this.step(
663
+ 'delete',
664
+ [table],
665
+ op => ({ t: 4, a: 'soft', c: [table, column, key, deleted(this.file, op)] }),
666
+ entry => {
667
+ const mark = entry.c[3];
668
+ const set =
669
+ `${this.ddl(column)} = ${time ? 'CURRENT_TIMESTAMP' : '?'}, ` +
670
+ `${this.ddl(FLAG)} = ${this.concat(`COALESCE(${this.ddl(FLAG)}, '')`)}`;
671
+ const value = time ? [mark] : [toValue(options.value), mark];
672
+ // rows deleted already, by the application or before, stay as they are
673
+ const active = {
674
+ sql: `(${condition.sql}) AND ${this.ddl(column)} IS NULL`,
675
+ params: condition.params
676
+ };
677
+
678
+ if (this.internals.dryRun) {
679
+ return this.driver.runSql(
680
+ `UPDATE ${this.ddl(table)} SET ${set} WHERE ${active.sql}`,
681
+ value.concat(active.params)
682
+ );
683
+ }
684
+
685
+ return this.untilDone(table, key, active, options, match =>
686
+ this.driver.runSql(
687
+ `UPDATE ${this.ddl(table)} SET ${set} WHERE ${match.sql}`,
688
+ value.concat(match.params)
689
+ )
690
+ );
691
+ }
692
+ );
693
+ }
694
+
695
+ /**
696
+ * purge(table, [migration], [options]), deletes the rows deleted in soft
697
+ * mode for good, of all migrations or of the one named. It can not be
698
+ * reverted.
699
+ */
700
+ purge (table, migration, options = {}) {
701
+ if (isObject(migration)) {
702
+ options = migration;
703
+ migration = undefined;
704
+ }
705
+
706
+ const key = this.key(table, options, 'purge');
707
+ const mark = migration ? `|del:${migration}#` : '|del:';
708
+
709
+ return this.step(
710
+ 'purge',
711
+ [table].concat(migration ? [migration] : []),
712
+ () => ({ t: 3, a: 'purge', c: [table] }),
713
+ () => {
714
+ const condition = {
715
+ sql: `${this.ddl(FLAG)} LIKE ? ESCAPE '!'`,
716
+ params: [`%${like(mark)}%`]
717
+ };
718
+
719
+ if (this.internals.dryRun) {
442
720
  return this.driver.runSql(
443
721
  `DELETE FROM ${this.ddl(table)} WHERE ${condition.sql}`,
444
722
  condition.params
445
723
  );
446
724
  }
447
725
 
448
- await this.backup(entry, table, null, condition);
449
- await this.batches(entry, options, rows => {
450
- const match = this.byKey(rows, key);
451
- return this.driver.runSql(
726
+ return this.untilDone(table, key, condition, options, match =>
727
+ this.driver.runSql(
452
728
  `DELETE FROM ${this.ddl(table)} WHERE ${match.sql}`,
453
729
  match.params
454
- );
455
- });
730
+ )
731
+ );
456
732
  }
457
733
  );
458
734
  }
@@ -486,7 +762,7 @@ class Dml {
486
762
  irreversible
487
763
  ? { t: 3, a: 'runSql', c: [sql] }
488
764
  : { t: 4, a: 'runSql', c: [revert[0], revert[1] || []] },
489
- () => this.driver.runSql(sql, params)
765
+ () => this.tx(() => this.driver.runSql(sql, params))
490
766
  );
491
767
  }
492
768
 
@@ -510,15 +786,18 @@ class Dml {
510
786
  const backup = this.ddl(entry.c[1]);
511
787
  const select = columns ? columns.map(c => this.ddl(c)).join(', ') : '*';
512
788
 
789
+ // outside of transactions, creating tables commits them in MySQL
513
790
  await this.driver.runSql(`DROP TABLE IF EXISTS ${backup}`);
514
791
  await this.driver.runSql(
515
792
  `CREATE TABLE ${backup} AS SELECT ${select} FROM ${this.ddl(table)} ` +
516
793
  'WHERE 1 = 0'
517
794
  );
518
- await this.driver.runSql(
519
- `INSERT INTO ${backup} SELECT ${select} FROM ${this.ddl(table)} ` +
520
- `WHERE ${condition.sql}`,
521
- condition.params
795
+ await this.tx(() =>
796
+ this.driver.runSql(
797
+ `INSERT INTO ${backup} SELECT ${select} FROM ${this.ddl(table)} ` +
798
+ `WHERE ${condition.sql}`,
799
+ condition.params
800
+ )
522
801
  );
523
802
 
524
803
  entry.b = 1;
@@ -532,13 +811,14 @@ class Dml {
532
811
  * interruption sets the same values.
533
812
  */
534
813
  async batches (entry, options, change) {
535
- const size = Number(options.batch) || BATCH;
814
+ const size = this.batchSize(options);
536
815
  const backup = this.ddl(entry.c[1]);
537
- const key = entry.c[2].map(k => this.ddl(k)).join(', ');
816
+ const key = entry.c[2];
817
+ const order = key.map(k => this.ddl(k)).join(', ');
538
818
 
539
819
  for (;;) {
540
820
  const rows = await this.driver.all(
541
- `SELECT ${key} FROM ${backup} ORDER BY ${key} ` +
821
+ `SELECT ${order} FROM ${backup} ORDER BY ${order} ` +
542
822
  `LIMIT ${size} OFFSET ${entry.o}`
543
823
  );
544
824
 
@@ -546,10 +826,11 @@ class Dml {
546
826
  return;
547
827
  }
548
828
 
549
- await change(rows);
829
+ await this.tx(() => change(this.byKey(rows, key)));
550
830
  entry.o += rows.length;
551
831
  log.verbose(`[dml] ${this.file.name}: ${entry.o} rows ${entry.a}d`);
552
832
  await this.save();
833
+ await this.between();
553
834
  }
554
835
  }
555
836
  }
@@ -560,12 +841,14 @@ class Dml {
560
841
  */
561
842
  const reverts = {
562
843
  insert: (d, [table, flag]) =>
563
- d.driver.runSql(
564
- `DELETE FROM ${d.ddl(table)} WHERE ${d.ddl(FLAG)} = ?`,
565
- [flag]
844
+ d.tx(() =>
845
+ d.driver.runSql(
846
+ `DELETE FROM ${d.ddl(table)} WHERE ${d.ddl(FLAG)} = ?`,
847
+ [flag]
848
+ )
566
849
  ),
567
850
 
568
- runSql: (d, [sql, params]) => d.driver.runSql(sql, params),
851
+ runSql: (d, [sql, params]) => d.tx(() => d.driver.runSql(sql, params)),
569
852
 
570
853
  update: async (d, [table, backup, key, columns]) => {
571
854
  let offset = 0;
@@ -583,10 +866,12 @@ const reverts = {
583
866
  return;
584
867
  }
585
868
 
586
- await Promise.each(rows, row =>
587
- d.driver.runSql(
588
- `UPDATE ${d.ddl(table)} SET ${assign} WHERE ${match}`,
589
- columns.map(c => row[c]).concat(key.map(k => row[k]))
869
+ await d.tx(() =>
870
+ Promise.each(rows, row =>
871
+ d.driver.runSql(
872
+ `UPDATE ${d.ddl(table)} SET ${assign} WHERE ${match}`,
873
+ columns.map(c => row[c]).concat(key.map(k => row[k]))
874
+ )
590
875
  )
591
876
  );
592
877
  offset += rows.length;
@@ -608,12 +893,32 @@ const reverts = {
608
893
  .join(' AND ');
609
894
 
610
895
  // rows deleted by the step only, those not deleted yet stay as they are
611
- await d.driver.runSql(
612
- `INSERT INTO ${d.ddl(table)} (${columns}) SELECT ${columns} FROM ` +
613
- `${d.ddl(backup)} ${d.ddl('__dbm_b')} WHERE NOT EXISTS (SELECT 1 ` +
614
- `FROM ${d.ddl(table)} ${d.ddl('__dbm_t')} WHERE ${match})`
896
+ await d.tx(() =>
897
+ d.driver.runSql(
898
+ `INSERT INTO ${d.ddl(table)} (${columns}) SELECT ${columns} FROM ` +
899
+ `${d.ddl(backup)} ${d.ddl('__dbm_b')} WHERE NOT EXISTS (SELECT 1 ` +
900
+ `FROM ${d.ddl(table)} ${d.ddl('__dbm_t')} WHERE ${match})`
901
+ )
615
902
  );
616
- }
903
+ },
904
+
905
+ soft: (d, [table, column, key, mark]) =>
906
+ d.untilDone(
907
+ table,
908
+ key,
909
+ {
910
+ sql: `${d.ddl(FLAG)} LIKE ? ESCAPE '!'`,
911
+ params: [`%${like(mark)}%`]
912
+ },
913
+ {},
914
+ match =>
915
+ d.driver.runSql(
916
+ `UPDATE ${d.ddl(table)} SET ${d.ddl(column)} = NULL, ` +
917
+ `${d.ddl(FLAG)} = NULLIF(REPLACE(${d.ddl(FLAG)}, ?, ''), '') ` +
918
+ `WHERE ${match.sql}`,
919
+ [mark].concat(match.params)
920
+ )
921
+ )
617
922
  };
618
923
 
619
924
  const irreversibleSteps = internals =>
@@ -625,8 +930,8 @@ const irreversibleSteps = internals =>
625
930
  * Reverts the recorded steps, the last first. Steps with a backup table
626
931
  * restore the rows from it and drop it afterwards.
627
932
  */
628
- const revert = async (context, file, internals, pdriver) => {
629
- const d = new Dml(context, file, internals, pdriver, null);
933
+ const revert = async (context, file, internals, pdriver, d) => {
934
+ d = d || new Dml(context, file, internals, pdriver, null);
630
935
  const mod = internals.modSchema;
631
936
 
632
937
  while (mod.s.length) {
@@ -637,10 +942,10 @@ const revert = async (context, file, internals, pdriver) => {
637
942
  throw new Error(`Invalid state record of a dml migration, ${entry.a}`);
638
943
  }
639
944
 
640
- if (internals.dryRun && backup) {
945
+ if (internals.dryRun && (backup || entry.a === 'soft')) {
641
946
  log.info(
642
- `[dml] would restore the rows of ${entry.a}("${entry.c[0]}") from ` +
643
- entry.c[1]
947
+ `[dml] would revert ${entry.a}("${entry.c[0]}")` +
948
+ (backup ? ` from ${entry.c[1]}` : '')
644
949
  );
645
950
  } else {
646
951
  // b: 1 the backup is complete, 2 the rows are restored from it
@@ -662,6 +967,21 @@ const revert = async (context, file, internals, pdriver) => {
662
967
  await State.deleteState(pdriver, file, internals);
663
968
  };
664
969
 
970
+ const irreversibleError = (file, internals) => {
971
+ const irreversible = irreversibleSteps(internals);
972
+
973
+ if (!irreversible.length) {
974
+ return null;
975
+ }
976
+
977
+ const err = new Error(
978
+ `Migration "${file.name}" can not be reverted, it ran ` +
979
+ `${irreversible.join(', ')}, which can not be reverted.`
980
+ );
981
+ err.irreversible = true;
982
+ return err;
983
+ };
984
+
665
985
  const opts = context => ({
666
986
  options: context.internals.safeOptions,
667
987
  dbm: context.internals.safeOptions.dbmigrate
@@ -675,14 +995,8 @@ const down = async (context, driver, file, { abort } = {}) => {
675
995
  await State.startMigration(pdriver, file, internals, { op: 'down' });
676
996
  }
677
997
 
678
- const irreversible = irreversibleSteps(internals);
679
- if (irreversible.length) {
680
- const err = new Error(
681
- `Migration "${file.name}" can not be reverted, it ran ` +
682
- `${irreversible.join(', ')} with { irreversible: true }.`
683
- );
684
- err.irreversible = true;
685
-
998
+ const err = irreversibleError(file, internals);
999
+ if (err) {
686
1000
  if (abort !== true) {
687
1001
  await State.endMigration(pdriver, file, internals);
688
1002
  }
@@ -695,15 +1009,16 @@ const down = async (context, driver, file, { abort } = {}) => {
695
1009
  return Promise.promisify(context.deleteMigrationRecord.bind(context))(file);
696
1010
  };
697
1011
 
698
- const up = async (context, driver, file) => {
699
- const internals = context.internals;
700
- const pdriver = context._pdriver;
1012
+ /**
1013
+ * Runs the migration, after recovering an interrupted run, and rolls it
1014
+ * back if it fails. Shared by the foreground and the background workers.
1015
+ *
1016
+ * hooks.rollingBack: records that the rollback started
1017
+ * hooks.restart: records a fresh start after recovering by rollback
1018
+ */
1019
+ const execute = async (context, file, internals, pdriver, interrupted, progress, work, hooks) => {
701
1020
  let recovery = null;
702
1021
 
703
- const interrupted = await State.startMigration(pdriver, file, internals, {
704
- recover: true
705
- });
706
-
707
1022
  if (interrupted) {
708
1023
  const mode = recoveryMode(file, interrupted);
709
1024
  log.warn(
@@ -713,29 +1028,32 @@ const up = async (context, driver, file) => {
713
1028
  );
714
1029
 
715
1030
  if (mode === 'rollback') {
716
- await State.rollingBack(pdriver, internals);
1031
+ await hooks.rollingBack();
717
1032
  await revert(context, file, internals, pdriver);
718
1033
  internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
719
- await State.endMigration(pdriver, file, internals);
720
- await State.startMigration(pdriver, file, internals);
1034
+ await hooks.restart();
721
1035
  } else {
722
1036
  recovery = interrupted;
723
1037
  }
724
1038
  }
725
1039
 
726
1040
  internals.migrationDone = recovery ? recovery.done : 0;
727
- const db = new Dml(context, file, internals, pdriver, recovery);
1041
+ const db = new Dml(context, file, internals, pdriver, recovery, progress, work);
728
1042
 
729
1043
  try {
730
1044
  await file.get().migrate(db, opts(context));
731
1045
  } catch (err) {
1046
+ if (err && err.stopped) {
1047
+ throw err;
1048
+ }
1049
+
732
1050
  describeFailure(err, db, internals);
733
1051
 
734
1052
  if (irreversibleSteps(internals).length) {
735
1053
  log.error(
736
1054
  `Migration "${file.name}" failed and can not be rolled back, it ` +
737
- 'ran a step with { irreversible: true }. The steps executed ' +
738
- 'stay, the next run continues after them.'
1055
+ 'ran a step which can not be reverted. The steps executed stay, ' +
1056
+ 'the next run continues after them.'
739
1057
  );
740
1058
  throw err;
741
1059
  }
@@ -746,8 +1064,34 @@ const up = async (context, driver, file) => {
746
1064
  (err && err.message) || err
747
1065
  }`
748
1066
  );
749
- await State.rollingBack(pdriver, internals);
750
- await down(context, driver, file, { abort: true });
1067
+ await hooks.rollingBack();
1068
+ await revert(context, file, internals, pdriver, db);
1069
+ err.rolledBack = true;
1070
+ throw err;
1071
+ }
1072
+ };
1073
+
1074
+ const up = async (context, driver, file) => {
1075
+ const internals = context.internals;
1076
+ const pdriver = context._pdriver;
1077
+
1078
+ const interrupted = await State.startMigration(pdriver, file, internals, {
1079
+ recover: true
1080
+ });
1081
+
1082
+ try {
1083
+ await execute(context, file, internals, pdriver, interrupted, null, {}, {
1084
+ rollingBack: () => State.rollingBack(pdriver, internals),
1085
+ restart: async () => {
1086
+ await State.endMigration(pdriver, file, internals);
1087
+ await State.startMigration(pdriver, file, internals);
1088
+ }
1089
+ });
1090
+ } catch (err) {
1091
+ if (err && err.rolledBack) {
1092
+ await State.endMigration(pdriver, file, internals);
1093
+ }
1094
+
751
1095
  throw err;
752
1096
  }
753
1097
 
@@ -755,4 +1099,17 @@ const up = async (context, driver, file) => {
755
1099
  return State.endMigration(pdriver, file, internals);
756
1100
  };
757
1101
 
758
- module.exports = { isDml, checkType, up, down, Dml, FLAG, insertRows };
1102
+ module.exports = {
1103
+ isDml,
1104
+ isBackground,
1105
+ checkType,
1106
+ up,
1107
+ down,
1108
+ execute,
1109
+ revert,
1110
+ irreversibleError,
1111
+ Dml,
1112
+ Stopped,
1113
+ FLAG,
1114
+ insertRows
1115
+ };