db-migrate 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,83 @@ 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
+ // migrations paused the jobs
286
+ if (this.work.yielding && (await this.work.yielding())) {
287
+ throw new Stopped();
288
+ }
289
+
290
+ if (this.work.pause) {
291
+ await delay(this.work.pause);
292
+ }
293
+ }
294
+
295
+ batchSize (options) {
296
+ return Number(options.batch) || Number(this.work.batch) || BATCH;
297
+ }
298
+
168
299
  /**
169
300
  * The key of a table, to change and restore its rows by. Taken from the
170
301
  * schema, for other tables it is passed with { key }.
@@ -179,10 +310,12 @@ class Dml {
179
310
 
180
311
  if (!key.length) {
181
312
  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.'
313
+ `${action}("${table}") needs the primary key of "${table}", which is ` +
314
+ "unknown to the schema of db-migrate. Pass it with { key: 'id' }" +
315
+ (action === 'purge'
316
+ ? '.'
317
+ : ', or pass { irreversible: true } to change the rows without ' +
318
+ 'being able to revert it.')
186
319
  );
187
320
  }
188
321
 
@@ -251,6 +384,31 @@ class Dml {
251
384
  return { sql, params };
252
385
  }
253
386
 
387
+ /**
388
+ * Changes the rows matching condition in batches by their key, until none
389
+ * matches anymore. Changing a row has to make it stop matching, so this
390
+ * continues by itself after an interruption.
391
+ */
392
+ async untilDone (table, key, condition, options, change) {
393
+ const size = this.batchSize(options);
394
+ const order = key.map(k => this.ddl(k)).join(', ');
395
+
396
+ for (;;) {
397
+ const rows = await this.select(
398
+ `SELECT ${order} FROM ${this.ddl(table)} WHERE ${condition.sql} ` +
399
+ `ORDER BY ${order} LIMIT ${size}`,
400
+ condition.params
401
+ );
402
+
403
+ if (!rows.length) {
404
+ return;
405
+ }
406
+
407
+ await this.tx(() => change(this.byKey(rows, key)));
408
+ await this.between();
409
+ }
410
+ }
411
+
254
412
  /**
255
413
  * Runs one instruction as a step: records what reverts it, executes it and
256
414
  * records it as done. The step of an interrupted run is continued, with
@@ -272,7 +430,7 @@ class Dml {
272
430
  return;
273
431
  }
274
432
 
275
- await State.step(this.pdriver, op, internals);
433
+ await this.progress.step(op);
276
434
 
277
435
  let entry = recovery && op <= recovery.learned
278
436
  ? mod.s.find(e => e.n === op)
@@ -288,22 +446,29 @@ class Dml {
288
446
  entry = record(op);
289
447
  entry.n = op;
290
448
  mod.s.push(entry);
291
- await State.update(this.pdriver, this.file, mod, internals);
292
- await State.learned(this.pdriver, op, internals);
449
+ await this.save();
450
+ await this.progress.learned(op);
293
451
  }
294
452
 
295
453
  await execute(entry, op);
296
454
 
297
455
  internals.migrationDone = op;
298
- await State.done(this.pdriver, op, internals);
456
+ await this.progress.done(op);
457
+ await this.between();
299
458
  }
300
459
 
460
+ /**
461
+ * The records of the migration, its schema stays as it is.
462
+ */
301
463
  save () {
302
- return State.update(
303
- this.pdriver,
304
- this.file,
305
- this.internals.modSchema,
306
- this.internals
464
+ if (this.internals.dryRun) {
465
+ return Promise.resolve();
466
+ }
467
+
468
+ return this.pdriver._updateKV(
469
+ this.internals.migrationState,
470
+ this.file.name,
471
+ JSON.stringify(this.internals.modSchema)
307
472
  );
308
473
  }
309
474
 
@@ -342,26 +507,27 @@ class Dml {
342
507
  irreversible
343
508
  ? { t: 3, a: 'insert', c: [table] }
344
509
  : { t: 4, a: 'insert', c: [table, `${this.file.name}#${op}`] },
345
- async entry => {
346
- if (!rows.length) {
347
- return;
348
- }
510
+ entry =>
511
+ this.tx(async () => {
512
+ if (!rows.length) {
513
+ return;
514
+ }
349
515
 
350
- if (irreversible) {
351
- return this.driver.insert(table, rows);
352
- }
516
+ if (irreversible) {
517
+ return this.driver.insert(table, rows);
518
+ }
353
519
 
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
- }
520
+ const flag = entry.c[1];
521
+ // rows of an interrupted run of this step
522
+ await this.driver.runSql(
523
+ `DELETE FROM ${this.ddl(table)} WHERE ${this.ddl(FLAG)} = ?`,
524
+ [flag]
525
+ );
526
+ await this.driver.insert(
527
+ table,
528
+ rows.map(row => Object.assign({}, row, { [FLAG]: flag }))
529
+ );
530
+ })
365
531
  );
366
532
  }
