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

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,91 @@ 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
+ /**
65
+ * Keep the reverse operations of the steps executed on the database. The
66
+ * failed step only changed the database, if its main statement went through
67
+ * before failing, signaled by the driver (e.g. a foreign key of createTable
68
+ * failed after the table was created).
69
+ */
70
+ const executedSteps = (internals, failed, signaled) => {
71
+ const done = internals.migrationDone;
72
+
73
+ internals.modSchema.s = internals.modSchema.s.filter(
74
+ entry =>
75
+ entry.n <= done || (signaled && failed > done && entry.n === failed)
76
+ );
77
+ };
78
+
79
+ const recoverByRollback = async (context, driver, file, interrupted) => {
80
+ const internals = context.internals;
81
+ const pdriver = context._pdriver;
82
+
83
+ await State.rollingBack(pdriver, internals);
84
+ internals.modSchema.s = internals.modSchema.s.filter(
85
+ entry => !(entry.n > interrupted.done)
86
+ );
87
+ await TranslateState(context._driver, file, driver, internals, pdriver);
88
+
89
+ internals.unlearn = false;
90
+ internals.rollback = false;
91
+ internals.rollbackContinue = false;
92
+ internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
93
+ await State.endMigration(pdriver, file, internals);
94
+ };
95
+
15
96
  const execUnit = {
16
97
  _extend: (context, type) => {
17
98
  return {
@@ -38,8 +119,33 @@ const execUnit = {
38
119
  chain.addChain(Learn);
39
120
  chain.addChain(StateNoStepTravel);
40
121
  chain.addChain(Migrate);
122
+ chain.addChain(StateDone);
123
+
124
+ const interrupted = await State.startMigration(
125
+ context._pdriver,
126
+ file,
127
+ context.internals,
128
+ { recover: true }
129
+ );
130
+
131
+ if (interrupted) {
132
+ const mode = recoveryMode(file, interrupted);
133
+ log.warn(
134
+ `[recovery] ${file.name}: the previous run was interrupted at step ` +
135
+ `${interrupted.step}, ${interrupted.done} steps were executed, ` +
136
+ `recovering by ${mode}`
137
+ );
138
+
139
+ if (mode === 'rollback') {
140
+ await recoverByRollback(context, driver, file, interrupted);
141
+ await State.startMigration(context._pdriver, file, context.internals);
142
+ } else {
143
+ chain.recovery = interrupted;
144
+ }
145
+ }
146
+
147
+ context.internals.migrationDone = chain.recovery ? chain.recovery.done : 0;
41
148
 
42
- await State.startMigration(context._pdriver, file, context.internals);
43
149
  // startMigration - needs secondary instance since we can not afford to
44
150
  // loose state and the transaction start will include these for roll back
45
151
  // we will disable them probably at all from DDL when the driver does not
@@ -51,15 +157,19 @@ const execUnit = {
51
157
  dbm: context.internals.safeOptions.dbmigrate
52
158
  });
53
159
  } catch (err) {
54
- context.internals.rollback = true;
55
-
56
160
  // transfer last state
57
161
  chain.transferInt();
162
+ executedSteps(
163
+ context.internals,
164
+ chain.op,
165
+ chain.udriver._counter.previousSignal() === true
166
+ );
58
167
 
59
168
  log.error(
60
169
  'An error occured. No alternative failure strategy defined. Rolling back!',
61
170
  err
62
171
  );
172
+ await State.rollingBack(context._pdriver, context.internals);
63
173
  await execUnit.down(context, driver, file, { abort: true });
64
174
  throw err;
65
175
  }
@@ -77,7 +187,9 @@ const execUnit = {
77
187
  chain.addChain(Learn);
78
188
  chain.addChain(StateTravel);
79
189
 
80
- await State.startMigration(context._pdriver, file, context.internals);
190
+ await State.startMigration(context._pdriver, file, context.internals, {
191
+ op: 'fix'
192
+ });
81
193
  // startMigration - needs secondary instance since we can not afford to
82
194
  // loose state and the transaction start will include these for roll back
83
195
  // we will disable them probably at all from DDL when the driver does not
@@ -110,7 +222,9 @@ const execUnit = {
110
222
  // if we get the abort signal this means we are in a rollback routine
111
223
  // which means in turn we want to avoid reloading the state
112
224
  if (abort !== true) {
113
- await State.startMigration(context._pdriver, file, context.internals);
225
+ await State.startMigration(context._pdriver, file, context.internals, {
226
+ op: 'down'
227
+ });
114
228
  }
115
229
  // start migration, see up comments
116
230
  await TranslateState(context._driver, file, driver, context.internals, context._pdriver);
package/lib/learn.js CHANGED
@@ -175,7 +175,8 @@ class STD {
175
175
  }
176
176
  }
177
177
 
178
- this.modS[t] = {};
178
+ // keep the columns removed before from the same table
179
+ if (!this.modS[t]) this.modS[t] = {};
179
180
 
180
181
  if (columns[c].notNull === true && !this.unlearn) {
181
182
  switch (o.columnStrategy) {
@@ -260,7 +261,7 @@ class STD {
260
261
 
261
262
  addIndex (t, i, c, u) {
262
263
  if (Array.isArray(c)) {
263
- c.forEach(x => this.checkColumn(t, typeof x === 'object' ? x.name : x));
264
+ c.forEach(x => this.checkColumn(t, typeof x === 'object' ? x.name : x));
264
265
  } else {
265
266
  this.checkColumn(t, c);
266
267
  }
@@ -300,7 +301,7 @@ class STD {
300
301
 
301
302
  alter.i[t] = {};
302
303
  alter.i[t][i] = this.indizies[t][i];
303
- this.modI[t] = {};
304
+ if (!this.modI[t]) this.modI[t] = {};
304
305
  this.modI[t][i] = this.indizies[t][i];
305
306
  delete this.indizies[t][i];
306
307
 
@@ -345,10 +346,12 @@ class STD {
345
346
 
346
347
  alter.f[t] = {};
347
348
  alter.f[t][k] = this.foreign[t][k];
348
- this.modF[t] = {};
349
+ if (!this.modF[t]) this.modF[t] = {};
349
350
  this.modF[t][k] = this.foreign[t][k];
350
351
  delete this.foreign[t][k];
351
352
 
353
+ this.modC.push({ t: 1, a: 'addForeignKey', c: [t, k] });
354
+
352
355
  return Promise.resolve(alter);
353
356
  }
354
357
 
@@ -371,6 +374,9 @@ const noLearnError = prop => {
371
374
  };
372
375
 
373
376
  module.exports = {
377
+ // learns the schema and records the reverse operations
378
+ learns: true,
379
+
374
380
  getInterface: (context, file, driver, internals) => {
375
381
  if (context.learnable) {
376
382
  const _std = new STD(internals, context);
@@ -0,0 +1,18 @@
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
+ // in memory first, a failing write must not hide the executed step
12
+ internals.migrationDone = internals.migrationOp;
13
+ return State.done(pdriver, internals.migrationOp, internals);
14
+ };
15
+
16
+ return Shadow.overshadow(driver, {}, done);
17
+ }
18
+ };
@@ -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
  }
@@ -46,6 +46,11 @@ const methods = {
46
46
  return driver.changeColumn(t, c, internals.modSchema.c[t][c]);
47
47
  },
48
48
 
49
+ addForeignKey: async (driver, [t, k], internals) => {
50
+ const foreign = internals.modSchema.f[t][k];
51
+ return driver.addForeignKey(t, foreign.rt, k, foreign.m, foreign.r);
52
+ },
53
+
49
54
  addIndex: async (driver, [t, i], internals) => {
50
55
  if (!internals.modSchema.i[t][i]) {
51
56
  log.warn(
@@ -101,7 +106,7 @@ async function processEntry (
101
106
  }
102
107
  }
103
108
 
104
- internals.modSchema.s.shift();
109
+ internals.modSchema.s.pop();
105
110
  await State.update(pdriver, file, internals.modSchema, internals);
106
111
  }
107
112
 
@@ -113,7 +118,9 @@ module.exports = async (context, file, driver, internals, pdriver) => {
113
118
 
114
119
  chain.addChain(Learn);
115
120
  chain.addChain(Migrate);
116
- await Promise.resolve(mod.s.reverse()).each(args =>
121
+ // the stored records keep their order, so an interrupted rollback can
122
+ // be continued
123
+ await Promise.resolve(mod.s.slice().reverse()).each(args =>
117
124
  processEntry(context, file, chain, internals, pdriver, args)
118
125
  );
119
126
 
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.38",
35
35
  "engines": {
36
36
  "node": ">=8.0.0"
37
37
  },