db-migrate 1.2.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/README.md +9 -8
- package/api.js +27 -46
- package/lib/commands/generated.js +3 -1
- package/lib/commands/helper/seeder.js +29 -0
- package/lib/commands/run.js +11 -13
- package/lib/commands/seed.js +5 -9
- package/lib/commands/set-default-argv.js +4 -1
- package/lib/commands/undo-seed.js +5 -9
- package/lib/commands/work.js +33 -0
- package/lib/dml.js +1115 -0
- package/lib/driver/index.js +2 -0
- package/lib/executors/versioned/v2.js +18 -78
- package/lib/jobs.js +177 -0
- package/lib/recovery.js +83 -0
- package/lib/seed.js +199 -0
- package/lib/walker.js +51 -3
- package/lib/work.js +260 -0
- package/package.json +4 -4
package/lib/dml.js
ADDED
|
@@ -0,0 +1,1115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const Promise = require('bluebird');
|
|
5
|
+
const log = require('db-migrate-shared').log;
|
|
6
|
+
const State = require('./state');
|
|
7
|
+
const { recoveryMode, describeFailure } = require('./recovery');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* DML migrations, _meta: { version: 2, type: 'dml' }, change data instead of
|
|
11
|
+
* the schema. Every instruction is a step, recorded with what reverts it:
|
|
12
|
+
*
|
|
13
|
+
* insert: the rows get the step in their __dbmigrate__flag column, reverting
|
|
14
|
+
* deletes the rows flagged with it
|
|
15
|
+
* update, delete: the rows are copied to a backup table in the same database
|
|
16
|
+
* first, reverting restores them from there. The rows are then
|
|
17
|
+
* changed in batches by their key, the progress is kept in the
|
|
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.
|
|
22
|
+
* runSql: reverted by the SQL given with { revert }
|
|
23
|
+
*
|
|
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.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const FLAG = '__dbmigrate__flag';
|
|
32
|
+
const BATCH = 1000;
|
|
33
|
+
const TYPES = ['dml'];
|
|
34
|
+
const PG = ['pg', 'cockroachdb', 'postgres', 'postgresql'];
|
|
35
|
+
|
|
36
|
+
const SCHEMA_INSTRUCTIONS = [
|
|
37
|
+
'createTable',
|
|
38
|
+
'dropTable',
|
|
39
|
+
'renameTable',
|
|
40
|
+
'addColumn',
|
|
41
|
+
'removeColumn',
|
|
42
|
+
'renameColumn',
|
|
43
|
+
'changeColumn',
|
|
44
|
+
'addIndex',
|
|
45
|
+
'removeIndex',
|
|
46
|
+
'addForeignKey',
|
|
47
|
+
'removeForeignKey',
|
|
48
|
+
'createCollection',
|
|
49
|
+
'dropCollection',
|
|
50
|
+
'renameCollection'
|
|
51
|
+
];
|
|
52
|
+
|
|
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;
|
|
58
|
+
|
|
59
|
+
const checkType = file => {
|
|
60
|
+
const { type, background } = meta(file);
|
|
61
|
+
|
|
62
|
+
if (type !== undefined && TYPES.indexOf(type) === -1) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Invalid migration type "${type}" in migration "${file.name}", use ` +
|
|
65
|
+
TYPES.join(', ') +
|
|
66
|
+
' or leave it out for a schema migration'
|
|
67
|
+
);
|
|
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
|
+
}
|
|
76
|
+
};
|
|
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
|
+
|
|
90
|
+
const backupName = (file, op) =>
|
|
91
|
+
'__dbm_backup_' +
|
|
92
|
+
crypto
|
|
93
|
+
.createHash('sha256')
|
|
94
|
+
.update(`${file.name}#${op}`)
|
|
95
|
+
.digest('hex')
|
|
96
|
+
.slice(0, 16);
|
|
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
|
+
|
|
104
|
+
const isObject = x =>
|
|
105
|
+
x !== null &&
|
|
106
|
+
typeof x === 'object' &&
|
|
107
|
+
!Array.isArray(x) &&
|
|
108
|
+
!(x instanceof Date) &&
|
|
109
|
+
!Buffer.isBuffer(x);
|
|
110
|
+
|
|
111
|
+
// like db-migrate-base, objects and arrays are stored as JSON
|
|
112
|
+
const toValue = value => {
|
|
113
|
+
if (
|
|
114
|
+
value !== null &&
|
|
115
|
+
typeof value === 'object' &&
|
|
116
|
+
!(value instanceof Date) &&
|
|
117
|
+
!Buffer.isBuffer(value)
|
|
118
|
+
) {
|
|
119
|
+
return JSON.stringify(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return value === undefined ? null : value;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The rows of an insert as objects, from any form db-migrate-base takes,
|
|
127
|
+
* and the options following them.
|
|
128
|
+
*/
|
|
129
|
+
const insertRows = args => {
|
|
130
|
+
if (Array.isArray(args[0]) && Array.isArray(args[1])) {
|
|
131
|
+
const [columns, values, options = {}] = args;
|
|
132
|
+
const rows =
|
|
133
|
+
!values.length || Array.isArray(values[0]) ? values : [values];
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
options,
|
|
137
|
+
rows: rows.map(row => {
|
|
138
|
+
if (!Array.isArray(row) || row.length !== columns.length) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
'The number of columns does not match the number of values.'
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const object = {};
|
|
145
|
+
columns.forEach((column, i) => {
|
|
146
|
+
object[column] = row[i];
|
|
147
|
+
});
|
|
148
|
+
return object;
|
|
149
|
+
})
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const [data, options = {}] = args;
|
|
154
|
+
|
|
155
|
+
if (isObject(data) && Array.isArray(data.columns)) {
|
|
156
|
+
const values = [];
|
|
157
|
+
for (let i = 0; i < data.data.length; i += data.columns.length) {
|
|
158
|
+
values.push(data.data.slice(i, i + data.columns.length));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return insertRows([data.columns, values, options]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (isObject(data)) {
|
|
165
|
+
return { rows: [data], options };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (Array.isArray(data) && data.every(isObject)) {
|
|
169
|
+
return { rows: data, options };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
throw new Error('insert needs the rows to insert');
|
|
173
|
+
};
|
|
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
|
+
|
|
185
|
+
class Dml {
|
|
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 = {}) {
|
|
192
|
+
this.driver = context._driver;
|
|
193
|
+
this.file = file;
|
|
194
|
+
this.internals = internals;
|
|
195
|
+
this.pdriver = pdriver;
|
|
196
|
+
this.recovery = recovery;
|
|
197
|
+
this.progress = progress || foreground(pdriver, internals);
|
|
198
|
+
this.work = work;
|
|
199
|
+
this.transactional = meta(file).transactions !== false;
|
|
200
|
+
this.op = 0;
|
|
201
|
+
this.current = null;
|
|
202
|
+
|
|
203
|
+
SCHEMA_INSTRUCTIONS.forEach(method => {
|
|
204
|
+
this[method] = () => {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`${method} is a schema instruction, which dml migrations do not ` +
|
|
207
|
+
'allow. Change the schema in a v2 migration of its own.'
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
ddl (name) {
|
|
214
|
+
return this.driver.escapeDDL(name);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
columns (table) {
|
|
218
|
+
const t = this.internals.schema.c[table];
|
|
219
|
+
return t ? t.columns || t : null;
|
|
220
|
+
}
|
|
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
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The key of a table, to change and restore its rows by. Taken from the
|
|
296
|
+
* schema, for other tables it is passed with { key }.
|
|
297
|
+
*/
|
|
298
|
+
key (table, options, action) {
|
|
299
|
+
if (options.key) {
|
|
300
|
+
return [].concat(options.key);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const columns = this.columns(table) || {};
|
|
304
|
+
const key = Object.keys(columns).filter(c => columns[c].primaryKey);
|
|
305
|
+
|
|
306
|
+
if (!key.length) {
|
|
307
|
+
throw new Error(
|
|
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.')
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return key;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
where (where) {
|
|
321
|
+
if (typeof where === 'string') {
|
|
322
|
+
return { sql: where, params: [] };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (Array.isArray(where) && typeof where[0] === 'string') {
|
|
326
|
+
return { sql: where[0], params: where[1] || [] };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (isObject(where)) {
|
|
330
|
+
const params = [];
|
|
331
|
+
const parts = Object.keys(where).map(column => {
|
|
332
|
+
const value = where[column];
|
|
333
|
+
|
|
334
|
+
if (value === null || value === undefined) {
|
|
335
|
+
return `${this.ddl(column)} IS NULL`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (Array.isArray(value)) {
|
|
339
|
+
if (!value.length) {
|
|
340
|
+
return '1 = 0';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
params.push(...value.map(toValue));
|
|
344
|
+
return `${this.ddl(column)} IN (${value.map(() => '?').join(', ')})`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
params.push(toValue(value));
|
|
348
|
+
return `${this.ddl(column)} = ?`;
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return { sql: parts.length ? parts.join(' AND ') : '1 = 1', params };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
throw new Error(
|
|
355
|
+
"where is an object like { status: 'old' }, a SQL string or " +
|
|
356
|
+
'[sql, params]'
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// matches the rows by their key
|
|
361
|
+
byKey (rows, key) {
|
|
362
|
+
const params = [];
|
|
363
|
+
|
|
364
|
+
if (key.length === 1) {
|
|
365
|
+
rows.forEach(row => params.push(row[key[0]]));
|
|
366
|
+
return {
|
|
367
|
+
sql: `${this.ddl(key[0])} IN (${rows.map(() => '?').join(', ')})`,
|
|
368
|
+
params
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const sql = rows
|
|
373
|
+
.map(row => {
|
|
374
|
+
key.forEach(k => params.push(row[k]));
|
|
375
|
+
return '(' + key.map(k => `${this.ddl(k)} = ?`).join(' AND ') + ')';
|
|
376
|
+
})
|
|
377
|
+
.join(' OR ');
|
|
378
|
+
|
|
379
|
+
return { sql, params };
|
|
380
|
+
}
|
|
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
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Runs one instruction as a step: records what reverts it, executes it and
|
|
409
|
+
* records it as done. The step of an interrupted run is continued, with
|
|
410
|
+
* the record it left.
|
|
411
|
+
*/
|
|
412
|
+
async step (action, args, record, execute) {
|
|
413
|
+
const op = ++this.op;
|
|
414
|
+
const internals = this.internals;
|
|
415
|
+
const mod = internals.modSchema;
|
|
416
|
+
const recovery = this.recovery;
|
|
417
|
+
this.current = `${action}(${args.map(a => JSON.stringify(a)).join(', ')})`;
|
|
418
|
+
internals.migrationOp = op;
|
|
419
|
+
|
|
420
|
+
if (recovery && op <= recovery.done) {
|
|
421
|
+
log.info(
|
|
422
|
+
`[recovery] ${this.file.name}: skipping already executed step ` +
|
|
423
|
+
`${op}/${recovery.done} ${this.current}`
|
|
424
|
+
);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
await this.progress.step(op);
|
|
429
|
+
|
|
430
|
+
let entry = recovery && op <= recovery.learned
|
|
431
|
+
? mod.s.find(e => e.n === op)
|
|
432
|
+
: null;
|
|
433
|
+
|
|
434
|
+
if (entry) {
|
|
435
|
+
log.info(
|
|
436
|
+
`[recovery] ${this.file.name}: continuing interrupted step ${op} ` +
|
|
437
|
+
this.current
|
|
438
|
+
);
|
|
439
|
+
} else {
|
|
440
|
+
mod.s = mod.s.filter(e => e.n !== op);
|
|
441
|
+
entry = record(op);
|
|
442
|
+
entry.n = op;
|
|
443
|
+
mod.s.push(entry);
|
|
444
|
+
await this.save();
|
|
445
|
+
await this.progress.learned(op);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
await execute(entry, op);
|
|
449
|
+
|
|
450
|
+
internals.migrationDone = op;
|
|
451
|
+
await this.progress.done(op);
|
|
452
|
+
await this.between();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The records of the migration, its schema stays as it is.
|
|
457
|
+
*/
|
|
458
|
+
save () {
|
|
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)
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* insert(table, rows, [options]), rows in any form of the SQL API insert.
|
|
472
|
+
*/
|
|
473
|
+
insert (table, ...args) {
|
|
474
|
+
const { rows, options } = insertRows(args);
|
|
475
|
+
const irreversible = options.irreversible === true;
|
|
476
|
+
|
|
477
|
+
if (!irreversible) {
|
|
478
|
+
const columns = this.columns(table);
|
|
479
|
+
|
|
480
|
+
if (!columns || !columns[FLAG]) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
`insert("${table}") can only be reverted for tables created by v2 ` +
|
|
483
|
+
`migrations, by their ${FLAG} column` +
|
|
484
|
+
(columns ? `, which "${table}" does not have` : '') +
|
|
485
|
+
'. Pass { irreversible: true } to insert the rows without being ' +
|
|
486
|
+
'able to revert it.'
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (typeof this.driver._insertGroups !== 'function') {
|
|
491
|
+
throw new Error(
|
|
492
|
+
'dml migrations need db-migrate-base 2.5.0 or newer, update your ' +
|
|
493
|
+
'driver'
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return this.step(
|
|
499
|
+
'insert',
|
|
500
|
+
[table],
|
|
501
|
+
op =>
|
|
502
|
+
irreversible
|
|
503
|
+
? { t: 3, a: 'insert', c: [table] }
|
|
504
|
+
: { t: 4, a: 'insert', c: [table, `${this.file.name}#${op}`] },
|
|
505
|
+
entry =>
|
|
506
|
+
this.tx(async () => {
|
|
507
|
+
if (!rows.length) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (irreversible) {
|
|
512
|
+
return this.driver.insert(table, rows);
|
|
513
|
+
}
|
|
514
|
+
|
|
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
|
+
})
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* update(table, set, where, [options]), set the values of the columns in
|
|
531
|
+
* set for the rows matching where.
|
|
532
|
+
*/
|
|
533
|
+
update (table, set, where, options = {}) {
|
|
534
|
+
if (!isObject(set) || !Object.keys(set).length) {
|
|
535
|
+
throw new Error(`update("${table}") needs the values to set`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const columns = Object.keys(set);
|
|
539
|
+
const condition = this.where(where);
|
|
540
|
+
const irreversible = options.irreversible === true;
|
|
541
|
+
const key = irreversible ? null : this.key(table, options, 'update');
|
|
542
|
+
const assign = columns.map(c => `${this.ddl(c)} = ?`).join(', ');
|
|
543
|
+
const values = columns.map(c => toValue(set[c]));
|
|
544
|
+
|
|
545
|
+
if (key) {
|
|
546
|
+
const changed = key.filter(k => columns.indexOf(k) !== -1);
|
|
547
|
+
if (changed.length) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`update("${table}") can not change the key ${changed.join(', ')}, ` +
|
|
550
|
+
'by which it reverts the rows'
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return this.step(
|
|
556
|
+
'update',
|
|
557
|
+
[table, set],
|
|
558
|
+
op =>
|
|
559
|
+
irreversible
|
|
560
|
+
? { t: 3, a: 'update', c: [table] }
|
|
561
|
+
: {
|
|
562
|
+
t: 4,
|
|
563
|
+
a: 'update',
|
|
564
|
+
c: [table, backupName(this.file, op), key, columns]
|
|
565
|
+
},
|
|
566
|
+
async entry => {
|
|
567
|
+
if (irreversible || this.internals.dryRun) {
|
|
568
|
+
return this.tx(() =>
|
|
569
|
+
this.driver.runSql(
|
|
570
|
+
`UPDATE ${this.ddl(table)} SET ${assign} WHERE ${condition.sql}`,
|
|
571
|
+
values.concat(condition.params)
|
|
572
|
+
)
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
await this.backup(entry, table, key.concat(columns), condition);
|
|
577
|
+
await this.batches(entry, options, match =>
|
|
578
|
+
this.driver.runSql(
|
|
579
|
+
`UPDATE ${this.ddl(table)} SET ${assign} WHERE ${match.sql}`,
|
|
580
|
+
values.concat(match.params)
|
|
581
|
+
)
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
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.
|
|
594
|
+
*/
|
|
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
|
+
|
|
608
|
+
const condition = this.where(where);
|
|
609
|
+
const irreversible = options.irreversible === true;
|
|
610
|
+
const key = irreversible ? null : this.key(table, options, 'delete');
|
|
611
|
+
|
|
612
|
+
return this.step(
|
|
613
|
+
'delete',
|
|
614
|
+
[table],
|
|
615
|
+
op =>
|
|
616
|
+
irreversible
|
|
617
|
+
? { t: 3, a: 'delete', c: [table] }
|
|
618
|
+
: { t: 4, a: 'delete', c: [table, backupName(this.file, op), key] },
|
|
619
|
+
async entry => {
|
|
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) {
|
|
720
|
+
return this.driver.runSql(
|
|
721
|
+
`DELETE FROM ${this.ddl(table)} WHERE ${condition.sql}`,
|
|
722
|
+
condition.params
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return this.untilDone(table, key, condition, options, match =>
|
|
727
|
+
this.driver.runSql(
|
|
728
|
+
`DELETE FROM ${this.ddl(table)} WHERE ${match.sql}`,
|
|
729
|
+
match.params
|
|
730
|
+
)
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* runSql(sql, [params], { revert } | { irreversible: true }), revert is
|
|
738
|
+
* the SQL reverting it, or [sql, params].
|
|
739
|
+
*/
|
|
740
|
+
runSql (sql, ...args) {
|
|
741
|
+
const params = Array.isArray(args[0]) ? args.shift() : [];
|
|
742
|
+
const options = args[0] || {};
|
|
743
|
+
const irreversible = options.irreversible === true;
|
|
744
|
+
const revert =
|
|
745
|
+
typeof options.revert === 'string' ? [options.revert, []] : options.revert;
|
|
746
|
+
|
|
747
|
+
if (
|
|
748
|
+
!irreversible &&
|
|
749
|
+
(!Array.isArray(revert) || typeof revert[0] !== 'string')
|
|
750
|
+
) {
|
|
751
|
+
throw new Error(
|
|
752
|
+
'runSql in a dml migration needs the SQL reverting it, ' +
|
|
753
|
+
"runSql(sql, { revert: 'sql' }), or { irreversible: true } to run " +
|
|
754
|
+
'it without being able to revert it.'
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return this.step(
|
|
759
|
+
'runSql',
|
|
760
|
+
[sql],
|
|
761
|
+
() =>
|
|
762
|
+
irreversible
|
|
763
|
+
? { t: 3, a: 'runSql', c: [sql] }
|
|
764
|
+
: { t: 4, a: 'runSql', c: [revert[0], revert[1] || []] },
|
|
765
|
+
() => this.tx(() => this.driver.runSql(sql, params))
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Reading is no step, it does not change anything.
|
|
771
|
+
*/
|
|
772
|
+
all (...args) {
|
|
773
|
+
return this.driver.all(...args);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Copies the rows about to change into the backup table of the step, only
|
|
778
|
+
* once: a backup completed by an interrupted run holds the rows as they
|
|
779
|
+
* were before the step.
|
|
780
|
+
*/
|
|
781
|
+
async backup (entry, table, columns, condition) {
|
|
782
|
+
if (entry.b) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const backup = this.ddl(entry.c[1]);
|
|
787
|
+
const select = columns ? columns.map(c => this.ddl(c)).join(', ') : '*';
|
|
788
|
+
|
|
789
|
+
// outside of transactions, creating tables commits them in MySQL
|
|
790
|
+
await this.driver.runSql(`DROP TABLE IF EXISTS ${backup}`);
|
|
791
|
+
await this.driver.runSql(
|
|
792
|
+
`CREATE TABLE ${backup} AS SELECT ${select} FROM ${this.ddl(table)} ` +
|
|
793
|
+
'WHERE 1 = 0'
|
|
794
|
+
);
|
|
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
|
+
)
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
entry.b = 1;
|
|
804
|
+
entry.o = 0;
|
|
805
|
+
await this.save();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Changes the rows of the backup in batches, ordered by their key. The
|
|
810
|
+
* progress is saved after each batch, changing a batch again after an
|
|
811
|
+
* interruption sets the same values.
|
|
812
|
+
*/
|
|
813
|
+
async batches (entry, options, change) {
|
|
814
|
+
const size = this.batchSize(options);
|
|
815
|
+
const backup = this.ddl(entry.c[1]);
|
|
816
|
+
const key = entry.c[2];
|
|
817
|
+
const order = key.map(k => this.ddl(k)).join(', ');
|
|
818
|
+
|
|
819
|
+
for (;;) {
|
|
820
|
+
const rows = await this.driver.all(
|
|
821
|
+
`SELECT ${order} FROM ${backup} ORDER BY ${order} ` +
|
|
822
|
+
`LIMIT ${size} OFFSET ${entry.o}`
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
if (!rows.length) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
await this.tx(() => change(this.byKey(rows, key)));
|
|
830
|
+
entry.o += rows.length;
|
|
831
|
+
log.verbose(`[dml] ${this.file.name}: ${entry.o} rows ${entry.a}d`);
|
|
832
|
+
await this.save();
|
|
833
|
+
await this.between();
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Reverts the record of one step. Each is safe to run again, so a revert
|
|
840
|
+
* interrupted in between is continued by running it again.
|
|
841
|
+
*/
|
|
842
|
+
const reverts = {
|
|
843
|
+
insert: (d, [table, flag]) =>
|
|
844
|
+
d.tx(() =>
|
|
845
|
+
d.driver.runSql(
|
|
846
|
+
`DELETE FROM ${d.ddl(table)} WHERE ${d.ddl(FLAG)} = ?`,
|
|
847
|
+
[flag]
|
|
848
|
+
)
|
|
849
|
+
),
|
|
850
|
+
|
|
851
|
+
runSql: (d, [sql, params]) => d.tx(() => d.driver.runSql(sql, params)),
|
|
852
|
+
|
|
853
|
+
update: async (d, [table, backup, key, columns]) => {
|
|
854
|
+
let offset = 0;
|
|
855
|
+
const order = key.map(k => d.ddl(k)).join(', ');
|
|
856
|
+
const assign = columns.map(c => `${d.ddl(c)} = ?`).join(', ');
|
|
857
|
+
const match = key.map(k => `${d.ddl(k)} = ?`).join(' AND ');
|
|
858
|
+
|
|
859
|
+
for (;;) {
|
|
860
|
+
const rows = await d.driver.all(
|
|
861
|
+
`SELECT * FROM ${d.ddl(backup)} ORDER BY ${order} ` +
|
|
862
|
+
`LIMIT ${BATCH} OFFSET ${offset}`
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
if (!rows.length) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
|
|
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
|
+
)
|
|
875
|
+
)
|
|
876
|
+
);
|
|
877
|
+
offset += rows.length;
|
|
878
|
+
}
|
|
879
|
+
},
|
|
880
|
+
|
|
881
|
+
delete: async (d, [table, backup, key]) => {
|
|
882
|
+
const [row] = await d.driver.all(`SELECT * FROM ${d.ddl(backup)} LIMIT 1`);
|
|
883
|
+
|
|
884
|
+
if (!row) {
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const columns = Object.keys(row)
|
|
889
|
+
.map(c => d.ddl(c))
|
|
890
|
+
.join(', ');
|
|
891
|
+
const match = key
|
|
892
|
+
.map(k => `${d.ddl('__dbm_t')}.${d.ddl(k)} = ${d.ddl('__dbm_b')}.${d.ddl(k)}`)
|
|
893
|
+
.join(' AND ');
|
|
894
|
+
|
|
895
|
+
// rows deleted by the step only, those not deleted yet stay as they are
|
|
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
|
+
)
|
|
902
|
+
);
|
|
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
|
+
)
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
const irreversibleSteps = internals =>
|
|
925
|
+
internals.modSchema.s
|
|
926
|
+
.filter(entry => entry.t === 3)
|
|
927
|
+
.map(entry => `step ${entry.n} ${entry.a}("${entry.c[0]}")`);
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Reverts the recorded steps, the last first. Steps with a backup table
|
|
931
|
+
* restore the rows from it and drop it afterwards.
|
|
932
|
+
*/
|
|
933
|
+
const revert = async (context, file, internals, pdriver, d) => {
|
|
934
|
+
d = d || new Dml(context, file, internals, pdriver, null);
|
|
935
|
+
const mod = internals.modSchema;
|
|
936
|
+
|
|
937
|
+
while (mod.s.length) {
|
|
938
|
+
const entry = mod.s[mod.s.length - 1];
|
|
939
|
+
const backup = entry.a === 'update' || entry.a === 'delete';
|
|
940
|
+
|
|
941
|
+
if (entry.t !== 4 || !reverts[entry.a]) {
|
|
942
|
+
throw new Error(`Invalid state record of a dml migration, ${entry.a}`);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (internals.dryRun && (backup || entry.a === 'soft')) {
|
|
946
|
+
log.info(
|
|
947
|
+
`[dml] would revert ${entry.a}("${entry.c[0]}")` +
|
|
948
|
+
(backup ? ` from ${entry.c[1]}` : '')
|
|
949
|
+
);
|
|
950
|
+
} else {
|
|
951
|
+
// b: 1 the backup is complete, 2 the rows are restored from it
|
|
952
|
+
if (!backup || entry.b === 1) {
|
|
953
|
+
await reverts[entry.a](d, entry.c);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (backup) {
|
|
957
|
+
entry.b = 2;
|
|
958
|
+
await d.save();
|
|
959
|
+
await d.driver.runSql(`DROP TABLE IF EXISTS ${d.ddl(entry.c[1])}`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
mod.s.pop();
|
|
964
|
+
await d.save();
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
await State.deleteState(pdriver, file, internals);
|
|
968
|
+
};
|
|
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
|
+
|
|
985
|
+
const opts = context => ({
|
|
986
|
+
options: context.internals.safeOptions,
|
|
987
|
+
dbm: context.internals.safeOptions.dbmigrate
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
const down = async (context, driver, file, { abort } = {}) => {
|
|
991
|
+
const internals = context.internals;
|
|
992
|
+
const pdriver = context._pdriver;
|
|
993
|
+
|
|
994
|
+
if (abort !== true) {
|
|
995
|
+
await State.startMigration(pdriver, file, internals, { op: 'down' });
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const err = irreversibleError(file, internals);
|
|
999
|
+
if (err) {
|
|
1000
|
+
if (abort !== true) {
|
|
1001
|
+
await State.endMigration(pdriver, file, internals);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
throw err;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
await revert(context, file, internals, pdriver);
|
|
1008
|
+
await State.endMigration(pdriver, file, internals);
|
|
1009
|
+
return Promise.promisify(context.deleteMigrationRecord.bind(context))(file);
|
|
1010
|
+
};
|
|
1011
|
+
|
|
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) => {
|
|
1020
|
+
let recovery = null;
|
|
1021
|
+
|
|
1022
|
+
if (interrupted) {
|
|
1023
|
+
const mode = recoveryMode(file, interrupted);
|
|
1024
|
+
log.warn(
|
|
1025
|
+
`[recovery] ${file.name}: the previous run was interrupted at step ` +
|
|
1026
|
+
`${interrupted.step}, ${interrupted.done} steps were executed, ` +
|
|
1027
|
+
`recovering by ${mode}`
|
|
1028
|
+
);
|
|
1029
|
+
|
|
1030
|
+
if (mode === 'rollback') {
|
|
1031
|
+
await hooks.rollingBack();
|
|
1032
|
+
await revert(context, file, internals, pdriver);
|
|
1033
|
+
internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
|
|
1034
|
+
await hooks.restart();
|
|
1035
|
+
} else {
|
|
1036
|
+
recovery = interrupted;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
internals.migrationDone = recovery ? recovery.done : 0;
|
|
1041
|
+
const db = new Dml(context, file, internals, pdriver, recovery, progress, work);
|
|
1042
|
+
|
|
1043
|
+
try {
|
|
1044
|
+
await file.get().migrate(db, opts(context));
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
if (err && err.stopped) {
|
|
1047
|
+
throw err;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
describeFailure(err, db, internals);
|
|
1051
|
+
|
|
1052
|
+
if (irreversibleSteps(internals).length) {
|
|
1053
|
+
log.error(
|
|
1054
|
+
`Migration "${file.name}" failed and can not be rolled back, it ` +
|
|
1055
|
+
'ran a step which can not be reverted. The steps executed stay, ' +
|
|
1056
|
+
'the next run continues after them.'
|
|
1057
|
+
);
|
|
1058
|
+
throw err;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// the record of the failed step reverts what it changed, if anything
|
|
1062
|
+
log.error(
|
|
1063
|
+
`Migration "${file.name}" failed, rolling back: ${
|
|
1064
|
+
(err && err.message) || err
|
|
1065
|
+
}`
|
|
1066
|
+
);
|
|
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
|
+
|
|
1095
|
+
throw err;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
await Promise.promisify(context.writeMigrationRecord.bind(context))(file);
|
|
1099
|
+
return State.endMigration(pdriver, file, internals);
|
|
1100
|
+
};
|
|
1101
|
+
|
|
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
|
+
};
|