db-migrate 1.0.0-beta.36 → 1.0.0-beta.37

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/connect.js CHANGED
@@ -85,7 +85,8 @@ exports.connect = async function (config, PassedClass) {
85
85
  dirPath,
86
86
  internals.mode !== 'static',
87
87
  internals,
88
- prefix
88
+ prefix,
89
+ { db2 }
89
90
  )
90
91
  );
91
92
  }
package/lib/chain.js CHANGED
@@ -1,3 +1,5 @@
1
+ const log = require('db-migrate-shared').log;
2
+
1
3
  const DEFAULT = [
2
4
  'renameCollection',
3
5
  'dropCollection',
@@ -15,6 +17,21 @@ const DEFAULT = [
15
17
  'removeForeignKey'
16
18
  ];
17
19
 
20
+ /**
21
+ * Human readable form of an instruction for the log, like
22
+ * addColumn("users", "email")
23
+ */
24
+ const describe = (method, args) =>
25
+ `${method}(${args
26
+ .filter(
27
+ a =>
28
+ typeof a === 'string' ||
29
+ typeof a === 'number' ||
30
+ (Array.isArray(a) && a.every(x => typeof x === 'string'))
31
+ )
32
+ .map(a => JSON.stringify(a))
33
+ .join(', ')})`;
34
+
18
35
  let rollbackCounter = 0;
19
36
  let rollbackSignal = null;
20
37
  let signal = null;
@@ -82,6 +99,14 @@ class Chain {
82
99
  this._step = null;
83
100
  this._interface = null;
84
101
 
102
+ /**
103
+ * op: number of the current step, the instructions of a migration
104
+ * recovery: progress of an interrupted previous run to resume from,
105
+ * see State.startMigration
106
+ */
107
+ this.op = 0;
108
+ this.recovery = null;
109
+
85
110
  DEFAULT.concat(Object.keys(this.context.learnable || [])).forEach(
86
111
  method => {
87
112
  this[method] = function (...args) {
@@ -130,7 +155,33 @@ class Chain {
130
155
  async exec (m, ...args) {
131
156
  let ret;
132
157
  const stat = [];
158
+ const op = ++this.op;
159
+ const recovery = this.recovery;
160
+ this.internals.migrationOp = op;
161
+
162
+ if (recovery && op <= recovery.done) {
163
+ log.info(
164
+ `[recovery] ${this.file.name}: skipping already executed step ` +
165
+ `${op}/${recovery.done} ${describe(m, args)}`
166
+ );
167
+ return ret;
168
+ }
169
+
170
+ // the step was interrupted after its reverse operation was recorded,
171
+ // but before it finished on the database
172
+ const learned = recovery !== null && op <= recovery.learned;
173
+ if (learned) {
174
+ log.info(
175
+ `[recovery] ${this.file.name}: executing interrupted step ${op} ` +
176
+ `${describe(m, args)} without learning it again`
177
+ );
178
+ }
179
+
133
180
  while (this.step()) {
181
+ if (learned && this._step.learns) {
182
+ continue;
183
+ }
184
+
134
185
  ret = await this._interface[m].apply(this._interface, args);
135
186
  stat.push(ret);
136
187
 
@@ -8,10 +8,76 @@ const Chain = require('../../chain');
8
8
  const StateTravel = require('../../methods/v2/statetravel')();
9
9
  const StateNoStepTravel = require('../../methods/v2/statetravel')(false);
10
10
  const Migrate = require('../../methods/v2/migrate');
11
+ const StateDone = require('../../methods/v2/statedone');
11
12
  const TranslateState = require('../../methods/v2/translatestate');
12
13
  const AddConventions = require('../../methods/v2/conventions');
13
14
  const util = require('util');
14
15
 
16
+ /**
17
+ * How to recover from an interrupted run of a migration, set per migration
18
+ * by _meta.recovery:
19
+ *
20
+ * skip: skip the steps already executed and continue with the rest
21
+ * rollback: revert the steps already executed and run the migration again
22
+ */
23
+ const RECOVERY_MODES = ['skip', 'rollback'];
24
+ const RECOVERY_DEFAULT = 'skip';
25
+
26
+ const recoveryMode = (file, interrupted) => {
27
+ const name = file.name;
28
+ const mode = file.get()._meta.recovery || RECOVERY_DEFAULT;
29
+
30
+ if (RECOVERY_MODES.indexOf(mode) === -1) {
31
+ throw new Error(
32
+ `Invalid recovery mode "${mode}" in migration "${name}", use one of ` +
33
+ RECOVERY_MODES.join(', ')
34
+ );
35
+ }
36
+
37
+ if (interrupted.rollback) {
38
+ if (mode !== 'rollback') {
39
+ log.warn(
40
+ `[recovery] ${name}: the previous run was interrupted while rolling ` +
41
+ 'back, continuing the rollback'
42
+ );
43
+ }
44
+
45
+ return 'rollback';
46
+ }
47
+
48
+ if (mode === 'skip' && interrupted.changed) {
49
+ throw new Error(
50
+ `Migration "${name}" was interrupted at step ${interrupted.step} and ` +
51
+ 'changed since, so the executed steps can not be skipped safely. ' +
52
+ "Set _meta.recovery to 'rollback' to revert them, or repair the " +
53
+ 'state manually.'
54
+ );
55
+ }
56
+
57
+ return mode;
58
+ };
59
+
60
+ /**
61
+ * Revert the steps executed by an interrupted run. A step started but not
62
+ * done did not change the database, so its reverse operation is dropped.
63
+ */
64
+ const recoverByRollback = async (context, driver, file, interrupted) => {
65
+ const internals = context.internals;
66
+ const pdriver = context._pdriver;
67
+
68
+ await State.rollingBack(pdriver, internals);
69
+ internals.modSchema.s = internals.modSchema.s.filter(
70
+ entry => !(entry.n > interrupted.done)
71
+ );
72
+ await TranslateState(context._driver, file, driver, internals, pdriver);
73
+
74
+ internals.unlearn = false;
75
+ internals.rollback = false;
76
+ internals.rollbackContinue = false;
77
+ internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
78
+ await State.endMigration(pdriver, file, internals);
79
+ };
80
+
15
81
  const execUnit = {
16
82
  _extend: (context, type) => {
17
83
  return {
@@ -38,8 +104,31 @@ const execUnit = {
38
104
  chain.addChain(Learn);
39
105
  chain.addChain(StateNoStepTravel);
40
106
  chain.addChain(Migrate);
107
+ chain.addChain(StateDone);
108
+
109
+ const interrupted = await State.startMigration(
110
+ context._pdriver,
111
+ file,
112
+ context.internals,
113
+ { recover: true }
114
+ );
115
+
116
+ if (interrupted) {
117
+ const mode = recoveryMode(file, interrupted);
118
+ log.warn(
119
+ `[recovery] ${file.name}: the previous run was interrupted at step ` +
120
+ `${interrupted.step}, ${interrupted.done} steps were executed, ` +
121
+ `recovering by ${mode}`
122
+ );
123
+
124
+ if (mode === 'rollback') {
125
+ await recoverByRollback(context, driver, file, interrupted);
126
+ await State.startMigration(context._pdriver, file, context.internals);
127
+ } else {
128
+ chain.recovery = interrupted;
129
+ }
130
+ }
41
131
 
42
- await State.startMigration(context._pdriver, file, context.internals);
43
132
  // startMigration - needs secondary instance since we can not afford to
44
133
  // loose state and the transaction start will include these for roll back
45
134
  // we will disable them probably at all from DDL when the driver does not
@@ -60,6 +149,7 @@ const execUnit = {
60
149
  'An error occured. No alternative failure strategy defined. Rolling back!',
61
150
  err
62
151
  );
152
+ await State.rollingBack(context._pdriver, context.internals);
63
153
  await execUnit.down(context, driver, file, { abort: true });
64
154
  throw err;
65
155
  }
@@ -77,7 +167,9 @@ const execUnit = {
77
167
  chain.addChain(Learn);
78
168
  chain.addChain(StateTravel);
79
169
 
80
- await State.startMigration(context._pdriver, file, context.internals);
170
+ await State.startMigration(context._pdriver, file, context.internals, {
171
+ op: 'fix'
172
+ });
81
173
  // startMigration - needs secondary instance since we can not afford to
82
174
  // loose state and the transaction start will include these for roll back
83
175
  // we will disable them probably at all from DDL when the driver does not
@@ -110,7 +202,9 @@ const execUnit = {
110
202
  // if we get the abort signal this means we are in a rollback routine
111
203
  // which means in turn we want to avoid reloading the state
112
204
  if (abort !== true) {
113
- await State.startMigration(context._pdriver, file, context.internals);
205
+ await State.startMigration(context._pdriver, file, context.internals, {
206
+ op: 'down'
207
+ });
114
208
  }
115
209
  // start migration, see up comments
116
210
  await TranslateState(context._driver, file, driver, context.internals, context._pdriver);
package/lib/learn.js CHANGED
@@ -260,7 +260,7 @@ class STD {
260
260
 
261
261
  addIndex (t, i, c, u) {
262
262
  if (Array.isArray(c)) {
263
- c.forEach(x => this.checkColumn(t, typeof x === 'object' ? x.name : x));
263
+ c.forEach(x => this.checkColumn(t, typeof x === 'object' ? x.name : x));
264
264
  } else {
265
265
  this.checkColumn(t, c);
266
266
  }
@@ -371,6 +371,9 @@ const noLearnError = prop => {
371
371
  };
372
372
 
373
373
  module.exports = {
374
+ // learns the schema and records the reverse operations
375
+ learns: true,
376
+
374
377
  getInterface: (context, file, driver, internals) => {
375
378
  if (context.learnable) {
376
379
  const _std = new STD(internals, context);
@@ -0,0 +1,16 @@
1
+ const Shadow = require('../../driver/shadow');
2
+ const State = require('../../state');
3
+
4
+ /**
5
+ * Records a step as done, after it was executed on the database.
6
+ */
7
+ module.exports = {
8
+ getInterface: (context, file, driver, internals, pdriver) => {
9
+ const done = () =>
10
+ function () {
11
+ return State.done(pdriver, internals.migrationOp, internals);
12
+ };
13
+
14
+ return Shadow.overshadow(driver, {}, done);
15
+ }
16
+ };
@@ -17,24 +17,38 @@ const DEFAULT = {
17
17
  removeForeignKey: 'addForeignKey'
18
18
  };
19
19
 
20
+ /**
21
+ * Persists the state around learning a step. Before learning (step) it
22
+ * records the step as started, afterwards it tags the recorded reverse
23
+ * operations with their step and records the step as learned.
24
+ */
20
25
  class StateTravel {
21
26
  constructor (internals, file, driver, pdriver, step = true) {
22
27
  this.file = file;
23
28
  this.internals = internals;
24
29
  this.driver = driver;
25
30
  this.pdriver = pdriver;
26
- this._counter = 0;
27
31
  this._default = async function _default () {
32
+ const op = this.internals.migrationOp;
33
+
28
34
  if (step) {
29
- await State.step(this.pdriver, ++this._counter, this.internals);
35
+ await State.step(this.pdriver, op, this.internals);
36
+ } else {
37
+ this.internals.modSchema.s.forEach(entry => {
38
+ if (entry.n === undefined) entry.n = op;
39
+ });
30
40
  }
31
41
 
32
- return State.update(
42
+ await State.update(
33
43
  this.pdriver,
34
44
  this.file,
35
45
  this.internals.modSchema,
36
46
  this.internals
37
47
  );
48
+
49
+ if (!step) {
50
+ await State.learned(this.pdriver, op, this.internals);
51
+ }
38
52
  };
39
53
  }
40
54
  }
@@ -101,7 +101,7 @@ async function processEntry (
101
101
  }
102
102
  }
103
103
 
104
- internals.modSchema.s.shift();
104
+ internals.modSchema.s.pop();
105
105
  await State.update(pdriver, file, internals.modSchema, internals);
106
106
  }
107
107
 
@@ -113,7 +113,9 @@ module.exports = async (context, file, driver, internals, pdriver) => {
113
113
 
114
114
  chain.addChain(Learn);
115
115
  chain.addChain(Migrate);
116
- await Promise.resolve(mod.s.reverse()).each(args =>
116
+ // the stored records keep their order, so an interrupted rollback can
117
+ // be continued
118
+ await Promise.resolve(mod.s.slice().reverse()).each(args =>
117
119
  processEntry(context, file, chain, internals, pdriver, args)
118
120
  );
119
121
 
package/lib/state.js CHANGED
@@ -29,6 +29,21 @@ let queue = Promise.resolve();
29
29
 
30
30
  const nonce = () => crypto.randomBytes(8).toString('hex');
31
31
 
32
+ const hashFile = async file => {
33
+ if (!file.path) {
34
+ return null;
35
+ }
36
+
37
+ try {
38
+ return crypto
39
+ .createHash('sha256')
40
+ .update(await fs.readFile(file.path))
41
+ .digest('hex');
42
+ } catch (err) {
43
+ return null;
44
+ }
45
+ };
46
+
32
47
  const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
33
48
 
34
49
  const lockOptions = internals => {
@@ -194,7 +209,9 @@ module.exports = {
194
209
  log.warn(
195
210
  `[state] taking over a stale migration lock from ${s.date}` +
196
211
  (s.fin === 0
197
- ? `, the previous run was interrupted during a migration at step ${s.step}`
212
+ ? `, the previous run was interrupted during ${
213
+ s.f ? `migration "${s.f}"` : 'a migration'
214
+ } at step ${s.step}`
198
215
  : '')
199
216
  );
200
217
  }
@@ -334,31 +351,80 @@ module.exports = {
334
351
  }
335
352
  },
336
353
 
337
- startMigration: async function (driver, file, internals) {
354
+ /**
355
+ * Start a migration in the state. With recover, an interrupted previous
356
+ * run of the same migration is not reset but returned, so the caller can
357
+ * decide how to recover:
358
+ *
359
+ * step: the last step started
360
+ * learned: the last step whose reverse operation was recorded
361
+ * done: the last step executed on the database
362
+ * rollback: the run was interrupted while rolling back
363
+ * changed: the migration file changed since
364
+ */
365
+ startMigration: async function (
366
+ driver,
367
+ file,
368
+ internals,
369
+ { op = 'up', recover = false } = {}
370
+ ) {
338
371
  const mig = await driver._getKV(internals.migrationState, file.name);
339
372
 
340
373
  if (mig && mig.value !== '{}') {
341
374
  internals.modSchema = JSON.parse(mig.value);
375
+ // records of older runs, they belong to no step of this run
376
+ (internals.modSchema.s || []).forEach(entry => {
377
+ if (entry.n === undefined) entry.n = 0;
378
+ });
342
379
  }
343
380
 
344
381
  if (internals.dryRun) {
345
- return Promise.resolve();
382
+ return null;
383
+ }
384
+
385
+ const h = await hashFile(file);
386
+ const row = await read(driver, internals);
387
+ const prev = row ? JSON.parse(row.value).s : null;
388
+
389
+ if (prev && prev.fin === 0 && prev.f) {
390
+ if (recover && prev.f === file.name && prev.o === 'up' && op === 'up') {
391
+ return {
392
+ step: prev.step || 0,
393
+ learned: prev.learned || 0,
394
+ done: prev.done || 0,
395
+ rollback: prev.rb === 1,
396
+ changed: !!prev.h && !!h && prev.h !== h
397
+ };
398
+ }
399
+
400
+ log.warn(
401
+ `[state] ignoring the interrupted ${prev.o} of migration ` +
402
+ `"${prev.f}" at step ${prev.step}`
403
+ );
346
404
  }
347
405
 
348
406
  await module.exports.progress(driver, internals, s => {
349
- s.step = 0;
350
- s.fin = 0;
407
+ Object.assign(s, {
408
+ step: 0,
409
+ fin: 0,
410
+ f: file.name,
411
+ o: op,
412
+ learned: 0,
413
+ done: 0,
414
+ rb: 0,
415
+ h
416
+ });
351
417
  });
352
418
 
353
419
  if (!mig) {
354
- return driver._insertKV(
420
+ await driver._insertKV(
355
421
  internals.migrationState,
356
422
  file.name,
357
423
  JSON.stringify({})
358
424
  );
359
- } else {
360
- return Promise.resolve();
361
425
  }
426
+
427
+ return null;
362
428
  },
363
429
 
364
430
  /**
@@ -412,6 +478,36 @@ module.exports = {
412
478
  return driver._getKV(internals.migrationState, file.name);
413
479
  },
414
480
 
481
+ learned: async function (driver, step, internals) {
482
+ if (internals.dryRun) {
483
+ return Promise.resolve();
484
+ }
485
+
486
+ return module.exports.progress(driver, internals, s => {
487
+ s.learned = step;
488
+ });
489
+ },
490
+
491
+ done: async function (driver, step, internals) {
492
+ if (internals.dryRun) {
493
+ return Promise.resolve();
494
+ }
495
+
496
+ return module.exports.progress(driver, internals, s => {
497
+ s.done = step;
498
+ });
499
+ },
500
+
501
+ rollingBack: async function (driver, internals) {
502
+ if (internals.dryRun) {
503
+ return Promise.resolve();
504
+ }
505
+
506
+ return module.exports.progress(driver, internals, s => {
507
+ s.rb = 1;
508
+ });
509
+ },
510
+
415
511
  step: async function (driver, step, internals) {
416
512
  log.verbose(`[state] proceeded to step ${step}`);
417
513
  if (internals.dryRun) {
@@ -435,6 +531,7 @@ module.exports = {
435
531
  // the lock itself stays until the walker releases it
436
532
  return module.exports.progress(driver, internals, s => {
437
533
  s.fin = 1;
534
+ s.rb = 0;
438
535
  });
439
536
  }
440
537
  };
package/package.json CHANGED
@@ -31,7 +31,7 @@
31
31
  "programable",
32
32
  "api"
33
33
  ],
34
- "version": "1.0.0-beta.36",
34
+ "version": "1.0.0-beta.37",
35
35
  "engines": {
36
36
  "node": ">=8.0.0"
37
37
  },