367
533
 
@@ -404,28 +570,46 @@ class Dml {
404
570
  },
405
571
  async entry => {
406
572
  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)
573
+ return this.tx(() =>
574
+ this.driver.runSql(
575
+ `UPDATE ${this.ddl(table)} SET ${assign} WHERE ${condition.sql}`,
576
+ values.concat(condition.params)
577
+ )
410
578
  );
411
579
  }
412
580
 
413
581
  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(
582
+ await this.batches(entry, options, match =>
583
+ this.driver.runSql(
417
584
  `UPDATE ${this.ddl(table)} SET ${assign} WHERE ${match.sql}`,
418
585
  values.concat(match.params)
419
- );
420
- });
586
+ )
587
+ );
421
588
  }
422
589
  );
423
590
  }
424
591
 
425
592
  /**
426
593
  * delete(table, where, [options]), delete the rows matching where.
594
+ *
595
+ * mode 'copy' (default) copies the rows to a backup table first. mode
596
+ * 'soft' keeps the rows, sets column to the current time, or value, and
597
+ * marks them in their flag. The application filters them by column, until
598
+ * purge deletes them.
427
599
  */
428
600
  delete (table, where, options = {}) {
601
+ const mode = options.mode || 'copy';
602
+
603
+ if (mode === 'soft') {
604
+ return this.softDelete(table, where, options);
605
+ }
606
+
607
+ if (mode !== 'copy') {
608
+ throw new Error(
609
+ `delete("${table}") has no mode "${mode}", use 'copy' or 'soft'`
610
+ );
611
+ }
612
+
429
613
  const condition = this.where(where);
430
614
  const irreversible = options.irreversible === true;
431
615
  const key = irreversible ? null : this.key(table, options, 'delete');
@@ -439,20 +623,117 @@ class Dml {
439
623
  : { t: 4, a: 'delete', c: [table, backupName(this.file, op), key] },
440
624
  async entry => {
441
625
  if (irreversible || this.internals.dryRun) {
626
+ return this.tx(() =>
627
+ this.driver.runSql(
628
+ `DELETE FROM ${this.ddl(table)} WHERE ${condition.sql}`,
629
+ condition.params
630
+ )
631
+ );
632
+ }
633
+
634
+ await this.backup(entry, table, null, condition);
635
+ await this.batches(entry, options, match =>
636
+ this.driver.runSql(
637
+ `DELETE FROM ${this.ddl(table)} WHERE ${match.sql}`,
638
+ match.params
639
+ )
640
+ );
641
+ }
642
+ );
643
+ }
644
+
645
+ softDelete (table, where, options) {
646
+ const column = options.column;
647
+ const columns = this.columns(table);
648
+
649
+ if (typeof column !== 'string') {
650
+ throw new Error(
651
+ `delete("${table}") in soft mode needs the column marking the rows ` +
652
+ "as deleted, { mode: 'soft', column: 'deleted_at' }"
653
+ );
654
+ }
655
+
656
+ if (!columns || !columns[FLAG]) {
657
+ throw new Error(
658
+ `delete("${table}") in soft mode marks the rows in their ${FLAG} ` +
659
+ 'column, which only tables created by v2 migrations have'
660
+ );
661
+ }
662
+
663
+ const condition = this.where(where);
664
+ const key = this.key(table, options, 'delete');
665
+ const time = options.value === undefined;
666
+
667
+ return this.step(
668
+ 'delete',
669
+ [table],
670
+ op => ({ t: 4, a: 'soft', c: [table, column, key, deleted(this.file, op)] }),
671
+ entry => {
672
+ const mark = entry.c[3];
673
+ const set =
674
+ `${this.ddl(column)} = ${time ? 'CURRENT_TIMESTAMP' : '?'}, ` +
675
+ `${this.ddl(FLAG)} = ${this.concat(`COALESCE(${this.ddl(FLAG)}, '')`)}`;
676
+ const value = time ? [mark] : [toValue(options.value), mark];
677
+ // rows deleted already, by the application or before, stay as they are
678
+ const active = {
679
+ sql: `(${condition.sql}) AND ${this.ddl(column)} IS NULL`,
680
+ params: condition.params
681
+ };
682
+
683
+ if (this.internals.dryRun) {
684
+ return this.driver.runSql(
685
+ `UPDATE ${this.ddl(table)} SET ${set} WHERE ${active.sql}`,
686
+ value.concat(active.params)
687
+ );
688
+ }
689
+
690
+ return this.untilDone(table, key, active, options, match =>
691
+ this.driver.runSql(
692
+ `UPDATE ${this.ddl(table)} SET ${set} WHERE ${match.sql}`,
693
+ value.concat(match.params)
694
+ )
695
+ );
696
+ }
697
+ );
698
+ }
699
+
700
+ /**
701
+ * purge(table, [migration], [options]), deletes the rows deleted in soft
702
+ * mode for good, of all migrations or of the one named. It can not be
703
+ * reverted.
704
+ */
705
+ purge (table, migration, options = {}) {
706
+ if (isObject(migration)) {
707
+ options = migration;
708
+ migration = undefined;
709
+ }
710
+
711
+ const key = this.key(table, options, 'purge');
712
+ const mark = migration ? `|del:${migration}#` : '|del:';
713
+
714
+ return this.step(
715
+ 'purge',
716
+ [table].concat(migration ? [migration] : []),
717
+ () => ({ t: 3, a: 'purge', c: [table] }),
718
+ () => {
719
+ const condition = {
720
+ sql: `${this.ddl(FLAG)} LIKE ? ESCAPE '!'`,
721
+ params: [`%${like(mark)}%`]
722
+ };
723
+
724
+ if (this.internals.dryRun) {
442
725
  return this.driver.runSql(
443
726
  `DELETE FROM ${this.ddl(table)} WHERE ${condition.sql}`,
444
727
  condition.params
445
728
  );
446
729
  }
447
730
 
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(
731
+ return this.untilDone(table, key, condition, options, match =>
732
+ this.driver.runSql(
452
733
  `DELETE FROM ${this.ddl(table)} WHERE ${match.sql}`,
453
734
  match.params
454
- );
455
- });
735
+ )
736
+ );
456
737
  }
