db-migrate 1.0.0-beta.9 → 1.0.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.
@@ -21,15 +21,15 @@ async function prepare (internals, config) {
21
21
  );
22
22
 
23
23
  if (internals.locTitle) {
24
- migrator.migrationsDir = path.resolve(
24
+ migrator.directory = path.resolve(
25
25
  internals.argv['migrations-dir'],
26
26
  internals.locTitle
27
27
  );
28
28
  } else {
29
- migrator.migrationsDir = path.resolve(internals.argv['migrations-dir']);
29
+ migrator.directory = path.resolve(internals.argv['migrations-dir']);
30
30
  }
31
31
 
32
- internals.migrationsDir = migrator.migrationsDir;
32
+ internals.migrationsDir = migrator.directory;
33
33
 
34
34
  await migrator.createMigrationsTable();
35
35
  log.verbose('migration table created');
@@ -1,54 +1,14 @@
1
1
  'use strict';
2
2
 
3
- var assert = require('assert');
4
- var path = require('path');
5
- var log = require('db-migrate-shared').log;
6
- var _assert = require('./helper/assert');
7
-
8
- function executeUndoSeed (internals, config, callback) {
9
- var index = require('./connect');
10
- var Seeder = require('./lib/walker.js');
11
-
12
- if (!internals.argv.count) {
13
- log.info('Defaulting to running 1 down seed.');
14
- internals.argv.count = 1;
15
- }
16
-
17
- if (internals.argv._.length > 0) {
18
- internals.argv.destination = internals.argv._.shift().toString();
19
- }
20
-
21
- index.connect(
22
- {
23
- config: config.getCurrent().settings,
24
- internals: internals,
25
- prefix: 'seed'
26
- },
27
- Seeder,
28
- function (err, seeder) {
29
- assert.ifError(err);
30
-
31
- var seedDir =
32
- internals.mode !== 'static' ? 'vcseeder-dir' : 'staticseeder-dir';
33
-
34
- seeder.seedDir = path.resolve(internals.argv[seedDir]);
35
-
36
- if (internals.mode === 'static') {
37
- internals.onComplete(seeder, callback, {
38
- stack: "Static seeders can't be undone. Use VC Seeders instead!"
39
- });
40
- } else {
41
- seeder.createSeedsTable(function (err) {
42
- if (_assert(err, callback)) {
43
- seeder.down(
44
- internals.argv,
45
- internals.onComplete.bind(this, seeder, internals, callback)
46
- );
47
- }
48
- });
49
- }
50
- }
3
+ /**
4
+ * Seeders are not part of db-migrate 1.0, their implementation was never
5
+ * finished. A new concept follows separately, see #687.
6
+ */
7
+ module.exports = function () {
8
+ return Promise.reject(
9
+ new Error(
10
+ 'Seeders are not supported by db-migrate 1.0. The unfinished seeders ' +
11
+ 'of db-migrate 0.11 are still available there.'
12
+ )
51
13
  );
52
- }
53
-
54
- module.exports = executeUndoSeed;
14
+ };
@@ -21,15 +21,15 @@ async function prepare (internals, config) {
21
21
  );
22
22
 
23
23
  if (internals.locTitle) {
24
- migrator.migrationsDir = path.resolve(
24
+ migrator.directory = path.resolve(
25
25
  internals.argv['migrations-dir'],
26
26
  internals.locTitle
27
27
  );
28
28
  } else {
29
- migrator.migrationsDir = path.resolve(internals.argv['migrations-dir']);
29
+ migrator.directory = path.resolve(internals.argv['migrations-dir']);
30
30
  }
31
31
 
32
- internals.migrationsDir = migrator.migrationsDir;
32
+ internals.migrationsDir = migrator.directory;
33
33
 
34
34
  await migrator.createMigrationsTable();
35
35
  log.verbose('migration table created');
@@ -16,6 +16,64 @@ var ShadowProto = {
16
16
  }
17
17
  };
18
18
 
19
+ /**
20
+ * Attach the failed statement to errors of the driver, so the error output
21
+ * can show what failed. Drivers already setting sql (mysql2) keep theirs.
22
+ */
23
+ const annotateSql = (err, sql, hasParams) => {
24
+ if (err && typeof err === 'object' && err.sql === undefined) {
25
+ try {
26
+ err.sql = sql;
27
+ // the values are not kept, they might be sensitive
28
+ Object.defineProperty(err, 'sqlParams', { value: hasParams });
29
+ } catch (e) {}
30
+ }
31
+
32
+ return err;
33
+ };
34
+
35
+ function trackSql (db) {
36
+ ['runSql', 'all'].forEach(method => {
37
+ const original = db[method];
38
+
39
+ if (typeof original !== 'function') {
40
+ return;
41
+ }
42
+
43
+ db[method] = function (sql, ...args) {
44
+ const callback =
45
+ typeof args[args.length - 1] === 'function' ? args.pop() : null;
46
+ const hasParams = args.length > 0;
47
+
48
+ if (callback) {
49
+ // the returned promise is left alone, the callback handles errors
50
+ return original.call(this, sql, ...args, function (err, ...res) {
51
+ return callback.call(
52
+ this,
53
+ err ? annotateSql(err, sql, hasParams) : err,
54
+ ...res
55
+ );
56
+ });
57
+ }
58
+
59
+ const ret = original.call(this, sql, ...args);
60
+ if (ret && typeof ret.catch === 'function') {
61
+ return ret.catch(err => {
62
+ throw annotateSql(err, sql, hasParams);
63
+ });
64
+ }
65
+
66
+ return ret;
67
+ };
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Established tunnels by their config. db-migrate opens more than one
73
+ * connection, which all share the tunnel listening on the local port.
74
+ */
75
+ const tunnels = {};
76
+
19
77
  exports.connect = function (config, intern, callback) {
20
78
  var driver, req;
21
79
  let plugin = false;
@@ -37,7 +95,7 @@ exports.connect = function (config, intern, callback) {
37
95
 
38
96
  if (config.driver === undefined) {
39
97
  throw new Error(
40
- 'config must include a driver key specifing which driver to use'
98
+ 'config must include a driver key specifying which driver to use'
41
99
  );
42
100
  }
43
101
 
@@ -92,64 +150,75 @@ exports.connect = function (config, intern, callback) {
92
150
  log.verbose('connecting');
93
151
 
94
152
  var connect = function (config) {
95
- driver.connect(config, intern, function (err, db) {
96
- if (err) {
97
- callback(err);
98
- return;
99
- }
100
- log.verbose('connected');
153
+ driver.connect(
154
+ // safe disconnect of our config object into userspace
155
+ JSON.parse(JSON.stringify(config)),
156
+ intern,
157
+ function (err, db) {
158
+ if (err) {
159
+ callback(err);
160
+ return;
161
+ }
162
+ log.verbose('connected');
101
163
 
102
- if (!global.immunity) {
103
- db = Shadow.infect(db, internals, ShadowProto);
104
- }
164
+ if (!global.immunity) {
165
+ db = Shadow.infect(db, internals, ShadowProto);
166
+ }
105
167
 
106
- callback(null, db);
107
- });
168
+ trackSql(db);
169
+ callback(null, db);
170
+ }
171
+ );
108
172
  };
109
173
 
110
174
  if (config.tunnel) {
111
- var tunnel = require('tunnel-ssh');
175
+ // tunnels are provided by plugins, e.g. db-migrate-plugin-tunnel-ssh
112
176
  var tunnelConfig = JSON.parse(JSON.stringify(config.tunnel));
113
177
  const { tunnelType } = tunnelConfig;
178
+ const type = tunnelType && tunnelType !== 'ssh' ? tunnelType : 'ssh';
179
+ const hook = `connection:tunnel:${type}`;
114
180
 
115
181
  if (plugins) {
116
- plugin = plugins.overwrite(
117
- `connection:tunnel:${
118
- tunnelType && tunnelType !== 'ssh' ? tunnelType : 'ssh'
119
- }`
182
+ plugin = plugins.overwrite(hook);
183
+ }
184
+
185
+ if (!plugin) {
186
+ return callback(
187
+ new Error(
188
+ `A ${type} tunnel is configured, but no plugin provides it. ` +
189
+ `Install db-migrate-plugin-tunnel-${type}, e.g. ` +
190
+ `npm install db-migrate-plugin-tunnel-${type}`
191
+ )
120
192
  );
121
193
  }
122
194
 
123
195
  tunnelConfig.dstHost = config.host;
124
196
  tunnelConfig.dstPort = config.port;
125
197
 
126
- if (plugin) {
127
- tunnel = plugin[
128
- `connection:tunnel:${
129
- tunnelType && tunnelType !== 'ssh' ? tunnelType : 'ssh'
130
- }`
131
- ](tunnelConfig);
132
- }
198
+ // Point the db host/port to our local tunnel, on a copy as the config
199
+ // is used again for further connections
200
+ config = Object.assign({}, config, {
201
+ host: '127.0.0.1',
202
+ port: tunnelConfig.localPort
203
+ });
133
204
 
134
- if (tunnelConfig.privateKeyPath) {
135
- tunnelConfig.privateKey = require('fs').readFileSync(
136
- tunnelConfig.privateKeyPath
137
- );
205
+ const key = JSON.stringify([type, tunnelConfig]);
206
+ if (!tunnels[key]) {
207
+ tunnels[key] = Promise.resolve().then(() => plugin[hook](tunnelConfig));
208
+ // a failed tunnel may be retried
209
+ tunnels[key].catch(() => delete tunnels[key]);
138
210
  }
139
211
 
140
- // Reassign the db host/port to point to our local ssh tunnel
141
- config.host = '127.0.0.1';
142
- config.port = tunnelConfig.localPort;
143
-
144
- tunnel(tunnelConfig, function (err) {
145
- if (err) {
146
- callback(err);
147
- return;
148
- }
149
- log.verbose('SSH tunnel connected on port ', tunnelConfig.localPort);
150
-
151
- connect(config);
152
- });
212
+ tunnels[key]
213
+ .then(
214
+ function () {
215
+ log.verbose(`${type} tunnel connected on port`, tunnelConfig.localPort);
216
+ connect(config);
217
+ },
218
+ function (err) {
219
+ callback(err);
220
+ }
221
+ );
153
222
  } else {
154
223
  connect(config);
155
224
  }
@@ -5,10 +5,108 @@ const State = require('../../state');
5
5
  const log = require('db-migrate-shared').log;
6
6
  const Learn = require('../../learn');
7
7
  const Chain = require('../../chain');
8
- const StateTravel = require('../../methods/v2/statetravel');
8
+ const StateTravel = require('../../methods/v2/statetravel')();
9
+ const StateNoStepTravel = require('../../methods/v2/statetravel')(false);
9
10
  const Migrate = require('../../methods/v2/migrate');
11
+ const StateDone = require('../../methods/v2/statedone');
10
12
  const TranslateState = require('../../methods/v2/translatestate');
11
13
  const AddConventions = require('../../methods/v2/conventions');
14
+ const util = require('util');
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
+ /**
80
+ * Name the instruction a migration failed at, or after if the instruction
81
+ * itself finished and the migration code failed afterwards.
82
+ */
83
+ const describeFailure = (err, chain, internals) => {
84
+ if (!err || typeof err !== 'object' || err.instruction || chain.op === 0) {
85
+ return;
86
+ }
87
+
88
+ const after = internals.migrationDone >= chain.op;
89
+ err.instruction = `${after ? 'after' : 'at'} step ${chain.op} ${
90
+ chain.current
91
+ }`;
92
+ };
93
+
94
+ const recoverByRollback = async (context, driver, file, interrupted) => {
95
+ const internals = context.internals;
96
+ const pdriver = context._pdriver;
97
+
98
+ await State.rollingBack(pdriver, internals);
99
+ internals.modSchema.s = internals.modSchema.s.filter(
100
+ entry => !(entry.n > interrupted.done)
101
+ );
102
+ await TranslateState(context._driver, file, driver, internals, pdriver);
103
+
104
+ internals.unlearn = false;
105
+ internals.rollback = false;
106
+ internals.rollbackContinue = false;
107
+ internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
108
+ await State.endMigration(pdriver, file, internals);
109
+ };
12
110
 
13
111
  const execUnit = {
14
112
  _extend: (context, type) => {
@@ -28,15 +126,87 @@ const execUnit = {
28
126
 
29
127
  up: async function (context, driver, file) {
30
128
  const _file = file.get();
31
- const chain = new Chain(context._driver, file, driver, context.internals);
129
+ const chain = new Chain(context._driver, file, driver, context.internals, context._pdriver);
32
130
  if (!_file._meta.noDefaultColumn) {
33
131
  chain.addChain(AddConventions);
34
132
  }
35
- chain.addChain(Learn);
36
133
  chain.addChain(StateTravel);
134
+ chain.addChain(Learn);
135
+ chain.addChain(StateNoStepTravel);
37
136
  chain.addChain(Migrate);
137
+ chain.addChain(StateDone);
138
+
139
+ const interrupted = await State.startMigration(
140
+ context._pdriver,
141
+ file,
142
+ context.internals,
143
+ { recover: true }
144
+ );
145
+
146
+ if (interrupted) {
147
+ const mode = recoveryMode(file, interrupted);
148
+ log.warn(
149
+ `[recovery] ${file.name}: the previous run was interrupted at step ` +
150
+ `${interrupted.step}, ${interrupted.done} steps were executed, ` +
151
+ `recovering by ${mode}`
152
+ );
153
+
154
+ if (mode === 'rollback') {
155
+ await recoverByRollback(context, driver, file, interrupted);
156
+ await State.startMigration(context._pdriver, file, context.internals);
157
+ } else {
158
+ chain.recovery = interrupted;
159
+ }
160
+ }
161
+
162
+ context.internals.migrationDone = chain.recovery ? chain.recovery.done : 0;
38
163
 
39
- await State.startMigration(context._driver, file, context.internals);
164
+ // startMigration - needs secondary instance since we can not afford to
165
+ // loose state and the transaction start will include these for roll back
166
+ // we will disable them probably at all from DDL when the driver does not
167
+ // explicitly signal DDL transaction support (like crdb)
168
+ try {
169
+ await _file.migrate(chain, {
170
+ options: context.internals.safeOptions,
171
+ seedLink: context.seedLink,
172
+ dbm: context.internals.safeOptions.dbmigrate
173
+ });
174
+ } catch (err) {
175
+ // transfer last state
176
+ chain.transferInt();
177
+ executedSteps(
178
+ context.internals,
179
+ chain.op,
180
+ chain.udriver._counter.previousSignal() === true
181
+ );
182
+ describeFailure(err, chain, context.internals);
183
+
184
+ log.error(
185
+ `Migration "${file.name}" failed, rolling back: ${
186
+ (err && err.message) || err
187
+ }`
188
+ );
189
+ await State.rollingBack(context._pdriver, context.internals);
190
+ await execUnit.down(context, driver, file, { abort: true });
191
+ throw err;
192
+ }
193
+ await Promise.promisify(context.writeMigrationRecord.bind(context))(file);
194
+ return State.endMigration(context._pdriver, file, context.internals);
195
+ // end migration, same as start migration
196
+ },
197
+
198
+ fix: async function (context, driver, file) {
199
+ const _file = file.get();
200
+ const chain = new Chain(context._driver, file, driver, context.internals, context._pdriver);
201
+ if (!_file._meta.noDefaultColumn) {
202
+ chain.addChain(AddConventions);
203
+ }
204
+ chain.addChain(Learn);
205
+ chain.addChain(StateTravel);
206
+
207
+ await State.startMigration(context._pdriver, file, context.internals, {
208
+ op: 'fix'
209
+ });
40
210
  // startMigration - needs secondary instance since we can not afford to
41
211
  // loose state and the transaction start will include these for roll back
42
212
  // we will disable them probably at all from DDL when the driver does not
@@ -53,23 +223,31 @@ const execUnit = {
53
223
  // transfer last state
54
224
  chain.transferInt();
55
225
 
226
+ describeFailure(err, chain, context.internals);
56
227
  log.error(
57
- 'An error occured. No alternative failure strategy defined. Rolling back!',
58
- err
228
+ `Migration "${file.name}" failed, rolling back: ${
229
+ (err && err.message) || err
230
+ }`
59
231
  );
60
232
  await execUnit.down(context, driver, file);
61
233
  throw err;
62
234
  }
63
- await Promise.promisify(context.writeMigrationRecord.bind(context))(file);
64
- return State.endMigration(context._driver, file, context.internals);
235
+ await State.endMigration(context._pdriver, file, context.internals);
236
+ log.verbose(`[fix] current schema`, util.inspect(context.internals.schema, false, null, true));
65
237
  // end migration, same as start migration
66
238
  },
67
239
 
68
- down: async function (context, driver, file) {
240
+ down: async function (context, driver, file, { abort } = {}) {
241
+ // if we get the abort signal this means we are in a rollback routine
242
+ // which means in turn we want to avoid reloading the state
243
+ if (abort !== true) {
244
+ await State.startMigration(context._pdriver, file, context.internals, {
245
+ op: 'down'
246
+ });
247
+ }
69
248
  // start migration, see up comments
70
- await State.startMigration(context._driver, file, context.internals);
71
- await TranslateState(context._driver, file, driver, context.internals);
72
- await State.endMigration(context._driver, file, context.internals);
249
+ await TranslateState(context._driver, file, driver, context.internals, context._pdriver);
250
+ await State.endMigration(context._pdriver, file, context.internals);
73
251
  return Promise.promisify(context.deleteMigrationRecord.bind(context))(file);
74
252
  // end migration, see up comments
75
253
  }
package/lib/file.js CHANGED
@@ -56,6 +56,12 @@ function parseTitle (name) {
56
56
 
57
57
  const filesRegEx = /\.js$/;
58
58
 
59
+ /**
60
+ * Loaders of plugins for their file extensions, files without one are
61
+ * required.
62
+ */
63
+ let loaders = {};
64
+
59
65
  const File = function () {
60
66
  if (arguments.length >= 3) {
61
67
  this.title = arguments[0];
@@ -81,7 +87,27 @@ File.prototype = {
81
87
  },
82
88
 
83
89
  get: function () {
84
- return this._required || (this._required = require(this.path));
90
+ if (!this._required) {
91
+ let file = this.path;
92
+ let loader = loaders[path.extname(file).slice(1)];
93
+
94
+ // migrations recorded in the database carry no extension, require
95
+ // resolves its own ones, files of plugins have to be looked up
96
+ if (!loader) {
97
+ const extension = Object.keys(loaders).find(ext =>
98
+ fs.existsSync(`${file}.${ext}`)
99
+ );
100
+
101
+ if (extension) {
102
+ file = `${file}.${extension}`;
103
+ loader = loaders[extension];
104
+ }
105
+ }
106
+
107
+ this._required = loader ? loader(file) : require(file);
108
+ }
109
+
110
+ return this._required;
85
111
  },
86
112
 
87
113
  write: function (data) {
@@ -104,6 +130,7 @@ File.registerHook = function (Plugin, internals) {
104
130
  filesRegEx: filesRegEx,
105
131
  extensions: 'js'
106
132
  };
133
+ loaders = {};
107
134
 
108
135
  if (!plugin) {
109
136
  return Promise.resolve(null);
@@ -137,6 +164,14 @@ File.registerHook = function (Plugin, internals) {
137
164
  if (parser && parser.extensions) {
138
165
  internals.parser.extensions =
139
166
  internals.parser.extensions + '|' + parser.extensions;
167
+
168
+ // a parser may load its files itself, returning the migration
169
+ // module, instead of registering them for require
170
+ if (typeof parser.load === 'function') {
171
+ parser.extensions.split('|').forEach(extension => {
172
+ loaders[extension] = parser.load;
173
+ });
174
+ }
140
175
  }
141
176
  })
142
177
  .then(function () {