457
738
  );
458
739
  }
@@ -486,7 +767,7 @@ class Dml {
486
767
  irreversible
487
768
  ? { t: 3, a: 'runSql', c: [sql] }
488
769
  : { t: 4, a: 'runSql', c: [revert[0], revert[1] || []] },
489
- () => this.driver.runSql(sql, params)
770
+ () => this.tx(() => this.driver.runSql(sql, params))
490
771
  );
491
772
  }
492
773
 
@@ -510,15 +791,18 @@ class Dml {
510
791
  const backup = this.ddl(entry.c[1]);
511
792
  const select = columns ? columns.map(c => this.ddl(c)).join(', ') : '*';
512
793
 
794
+ // outside of transactions, creating tables commits them in MySQL
513
795
  await this.driver.runSql(`DROP TABLE IF EXISTS ${backup}`);
514
796
  await this.driver.runSql(
515
797
  `CREATE TABLE ${backup} AS SELECT ${select} FROM ${this.ddl(table)} ` +
516
798
  'WHERE 1 = 0'
517
799
  );
518
- await this.driver.runSql(
519
- `INSERT INTO ${backup} SELECT ${select} FROM ${this.ddl(table)} ` +
520
- `WHERE ${condition.sql}`,
521
- condition.params
800
+ await this.tx(() =>
801
+ this.driver.runSql(
802
+ `INSERT INTO ${backup} SELECT ${select} FROM ${this.ddl(table)} ` +
803
+ `WHERE ${condition.sql}`,
804
+ condition.params
805
+ )
522
806
  );
523
807
 
524
808
  entry.b = 1;
@@ -532,13 +816,14 @@ class Dml {
532
816
  * interruption sets the same values.
533
817
  */
534
818
  async batches (entry, options, change) {
535
- const size = Number(options.batch) || BATCH;
819
+ const size = this.batchSize(options);
536
820
  const backup = this.ddl(entry.c[1]);
537
- const key = entry.c[2].map(k => this.ddl(k)).join(', ');
821
+ const key = entry.c[2];
822
+ const order = key.map(k => this.ddl(k)).join(', ');
538
823
 
539
824
  for (;;) {
540
825
  const rows = await this.driver.all(
541
- `SELECT ${key} FROM ${backup} ORDER BY ${key} ` +
826
+ `SELECT ${order} FROM ${backup} ORDER BY ${order} ` +
542
827
  `LIMIT ${size} OFFSET ${entry.o}`
543
828
  );
544
829
 
@@ -546,10 +831,11 @@ class Dml {
546
831
  return;
547
832
  }
548
833
 
549
- await change(rows);
834
+ await this.tx(() => change(this.byKey(rows, key)));
550
835
  entry.o += rows.length;
551
836
  log.verbose(`[dml] ${this.file.name}: ${entry.o} rows ${entry.a}d`);
552
837
  await this.save();
838
+ await this.between();
553
839
  }
554
840
  }
555
841
  }
@@ -560,12 +846,14 @@ class Dml {
560
846
  */
561
847
  const reverts = {
562
848
  insert: (d, [table, flag]) =>
563
- d.driver.runSql(
564
- `DELETE FROM ${d.ddl(table)} WHERE ${d.ddl(FLAG)} = ?`,
565
- [flag]
849
+ d.tx(() =>
850
+ d.driver.runSql(
851
+ `DELETE FROM ${d.ddl(table)} WHERE ${d.ddl(FLAG)} = ?`,
852
+ [flag]
853
+ )
566
854
  ),
567
855
 
568
- runSql: (d, [sql, params]) => d.driver.runSql(sql, params),
856
+ runSql: (d, [sql, params]) => d.tx(() => d.driver.runSql(sql, params)),
569
857
 
570
858
  update: async (d, [table, backup, key, columns]) => {
571
859
  let offset = 0;
@@ -583,10 +871,12 @@ const reverts = {
583
871
  return;
584
872
  }
585
873
 
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]))
874
+ await d.tx(() =>
875
+ Promise.each(rows, row =>
876
+ d.driver.runSql(
877
+ `UPDATE ${d.ddl(table)} SET ${assign} WHERE ${match}`,
878
+ columns.map(c => row[c]).concat(key.map(k => row[k]))
879
+ )
590
880
  )
591
881
  );
592
882
  offset += rows.length;
@@ -608,12 +898,32 @@ const reverts = {
608
898
  .join(' AND ');
609
899
 
610
900
  // 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})`
901
+ await d.tx(() =>
902
+ d.driver.runSql(
903
+ `INSERT INTO ${d.ddl(table)} (${columns}) SELECT ${columns} FROM ` +
904
+ `${d.ddl(backup)} ${d.ddl('__dbm_b')} WHERE NOT EXISTS (SELECT 1 ` +
905
+ `FROM ${d.ddl(table)} ${d.ddl('__dbm_t')} WHERE ${match})`
906
+ )
615
907
  );
616
- }
908
+ },
909
+
910
+ soft: (d, [table, column, key, mark]) =>
911
+ d.untilDone(
912
+ table,
913
+ key,
914
+ {
915
+ sql: `${d.ddl(FLAG)} LIKE ? ESCAPE '!'`,
916
+ params: [`%${like(mark)}%`]
917
+ },
918
+ {},
919
+ match =>
920
+ d.driver.runSql(
921
+ `UPDATE ${d.ddl(table)} SET ${d.ddl(column)} = NULL, ` +
922
+ `${d.ddl(FLAG)} = NULLIF(REPLACE(${d.ddl(FLAG)}, ?, ''), '') ` +
923
+ `WHERE ${match.sql}`,
924
+ [mark].concat(match.params)
925
+ )
926
+ )
617
927
  };
618
928
 
619
929
  const irreversibleSteps = internals =>
@@ -625,8 +935,8 @@ const irreversibleSteps = internals =>
625
935
  * Reverts the recorded steps, the last first. Steps with a backup table
626
936
  * restore the rows from it and drop it afterwards.
627
937
  */
628
- const revert = async (context, file, internals, pdriver) => {
629
- const d = new Dml(context, file, internals, pdriver, null);
938
+ const revert = async (context, file, internals, pdriver, d) => {
939
+ d = d || new Dml(context, file, internals, pdriver, null);
630
940
  const mod = internals.modSchema;
631
941
 
632
942
  while (mod.s.length) {
@@ -637,10 +947,10 @@ const revert = async (context, file, internals, pdriver) => {
637
947
  throw new Error(`Invalid state record of a dml migration, ${entry.a}`);
638
948
  }
639
949
 
640
- if (internals.dryRun && backup) {
950
+ if (internals.dryRun && (backup || entry.a === 'soft')) {
641
951
  log.info(
642
- `[dml] would restore the rows of ${entry.a}("${entry.c[0]}") from ` +
643
- entry.c[1]
952
+ `[dml] would revert ${entry.a}("${entry.c[0]}")` +
953
+ (backup ? ` from ${entry.c[1]}` : '')
644
954
  );
645
955
  } else {
646
956
  // b: 1 the backup is complete, 2 the rows are restored from it
@@ -662,6 +972,21 @@ const revert = async (context, file, internals, pdriver) => {
662
972
  await State.deleteState(pdriver, file, internals);
663
973
  };
664
974
 
975
+ const irreversibleError = (file, internals) => {
976
+ const irreversible = irreversibleSteps(internals);
977
+
978
+ if (!irreversible.length) {
979
+ return null;
980
+ }
981
+
982
+ const err = new Error(
983
+ `Migration "${file.name}" can not be reverted, it ran ` +
984
+ `${irreversible.join(', ')}, which can not be reverted.`
985
+ );
986
+ err.irreversible = true;
987
+ return err;
988
+ };
989
+
665
990
  const opts = context => ({
666
991
  options: context.internals.safeOptions,
667
992
  dbm: context.internals.safeOptions.dbmigrate
@@ -675,14 +1000,8 @@ const down = async (context, driver, file, { abort } = {}) => {
675
1000
  await State.startMigration(pdriver, file, internals, { op: 'down' });
676
1001
  }
677
1002
 
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
-
1003
+ const err = irreversibleError(file, internals);
1004
+ if (err) {
686
1005
  if (abort !== true) {
687
1006
  await State.endMigration(pdriver, file, internals);
688
1007
  }
@@ -695,15 +1014,16 @@ const down = async (context, driver, file, { abort } = {}) => {
695
1014
  return Promise.promisify(context.deleteMigrationRecord.bind(context))(file);
696
1015
  };
697
1016
 
698
- const up = async (context, driver, file) => {
699
- const internals = context.internals;
700
- const pdriver = context._pdriver;
1017
+ /**
1018
+ * Runs the migration, after recovering an interrupted run, and rolls it
1019
+ * back if it fails. Shared by the foreground and the background workers.
1020
+ *
1021
+ * hooks.rollingBack: records that the rollback started
1022
+ * hooks.restart: records a fresh start after recovering by rollback
1023
+ */
1024
+ const execute = async (context, file, internals, pdriver, interrupted, progress, work, hooks) => {
701
1025
  let recovery = null;
702
1026
 
703
- const interrupted = await State.startMigration(pdriver, file, internals, {
704
- recover: true
705
- });
706
-
707
1027
  if (interrupted) {
708
1028
  const mode = recoveryMode(file, interrupted);
709
1029
  log.warn(
@@ -713,29 +1033,32 @@ const up = async (context, driver, file) => {
713
1033
  );
714
1034
 
715
1035
  if (mode === 'rollback') {
716
- await State.rollingBack(pdriver, internals);
1036
+ await hooks.rollingBack();
717
1037
  await revert(context, file, internals, pdriver);
718
1038
  internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
719
- await State.endMigration(pdriver, file, internals);
720
- await State.startMigration(pdriver, file, internals);
1039
+ await hooks.restart();
721
1040
  } else {
722
1041
  recovery = interrupted;
723
1042
  }
724
1043
  }
725
1044
 
726
1045
  internals.migrationDone = recovery ? recovery.done : 0;
727
- const db = new Dml(context, file, internals, pdriver, recovery);
1046
+ const db = new Dml(context, file, internals, pdriver, recovery, progress, work);
728
1047
 
729
1048
  try {
730
1049
  await file.get().migrate(db, opts(context));
731
1050
  } catch (err) {
1051
+ if (err && err.stopped) {
1052
+ throw err;
1053
+ }
1054
+
732
1055
  describeFailure(err, db, internals);
733
1056
 
734
1057
  if (irreversibleSteps(internals).length) {
735
1058
  log.error(
736
1059
  `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.'
1060
+ 'ran a step which can not be reverted. The steps executed stay, ' +
1061
+ 'the next run continues after them.'
739
1062
  );
740
1063
  throw err;
741
1064
  }
@@ -746,8 +1069,34 @@ const up = async (context, driver, file) => {
746
1069
  (err && err.message) || err
747
1070
  }`
748
1071
  );
749
- await State.rollingBack(pdriver, internals);
750
- await down(context, driver, file, { abort: true });
1072
+ await hooks.rollingBack();
1073
+ await revert(context, file, internals, pdriver, db);
1074
+ err.rolledBack = true;
1075
+ throw err;
1076
+ }
1077
+ };
1078
+
1079
+ const up = async (context, driver, file) => {
1080
+ const internals = context.internals;
1081
+ const pdriver = context._pdriver;
1082
+
1083
+ const interrupted = await State.startMigration(pdriver, file, internals, {
1084
+ recover: true
1085
+ });
1086
+
1087
+ try {
1088
+ await execute(context, file, internals, pdriver, interrupted, null, {}, {
1089
+ rollingBack: () => State.rollingBack(pdriver, internals),
1090
+ restart: async () => {
1091
+ await State.endMigration(pdriver, file, internals);
1092
+ await State.startMigration(pdriver, file, internals);
1093
+ }
1094
+ });
1095
+ } catch (err) {
1096
+ if (err && err.rolledBack) {
1097
+ await State.endMigration(pdriver, file, internals);
1098
+ }
1099
+
751
1100
  throw err;
752
1101
  }
753
1102
 
@@ -755,4 +1104,17 @@ const up = async (context, driver, file) => {
755
1104
  return State.endMigration(pdriver, file, internals);
756
1105
  };
757
1106
 
758
- module.exports = { isDml, checkType, up, down, Dml, FLAG, insertRows };
1107
+ module.exports = {
1108
+ isDml,
1109
+ isBackground,
1110
+ checkType,
1111
+ up,
1112
+ down,
1113
+ execute,
1114
+ revert,
1115
+ irreversibleError,
1116
+ Dml,
1117
+ Stopped,
1118
+ FLAG,
1119
+ insertRows
1120
+ };