db-migrate 1.4.0 → 1.5.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/lib/chain.js +84 -0
- package/lib/dml.js +39 -5
- package/lib/driver/shadow.js +15 -0
- package/lib/jobs.js +134 -9
- package/lib/learn.js +52 -1
- package/lib/methods/v2/statedone.js +2 -1
- package/lib/methods/v2/statetravel.js +2 -1
- package/lib/release.js +502 -0
- package/lib/state.js +7 -1
- package/lib/walker.js +184 -15
- package/lib/work.js +20 -3
- package/package.json +1 -1
package/lib/chain.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const log = require('db-migrate-shared').log;
|
|
2
|
+
const Release = require('./release');
|
|
3
|
+
const { unknownTable, unknownColumn } = require('./learn');
|
|
2
4
|
|
|
3
5
|
const DEFAULT = [
|
|
4
6
|
'renameCollection',
|
|
@@ -151,6 +153,8 @@ class Chain {
|
|
|
151
153
|
*/
|
|
152
154
|
this.adopt = {};
|
|
153
155
|
|
|
156
|
+
this.deprecations();
|
|
157
|
+
|
|
154
158
|
DEFAULT.concat(Object.keys(this.context.learnable || [])).forEach(
|
|
155
159
|
method => {
|
|
156
160
|
this[method] = function (...args) {
|
|
@@ -173,6 +177,86 @@ class Chain {
|
|
|
173
177
|
);
|
|
174
178
|
}
|
|
175
179
|
|
|
180
|
+
/**
|
|
181
|
+
* deprecateTable, deprecateColumn and dropDeprecated, see Release. The
|
|
182
|
+
* marks are learned only, relaxing notNull of a column is executed.
|
|
183
|
+
*/
|
|
184
|
+
deprecations () {
|
|
185
|
+
const internals = this.internals;
|
|
186
|
+
|
|
187
|
+
this.deprecateTable = async (t, options) => {
|
|
188
|
+
this.transferInt();
|
|
189
|
+
if (!internals.schema.c[t]) {
|
|
190
|
+
throw unknownTable(t);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const entry = Release.entry(internals, t, options);
|
|
194
|
+
return this.run('setDeprecated', ['tables', t, null, entry], 'learn');
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
this.deprecateColumn = async (t, c, options) => {
|
|
198
|
+
this.transferInt();
|
|
199
|
+
const columns = Release.columnsOf(internals.schema, t);
|
|
200
|
+
if (!columns) {
|
|
201
|
+
throw unknownTable(t);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!columns[c]) {
|
|
205
|
+
throw unknownColumn(t, c);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const entry = Release.entry(internals, c, options);
|
|
209
|
+
if (columns[c].notNull) {
|
|
210
|
+
// the application stops writing it. The whole spec, MySQL changes
|
|
211
|
+
// a column by its full definition
|
|
212
|
+
await this.changeColumn(
|
|
213
|
+
t,
|
|
214
|
+
c,
|
|
215
|
+
Object.assign({}, columns[c], { notNull: false })
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return this.run('setDeprecated', ['columns', t, c, entry], 'learn');
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* dropDeprecated() drops what is due, dropDeprecated(table) or
|
|
224
|
+
* dropDeprecated(table, column) the one named, due or not.
|
|
225
|
+
*/
|
|
226
|
+
this.dropDeprecated = async (t, c) => {
|
|
227
|
+
this.transferInt();
|
|
228
|
+
const items = Release.list(
|
|
229
|
+
internals,
|
|
230
|
+
internals.releaseCurrent || 0,
|
|
231
|
+
internals.releaseIndex || {}
|
|
232
|
+
);
|
|
233
|
+
let targets;
|
|
234
|
+
|
|
235
|
+
if (t === undefined) {
|
|
236
|
+
targets = items.filter(
|
|
237
|
+
item => item.age >= Release.settings(internals, item.entry).releases
|
|
238
|
+
);
|
|
239
|
+
} else {
|
|
240
|
+
targets = items.filter(item =>
|
|
241
|
+
c === undefined
|
|
242
|
+
? item.kind === 'tables' && item.t === t
|
|
243
|
+
: item.kind === 'columns' && item.t === t && item.c === c
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (!targets.length) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`${c === undefined ? `The table "${t}"` : `The column "${c}" of "${t}"`} ` +
|
|
249
|
+
'is not deprecated, deprecate it first'
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const item of targets) {
|
|
255
|
+
await Release.drop(this, item);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
176
260
|
transferInt () {
|
|
177
261
|
rollbackCounter = counter;
|
|
178
262
|
counter = 0;
|
package/lib/dml.js
CHANGED
|
@@ -282,6 +282,11 @@ class Dml {
|
|
|
282
282
|
throw new Stopped();
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
// migrations paused the jobs
|
|
286
|
+
if (this.work.yielding && (await this.work.yielding())) {
|
|
287
|
+
throw new Stopped();
|
|
288
|
+
}
|
|
289
|
+
|
|
285
290
|
if (this.work.pause) {
|
|
286
291
|
await delay(this.work.pause);
|
|
287
292
|
}
|
|
@@ -658,12 +663,20 @@ class Dml {
|
|
|
658
663
|
const condition = this.where(where);
|
|
659
664
|
const key = this.key(table, options, 'delete');
|
|
660
665
|
const time = options.value === undefined;
|
|
666
|
+
const purge = options.purge;
|
|
667
|
+
|
|
668
|
+
if (purge !== undefined && purge !== true && !isObject(purge)) {
|
|
669
|
+
throw new Error(
|
|
670
|
+
`delete("${table}") takes purge: true or { releases, drop }, to purge ` +
|
|
671
|
+
'the rows with a later release'
|
|
672
|
+
);
|
|
673
|
+
}
|
|
661
674
|
|
|
662
675
|
return this.step(
|
|
663
676
|
'delete',
|
|
664
677
|
[table],
|
|
665
678
|
op => ({ t: 4, a: 'soft', c: [table, column, key, deleted(this.file, op)] }),
|
|
666
|
-
entry => {
|
|
679
|
+
async entry => {
|
|
667
680
|
const mark = entry.c[3];
|
|
668
681
|
const set =
|
|
669
682
|
`${this.ddl(column)} = ${time ? 'CURRENT_TIMESTAMP' : '?'}, ` +
|
|
@@ -682,6 +695,17 @@ class Dml {
|
|
|
682
695
|
);
|
|
683
696
|
}
|
|
684
697
|
|
|
698
|
+
if (purge) {
|
|
699
|
+
await require('./release').schedulePurge(
|
|
700
|
+
this.pdriver,
|
|
701
|
+
this.internals,
|
|
702
|
+
mark,
|
|
703
|
+
table,
|
|
704
|
+
key,
|
|
705
|
+
purge
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
685
709
|
return this.untilDone(table, key, active, options, match =>
|
|
686
710
|
this.driver.runSql(
|
|
687
711
|
`UPDATE ${this.ddl(table)} SET ${set} WHERE ${match.sql}`,
|
|
@@ -710,7 +734,15 @@ class Dml {
|
|
|
710
734
|
'purge',
|
|
711
735
|
[table].concat(migration ? [migration] : []),
|
|
712
736
|
() => ({ t: 3, a: 'purge', c: [table] }),
|
|
713
|
-
() => {
|
|
737
|
+
async () => {
|
|
738
|
+
// nothing left to purge with a later release
|
|
739
|
+
await require('./release').forgetPurges(
|
|
740
|
+
this.pdriver,
|
|
741
|
+
this.internals,
|
|
742
|
+
table,
|
|
743
|
+
mark
|
|
744
|
+
);
|
|
745
|
+
|
|
714
746
|
const condition = {
|
|
715
747
|
sql: `${this.ddl(FLAG)} LIKE ? ESCAPE '!'`,
|
|
716
748
|
params: [`%${like(mark)}%`]
|
|
@@ -902,8 +934,9 @@ const reverts = {
|
|
|
902
934
|
);
|
|
903
935
|
},
|
|
904
936
|
|
|
905
|
-
soft: (d, [table, column, key, mark]) =>
|
|
906
|
-
d.
|
|
937
|
+
soft: async (d, [table, column, key, mark]) => {
|
|
938
|
+
await require('./release').forgetPurges(d.pdriver, d.internals, table, mark);
|
|
939
|
+
return d.untilDone(
|
|
907
940
|
table,
|
|
908
941
|
key,
|
|
909
942
|
{
|
|
@@ -918,7 +951,8 @@ const reverts = {
|
|
|
918
951
|
`WHERE ${match.sql}`,
|
|
919
952
|
[mark].concat(match.params)
|
|
920
953
|
)
|
|
921
|
-
)
|
|
954
|
+
);
|
|
955
|
+
}
|
|
922
956
|
};
|
|
923
957
|
|
|
924
958
|
const irreversibleSteps = internals =>
|
package/lib/driver/shadow.js
CHANGED
|
@@ -70,5 +70,20 @@ exports.overshadow = function (db, ShadowProto, ShadowError) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// instructions of db-migrate itself, which no driver has, e.g. setDeprecated
|
|
74
|
+
let proto = Object.getPrototypeOf(ShadowProto);
|
|
75
|
+
while (proto && proto !== Object.prototype) {
|
|
76
|
+
Object.getOwnPropertyNames(proto).forEach(prop => {
|
|
77
|
+
if (
|
|
78
|
+
prop !== 'constructor' &&
|
|
79
|
+
newDb[prop] === undefined &&
|
|
80
|
+
typeof ShadowProto[prop] === 'function'
|
|
81
|
+
) {
|
|
82
|
+
newDb[prop] = ShadowProto[prop];
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
proto = Object.getPrototypeOf(proto);
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
return newDb;
|
|
74
89
|
};
|
package/lib/jobs.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const log = require('db-migrate-shared').log;
|
|
5
|
+
const State = require('./state');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Background migrations, dml migrations with _meta.background, are not run
|
|
@@ -20,6 +21,12 @@ const log = require('db-migrate-shared').log;
|
|
|
20
21
|
* in the foreground, see State
|
|
21
22
|
* h: hash of the migration file when it started
|
|
22
23
|
* err: why it failed
|
|
24
|
+
*
|
|
25
|
+
* Migrations take precedence over jobs. Whoever holds the migration lock
|
|
26
|
+
* pauses the jobs before migrating: no job is taken anymore, the running
|
|
27
|
+
* ones stop after their current batch, and are continued once the lock is
|
|
28
|
+
* released. The pause is stored with the jobs, so taking a job and pausing
|
|
29
|
+
* never miss each other. It holds as long as its holder holds the lock.
|
|
23
30
|
*/
|
|
24
31
|
|
|
25
32
|
const JOBS = '__dbmigrate_jobs__';
|
|
@@ -28,26 +35,30 @@ const nonce = () => crypto.randomBytes(8).toString('hex');
|
|
|
28
35
|
|
|
29
36
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
30
37
|
|
|
38
|
+
const WAIT = { wait: true };
|
|
39
|
+
|
|
31
40
|
const read = async (pdriver, internals) => {
|
|
32
41
|
const row = await pdriver._getKV(internals.migrationState, JOBS);
|
|
33
|
-
|
|
42
|
+
const state = row ? JSON.parse(row.value) : { jobs: {} };
|
|
43
|
+
return { row, state, jobs: state.jobs };
|
|
34
44
|
};
|
|
35
45
|
|
|
36
46
|
const list = async (pdriver, internals) => (await read(pdriver, internals)).jobs;
|
|
37
47
|
|
|
38
48
|
/**
|
|
39
|
-
* Changes the jobs by mutate, retried until no other worker
|
|
40
|
-
* between. mutate returns false to leave them as they are.
|
|
49
|
+
* Changes the jobs by mutate(jobs, state), retried until no other worker
|
|
50
|
+
* wrote in between. mutate returns false to leave them as they are.
|
|
41
51
|
*/
|
|
42
52
|
const change = async (pdriver, internals, mutate) => {
|
|
43
53
|
for (let attempt = 0; ; attempt++) {
|
|
44
|
-
const { row, jobs } = await read(pdriver, internals);
|
|
54
|
+
const { row, state, jobs } = await read(pdriver, internals);
|
|
45
55
|
|
|
46
|
-
if (mutate(jobs) === false) {
|
|
56
|
+
if (mutate(jobs, state) === false) {
|
|
47
57
|
return jobs;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
|
|
60
|
+
state.n = nonce();
|
|
61
|
+
const value = JSON.stringify(state);
|
|
51
62
|
|
|
52
63
|
if (!row) {
|
|
53
64
|
try {
|
|
@@ -108,19 +119,61 @@ const register = async (pdriver, internals, file) => {
|
|
|
108
119
|
log.info(`[jobs] ${file.name} runs in the background`);
|
|
109
120
|
};
|
|
110
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Whether the jobs are paused by a migration. A pause holds as long as its
|
|
124
|
+
* holder holds the migration lock and keeps it alive, the lock row did not
|
|
125
|
+
* stay the same for the lock timeout, measured on our clock in seen.
|
|
126
|
+
*
|
|
127
|
+
* Resolves to the pause, or null.
|
|
128
|
+
*/
|
|
129
|
+
const paused = async (pdriver, internals, seen = {}) => {
|
|
130
|
+
const { state } = await read(pdriver, internals);
|
|
131
|
+
|
|
132
|
+
if (!state.pause) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const lock = await State.readLock(pdriver, internals);
|
|
137
|
+
if (!lock || State.holder(lock) !== state.pause.ID) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const last = seen.lock;
|
|
143
|
+
if (!last || last.value !== lock.value || String(last.run_on) !== String(lock.run_on)) {
|
|
144
|
+
seen.lock = { value: lock.value, run_on: lock.run_on, since: now };
|
|
145
|
+
} else if (now - last.since >= State.lockOptions(internals).timeout) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return state.pause;
|
|
150
|
+
};
|
|
151
|
+
|
|
111
152
|
/**
|
|
112
153
|
* Takes the first job to run, in the order of their names. A running job is
|
|
113
154
|
* taken over if it did not change for timeout ms, measured on our clock in
|
|
114
155
|
* seen. Jobs after a blocking job wait until it is done.
|
|
115
156
|
*
|
|
116
|
-
* Resolves to the name of the job taken, or
|
|
157
|
+
* Resolves to the name of the job taken, null if there is none, or WAIT
|
|
158
|
+
* while the jobs are paused.
|
|
117
159
|
*/
|
|
118
160
|
const claim = async (pdriver, internals, id, seen, timeout) => {
|
|
119
161
|
const now = Date.now();
|
|
162
|
+
const valid = await paused(pdriver, internals, seen);
|
|
120
163
|
let taken = null;
|
|
164
|
+
let wait = false;
|
|
121
165
|
|
|
122
|
-
await change(pdriver, internals, jobs => {
|
|
166
|
+
await change(pdriver, internals, (jobs, state) => {
|
|
123
167
|
taken = null;
|
|
168
|
+
// a pause found invalid before is ignored, one set in between holds
|
|
169
|
+
wait = !!state.pause && (!!valid || state.pause.n !== seen.invalid);
|
|
170
|
+
if (state.pause && !valid) {
|
|
171
|
+
seen.invalid = seen.invalid || state.pause.n;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (wait) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
124
177
|
|
|
125
178
|
for (const name of Object.keys(jobs).sort()) {
|
|
126
179
|
const job = jobs[name];
|
|
@@ -154,9 +207,81 @@ const claim = async (pdriver, internals, id, seen, timeout) => {
|
|
|
154
207
|
return false;
|
|
155
208
|
});
|
|
156
209
|
|
|
210
|
+
if (wait) {
|
|
211
|
+
return WAIT;
|
|
212
|
+
}
|
|
213
|
+
|
|
157
214
|
return taken;
|
|
158
215
|
};
|
|
159
216
|
|
|
217
|
+
const isRunning = (job, id) => job.s === 'running' && job.ID !== id;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Pauses the jobs for the migrations of the lock holder id, and waits until
|
|
221
|
+
* the running jobs stopped. A job whose worker does not respond for the lock
|
|
222
|
+
* timeout is not waited for, it is continued by another worker later.
|
|
223
|
+
*/
|
|
224
|
+
const pause = async (pdriver, internals, id) => {
|
|
225
|
+
const { timeout, interval } = State.lockOptions(internals);
|
|
226
|
+
const seen = {};
|
|
227
|
+
|
|
228
|
+
await change(pdriver, internals, (jobs, state) => {
|
|
229
|
+
state.pause = { ID: id, n: nonce() };
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
for (;;) {
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
const jobs = await list(pdriver, internals);
|
|
235
|
+
const running = Object.keys(jobs).filter(name => {
|
|
236
|
+
const job = jobs[name];
|
|
237
|
+
|
|
238
|
+
if (!isRunning(job, id)) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const last = seen[name];
|
|
243
|
+
if (!last || last.n !== job.n) {
|
|
244
|
+
seen[name] = { n: job.n, since: now };
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return now - last.since < timeout;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (!running.length) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
log.info(
|
|
256
|
+
`[jobs] waiting for ${running.length} background job(s) to pause: ` +
|
|
257
|
+
running.join(', ')
|
|
258
|
+
);
|
|
259
|
+
// they stop after their current batch, usually soon
|
|
260
|
+
await delay(Math.min(interval, 200));
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Forgets a job, once its migration is reverted.
|
|
266
|
+
*/
|
|
267
|
+
const remove = (pdriver, internals, name) =>
|
|
268
|
+
change(pdriver, internals, jobs => {
|
|
269
|
+
if (!jobs[name]) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
delete jobs[name];
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const resume = (pdriver, internals, id) =>
|
|
277
|
+
change(pdriver, internals, (jobs, state) => {
|
|
278
|
+
if (!state.pause || state.pause.ID !== id) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
delete state.pause;
|
|
283
|
+
});
|
|
284
|
+
|
|
160
285
|
/**
|
|
161
286
|
* Changes a job we run. If another worker took it over in between, lost is
|
|
162
287
|
* called and nothing is changed.
|
|
@@ -174,4 +299,4 @@ const update = (pdriver, internals, name, id, mutate, lost = () => {}) =>
|
|
|
174
299
|
job.n = nonce();
|
|
175
300
|
});
|
|
176
301
|
|
|
177
|
-
module.exports = { JOBS, list, register, claim, update };
|
|
302
|
+
module.exports = { JOBS, WAIT, list, register, claim, update, paused, pause, resume, remove };
|
package/lib/learn.js
CHANGED
|
@@ -59,6 +59,14 @@ class STD {
|
|
|
59
59
|
schema.e = {};
|
|
60
60
|
}
|
|
61
61
|
this.extra = schema.e;
|
|
62
|
+
if (!schema.d) {
|
|
63
|
+
schema.d = {};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* deprecated tables and columns, d.tables[table] and
|
|
67
|
+
* d.columns[table][column], see Release
|
|
68
|
+
*/
|
|
69
|
+
this.deprecated = schema.d;
|
|
62
70
|
if (unlearn) {
|
|
63
71
|
this.modC = [];
|
|
64
72
|
} else {
|
|
@@ -137,6 +145,15 @@ class STD {
|
|
|
137
145
|
}
|
|
138
146
|
|
|
139
147
|
renameTable (t, n) {
|
|
148
|
+
// deprecations move with the table
|
|
149
|
+
['tables', 'columns'].forEach(kind => {
|
|
150
|
+
const d = this.deprecated[kind];
|
|
151
|
+
if (d && d[t] !== undefined) {
|
|
152
|
+
d[n] = d[t];
|
|
153
|
+
delete d[t];
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
140
157
|
if (this.schema[t]) {
|
|
141
158
|
this.schema[n] = this.schema[t];
|
|
142
159
|
delete this.schema[t];
|
|
@@ -310,7 +327,9 @@ class STD {
|
|
|
310
327
|
const columns = hasColumns ? this.schema[t].columns : this.schema[t];
|
|
311
328
|
|
|
312
329
|
this.modS[t][c] = columns[c];
|
|
313
|
-
|
|
330
|
+
// a copy, the previous spec is kept to revert it. Reverting restores
|
|
331
|
+
// the previous spec as a whole.
|
|
332
|
+
columns[c] = Object.assign({}, this.unlearn ? {} : columns[c], s);
|
|
314
333
|
|
|
315
334
|
this.modC.push({ t: 1, a: 'changeColumn', c: [t, c] });
|
|
316
335
|
|
|
@@ -419,6 +438,38 @@ class STD {
|
|
|
419
438
|
return Promise.resolve(alter);
|
|
420
439
|
}
|
|
421
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Marks a table or column as deprecated, or removes the mark with entry
|
|
443
|
+
* null. Only learned, nothing is executed.
|
|
444
|
+
*/
|
|
445
|
+
setDeprecated (kind, t, c, entry) {
|
|
446
|
+
const d = this.deprecated;
|
|
447
|
+
if (!d[kind]) d[kind] = {};
|
|
448
|
+
|
|
449
|
+
let previous;
|
|
450
|
+
if (kind === 'tables') {
|
|
451
|
+
previous = d.tables[t] || null;
|
|
452
|
+
if (entry) {
|
|
453
|
+
d.tables[t] = entry;
|
|
454
|
+
} else {
|
|
455
|
+
delete d.tables[t];
|
|
456
|
+
}
|
|
457
|
+
} else {
|
|
458
|
+
if (!d.columns[t]) d.columns[t] = {};
|
|
459
|
+
previous = d.columns[t][c] || null;
|
|
460
|
+
if (entry) {
|
|
461
|
+
d.columns[t][c] = entry;
|
|
462
|
+
} else {
|
|
463
|
+
delete d.columns[t][c];
|
|
464
|
+
if (!Object.keys(d.columns[t]).length) delete d.columns[t];
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
this.modC.push({ t: 2, a: 'setDeprecated', c: [kind, t, c, previous] });
|
|
469
|
+
|
|
470
|
+
return Promise.resolve();
|
|
471
|
+
}
|
|
472
|
+
|
|
422
473
|
//
|
|
423
474
|
// checkDBMS: dummy,
|
|
424
475
|
//
|
|
@@ -13,6 +13,7 @@ module.exports = {
|
|
|
13
13
|
return State.done(pdriver, internals.migrationOp, internals);
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// learned only, no method of the driver
|
|
17
|
+
return Shadow.overshadow(driver, { setDeprecated: done() }, done);
|
|
17
18
|
}
|
|
18
19
|
};
|
package/lib/release.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const log = require('db-migrate-shared').log;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Releases and the deprecation of tables and columns.
|
|
8
|
+
*
|
|
9
|
+
* A migration starts a release with _meta.release, any label. The releases
|
|
10
|
+
* are counted in the order of the migrations, a migration without a label
|
|
11
|
+
* belongs to the release before it, those before the first label to release
|
|
12
|
+
* 0. The labels themselves are not interpreted.
|
|
13
|
+
*
|
|
14
|
+
* db.deprecateTable and db.deprecateColumn mark a table or column in the
|
|
15
|
+
* release of their migration R. Once a migration starts a later release,
|
|
16
|
+
* db-migrate runs the steps due before it, as a migration of its own named
|
|
17
|
+
* __dbmigrate_release__:<label>:
|
|
18
|
+
*
|
|
19
|
+
* R+1: the table or column is renamed to __dbm_deprecated_<name>_<time>, so
|
|
20
|
+
* whatever still uses it fails, while the data is still there
|
|
21
|
+
* R+N: it is dropped, N = releases (4 by default), with drop 'auto' only.
|
|
22
|
+
* With drop 'manual' (default) db-migrate warns it is due, until
|
|
23
|
+
* db.dropDeprecated drops it.
|
|
24
|
+
*
|
|
25
|
+
* Reverting the first migration of a release reverts the steps run before
|
|
26
|
+
* it as well.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const PREFIX = '__dbmigrate_release__:';
|
|
30
|
+
const PURGES = '__dbmigrate_purges__';
|
|
31
|
+
const DEFAULTS = { releases: 4, drop: 'manual' };
|
|
32
|
+
const DROPS = ['auto', 'manual'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The release of every migration: { label, index }, by name.
|
|
36
|
+
*/
|
|
37
|
+
const releases = files => {
|
|
38
|
+
const map = {};
|
|
39
|
+
const index = {};
|
|
40
|
+
let current = { label: null, index: 0 };
|
|
41
|
+
|
|
42
|
+
files
|
|
43
|
+
.slice()
|
|
44
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
45
|
+
.forEach(file => {
|
|
46
|
+
let label;
|
|
47
|
+
try {
|
|
48
|
+
label = (file.get()._meta || {}).release;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// an old migration which can not be loaded anymore
|
|
51
|
+
log.verbose(`[release] can not load ${file.name}: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (label !== undefined && label !== null && String(label) !== current.label) {
|
|
55
|
+
const key = String(label);
|
|
56
|
+
if (index[key] === undefined) {
|
|
57
|
+
index[key] = Object.keys(index).length + 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
current = { label: key, index: index[key] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
map[file.name] = current;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return { map, index };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The name a deprecated table or column is renamed to.
|
|
71
|
+
*/
|
|
72
|
+
const hiddenName = name => {
|
|
73
|
+
const time = Math.floor(Date.now() / 1000);
|
|
74
|
+
const plain = `__dbm_deprecated_${name}_${time}`;
|
|
75
|
+
|
|
76
|
+
if (plain.length <= 60) {
|
|
77
|
+
return plain;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const hash = crypto.createHash('sha256').update(name).digest('hex');
|
|
81
|
+
return `__dbm_deprecated_${hash.slice(0, 16)}_${time}`;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const checkOptions = (options = {}) => {
|
|
85
|
+
const out = {};
|
|
86
|
+
|
|
87
|
+
if (options.releases !== undefined) {
|
|
88
|
+
const n = Number(options.releases);
|
|
89
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`releases is the number of releases until dropping, at least 1, not ${options.releases}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
out.releases = n;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (options.drop !== undefined) {
|
|
99
|
+
if (DROPS.indexOf(options.drop) === -1) {
|
|
100
|
+
throw new Error(`drop is 'auto' or 'manual', not ${options.drop}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
out.drop = options.drop;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return out;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The options of an entry, given for it, else of the project, else the
|
|
111
|
+
* defaults. The project sets them as deprecation: { releases, drop }, in
|
|
112
|
+
* the rc file or the options of the programmable API.
|
|
113
|
+
*/
|
|
114
|
+
const settings = (internals, entry) => {
|
|
115
|
+
const project = checkOptions((internals.argv && internals.argv.deprecation) || {});
|
|
116
|
+
return Object.assign({}, DEFAULTS, project, entry.o || {});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const entry = (internals, name, options) => {
|
|
120
|
+
const e = { r: internals.releaseLabel || null, to: hiddenName(name) };
|
|
121
|
+
const o = checkOptions(options);
|
|
122
|
+
|
|
123
|
+
if (Object.keys(o).length) {
|
|
124
|
+
e.o = o;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return e;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const columnsOf = (schema, table) => {
|
|
131
|
+
const t = schema.c[table];
|
|
132
|
+
return t ? t.columns || t : null;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* All deprecations with their state:
|
|
137
|
+
*
|
|
138
|
+
* name: the current name, renamed or not
|
|
139
|
+
* renamed: whether it was renamed
|
|
140
|
+
* age: releases since deprecated, up to index
|
|
141
|
+
*/
|
|
142
|
+
const list = (internals, index, releaseIndex) => {
|
|
143
|
+
const schema = internals.schema;
|
|
144
|
+
const d = schema.d || {};
|
|
145
|
+
const out = [];
|
|
146
|
+
const age = e => index - (e.r === null ? 0 : releaseIndex[e.r] || 0);
|
|
147
|
+
|
|
148
|
+
Object.keys(d.tables || {}).forEach(t => {
|
|
149
|
+
const e = d.tables[t];
|
|
150
|
+
const renamed = !!schema.c[e.to];
|
|
151
|
+
if (!renamed && !schema.c[t]) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
out.push({ kind: 'tables', t, entry: e, renamed, name: renamed ? e.to : t, age: age(e) });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
Object.keys(d.columns || {}).forEach(t => {
|
|
159
|
+
const columns = columnsOf(schema, t) || {};
|
|
160
|
+
Object.keys(d.columns[t]).forEach(c => {
|
|
161
|
+
const e = d.columns[t][c];
|
|
162
|
+
const renamed = !!columns[e.to];
|
|
163
|
+
if (!renamed && !columns[c]) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
out.push({ kind: 'columns', t, c, entry: e, renamed, name: renamed ? e.to : c, age: age(e) });
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return out;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const describe = item =>
|
|
175
|
+
item.kind === 'tables'
|
|
176
|
+
? `table "${item.t}"`
|
|
177
|
+
: `column "${item.c}" of "${item.t}"`;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Drops a deprecated table or column and forgets the deprecation, within a
|
|
181
|
+
* v2 migration.
|
|
182
|
+
*/
|
|
183
|
+
const drop = async (db, item) => {
|
|
184
|
+
if (item.kind === 'tables') {
|
|
185
|
+
await db.dropTable(item.name);
|
|
186
|
+
} else {
|
|
187
|
+
await db.removeColumn(item.t, item.name);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await db.run('setDeprecated', [item.kind, item.t, item.c || null, null], 'learn');
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The steps due when the release with index starts.
|
|
195
|
+
*/
|
|
196
|
+
const due = (internals, index, releaseIndex) => {
|
|
197
|
+
const renames = [];
|
|
198
|
+
const drops = [];
|
|
199
|
+
|
|
200
|
+
list(internals, index, releaseIndex).forEach(item => {
|
|
201
|
+
const { releases, drop } = settings(internals, item.entry);
|
|
202
|
+
|
|
203
|
+
if (item.age >= releases && drop === 'auto') {
|
|
204
|
+
drops.push(item);
|
|
205
|
+
} else if (item.age >= 1 && !item.renamed) {
|
|
206
|
+
renames.push(item);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return { renames, drops };
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Rows deleted in soft mode with { purge }, to delete for good once enough
|
|
215
|
+
* releases passed: { [mark]: { t, key, r, o } }, stored in the state.
|
|
216
|
+
* Changed by compare and swap, background jobs delete in soft mode as well.
|
|
217
|
+
*/
|
|
218
|
+
const purges = async (pdriver, internals) => {
|
|
219
|
+
const row = await pdriver._getKV(internals.migrationState, PURGES);
|
|
220
|
+
return { row, value: row ? JSON.parse(row.value) : {} };
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const changePurges = async (pdriver, internals, mutate) => {
|
|
224
|
+
if (internals.dryRun) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
229
|
+
const { row, value } = await purges(pdriver, internals);
|
|
230
|
+
if (mutate(value) === false) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const next = JSON.stringify(value);
|
|
235
|
+
if (!row) {
|
|
236
|
+
try {
|
|
237
|
+
await pdriver._insertKV(internals.migrationState, PURGES, next);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
log.verbose('[release] purges inserted concurrently', err.message);
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
await pdriver._updateKVC(internals.migrationState, PURGES, next, 'value', row.value);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const after = await pdriver._getKV(internals.migrationState, PURGES);
|
|
246
|
+
if (after && after.value === next) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
throw new Error('[release] could not change the purges, too much contention');
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Schedules purging the rows of a soft delete, { purge: true } takes the
|
|
256
|
+
* options of the project.
|
|
257
|
+
*/
|
|
258
|
+
const schedulePurge = (pdriver, internals, mark, t, key, purge) => {
|
|
259
|
+
const o = purge === true ? {} : checkOptions(purge);
|
|
260
|
+
const entry = { t, key, r: internals.releaseLabel || null };
|
|
261
|
+
if (Object.keys(o).length) {
|
|
262
|
+
entry.o = o;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return changePurges(pdriver, internals, value => {
|
|
266
|
+
value[mark] = entry;
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Forgets the purges done, of a soft delete or by db.purge, matching
|
|
272
|
+
* the table and the prefix of the mark.
|
|
273
|
+
*/
|
|
274
|
+
const forgetPurges = (pdriver, internals, t, prefix) =>
|
|
275
|
+
changePurges(pdriver, internals, value => {
|
|
276
|
+
const marks = Object.keys(value).filter(
|
|
277
|
+
mark => value[mark].t === t && mark.indexOf(prefix) === 0
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
if (!marks.length) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
marks.forEach(mark => delete value[mark]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const duePurges = async (pdriver, internals, index, releaseIndex) => {
|
|
288
|
+
const { value } = await purges(pdriver, internals);
|
|
289
|
+
|
|
290
|
+
return Object.keys(value).map(mark => {
|
|
291
|
+
const e = value[mark];
|
|
292
|
+
const age = index - (e.r === null ? 0 : releaseIndex[e.r] || 0);
|
|
293
|
+
return Object.assign({ mark, age }, e, settings(internals, e));
|
|
294
|
+
}).filter(e => e.age >= e.releases);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const releaseFile = (label, migrate) => ({
|
|
298
|
+
name: PREFIX + label,
|
|
299
|
+
path: null,
|
|
300
|
+
get: () => ({ _meta: { version: 2 }, migrate })
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The walker, without recording the release steps as migrations.
|
|
305
|
+
*/
|
|
306
|
+
const unrecorded = walker =>
|
|
307
|
+
Object.create(walker, {
|
|
308
|
+
writeMigrationRecord: { value: (file, cb) => cb() },
|
|
309
|
+
deleteMigrationRecord: { value: (file, cb) => cb() }
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Runs the steps due before the first migration of the release label.
|
|
314
|
+
*/
|
|
315
|
+
const start = async (walker, label, index, releaseIndex) => {
|
|
316
|
+
const internals = walker.internals;
|
|
317
|
+
const { renames, drops } = due(internals, index, releaseIndex);
|
|
318
|
+
const purged = (await duePurges(walker._pdriver, internals, index, releaseIndex)).filter(
|
|
319
|
+
e => e.drop === 'auto'
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
if (purged.length) {
|
|
323
|
+
await purge(walker, label, purged);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!renames.length && !drops.length) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
log.info(`[release] starting release ${label}`);
|
|
331
|
+
renames.forEach(item =>
|
|
332
|
+
log.info(`[release] renaming the deprecated ${describe(item)} to ${item.entry.to}`)
|
|
333
|
+
);
|
|
334
|
+
drops.forEach(item => log.info(`[release] dropping the deprecated ${describe(item)}`));
|
|
335
|
+
|
|
336
|
+
const file = releaseFile(label, async db => {
|
|
337
|
+
for (const item of renames) {
|
|
338
|
+
if (item.kind === 'tables') {
|
|
339
|
+
await db.renameTable(item.t, item.entry.to);
|
|
340
|
+
} else {
|
|
341
|
+
await db.renameColumn(item.t, item.c, item.entry.to);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
for (const item of drops) {
|
|
346
|
+
await drop(db, item);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
|
|
351
|
+
if (internals.dryRun) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await require('./executors/versioned/v2').up(unrecorded(walker), walker.driver, file);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Deletes the rows of the soft deletes due for good, before the first
|
|
360
|
+
* migration of the release label. This can not be reverted, the steps of
|
|
361
|
+
* the release are recorded as irreversible.
|
|
362
|
+
*/
|
|
363
|
+
const purge = async (walker, label, due) => {
|
|
364
|
+
const internals = walker.internals;
|
|
365
|
+
const pdriver = walker._pdriver;
|
|
366
|
+
const name = PREFIX + label;
|
|
367
|
+
const { Dml, FLAG } = require('./dml');
|
|
368
|
+
const file = releaseFile(label, async () => {});
|
|
369
|
+
const d = new Dml(walker, file, internals, pdriver, null, {
|
|
370
|
+
step: async () => {},
|
|
371
|
+
learned: async () => {},
|
|
372
|
+
done: async () => {}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
for (const e of due) {
|
|
376
|
+
log.info(`[release] purging the rows of "${e.t}" deleted in soft mode by ${e.mark.slice(5)}`);
|
|
377
|
+
if (!internals.dryRun) {
|
|
378
|
+
await d.untilDone(
|
|
379
|
+
e.t,
|
|
380
|
+
e.key,
|
|
381
|
+
{ sql: `${d.ddl(FLAG)} LIKE ? ESCAPE '!'`, params: [`%${e.mark.replace(/[!%_]/g, c => '!' + c)}%`] },
|
|
382
|
+
{},
|
|
383
|
+
match => walker._driver.runSql(`DELETE FROM ${d.ddl(e.t)} WHERE ${match.sql}`, match.params)
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (internals.dryRun) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
await changePurges(pdriver, internals, value => {
|
|
393
|
+
due.forEach(e => delete value[e.mark]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// the release can not be reverted anymore
|
|
397
|
+
const row = await pdriver._getKV(internals.migrationState, name);
|
|
398
|
+
const state = row ? JSON.parse(row.value) : { i: {}, c: {}, f: {}, s: [] };
|
|
399
|
+
state.s = (state.s || []).concat(
|
|
400
|
+
due.map(e => ({ t: 3, a: 'purge', c: [e.t], n: 0 }))
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
if (row) {
|
|
404
|
+
await pdriver._updateKV(internals.migrationState, name, JSON.stringify(state));
|
|
405
|
+
} else {
|
|
406
|
+
await pdriver._insertKV(internals.migrationState, name, JSON.stringify(state));
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Reverts the steps run before the first migration of the release label,
|
|
412
|
+
* once its last migration is reverted.
|
|
413
|
+
*/
|
|
414
|
+
const revert = async (walker, label) => {
|
|
415
|
+
const internals = walker.internals;
|
|
416
|
+
const name = PREFIX + label;
|
|
417
|
+
const state = await walker._pdriver._getKV(internals.migrationState, name);
|
|
418
|
+
|
|
419
|
+
if (!state || internals.dryRun) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
log.info(`[release] reverting the steps of release ${label}`);
|
|
424
|
+
internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
|
|
425
|
+
await require('./executors/versioned/v2').down(
|
|
426
|
+
unrecorded(walker),
|
|
427
|
+
walker.driver,
|
|
428
|
+
releaseFile(label, async () => {})
|
|
429
|
+
);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Refuses reverting a release whose steps can not be reverted, rows purged.
|
|
434
|
+
*/
|
|
435
|
+
const assertRevertible = async (walker, label) => {
|
|
436
|
+
const row = await walker._pdriver._getKV(
|
|
437
|
+
walker.internals.migrationState,
|
|
438
|
+
PREFIX + label
|
|
439
|
+
);
|
|
440
|
+
const steps = row ? JSON.parse(row.value).s || [] : [];
|
|
441
|
+
const irreversible = steps.filter(entry => entry.t === 3);
|
|
442
|
+
|
|
443
|
+
if (irreversible.length) {
|
|
444
|
+
const err = new Error(
|
|
445
|
+
`Release ${label} can not be reverted, it ${irreversible
|
|
446
|
+
.map(entry => `purged "${entry.c[0]}"`)
|
|
447
|
+
.join(', ')} before its first migration.`
|
|
448
|
+
);
|
|
449
|
+
err.irreversible = true;
|
|
450
|
+
throw err;
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Warns about what is due for dropping with drop 'manual'.
|
|
456
|
+
*/
|
|
457
|
+
const warnDuePurges = async (pdriver, internals, index, releaseIndex) => {
|
|
458
|
+
(await duePurges(pdriver, internals, index, releaseIndex))
|
|
459
|
+
.filter(e => e.drop === 'manual')
|
|
460
|
+
.forEach(e =>
|
|
461
|
+
log.warn(
|
|
462
|
+
`[release] the rows of "${e.t}" deleted in soft mode by ` +
|
|
463
|
+
`${e.mark.slice(5)} are due for purging, deleted ${e.age} releases ` +
|
|
464
|
+
`ago. Purge them in a dml migration with db.purge(${JSON.stringify(e.t)}, ` +
|
|
465
|
+
`${JSON.stringify(e.mark.slice(5, e.mark.lastIndexOf('#')))}).`
|
|
466
|
+
)
|
|
467
|
+
);
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const warnDue = (internals, index, releaseIndex) => {
|
|
471
|
+
list(internals, index, releaseIndex).forEach(item => {
|
|
472
|
+
const { releases, drop } = settings(internals, item.entry);
|
|
473
|
+
|
|
474
|
+
if (drop === 'manual' && item.age >= releases) {
|
|
475
|
+
log.warn(
|
|
476
|
+
`[release] the deprecated ${describe(item)} is due for dropping, ` +
|
|
477
|
+
`deprecated ${item.age} releases ago. Drop it in a migration with ` +
|
|
478
|
+
`db.dropDeprecated(${JSON.stringify(item.t)}${
|
|
479
|
+
item.c ? `, ${JSON.stringify(item.c)}` : ''
|
|
480
|
+
}).`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
module.exports = {
|
|
487
|
+
PREFIX,
|
|
488
|
+
PURGES,
|
|
489
|
+
schedulePurge,
|
|
490
|
+
forgetPurges,
|
|
491
|
+
warnDuePurges,
|
|
492
|
+
releases,
|
|
493
|
+
entry,
|
|
494
|
+
list,
|
|
495
|
+
settings,
|
|
496
|
+
drop,
|
|
497
|
+
start,
|
|
498
|
+
revert,
|
|
499
|
+
assertRevertible,
|
|
500
|
+
warnDue,
|
|
501
|
+
columnsOf
|
|
502
|
+
};
|
package/lib/state.js
CHANGED
|
@@ -97,7 +97,7 @@ const createConcurrently = async create => {
|
|
|
97
97
|
* The stored schema starts as '{}', keep the structure the walker expects.
|
|
98
98
|
*/
|
|
99
99
|
const parseSchema = value =>
|
|
100
|
-
Object.assign({ i: {}, c: {}, f: {}, e: {} }, JSON.parse(value));
|
|
100
|
+
Object.assign({ i: {}, c: {}, f: {}, e: {}, d: {} }, JSON.parse(value));
|
|
101
101
|
|
|
102
102
|
const stopHeartbeat = () => {
|
|
103
103
|
if (heartbeat) {
|
|
@@ -155,6 +155,12 @@ const claim = (row, driver, internals) => {
|
|
|
155
155
|
module.exports = {
|
|
156
156
|
createConcurrently,
|
|
157
157
|
|
|
158
|
+
// the id of this process in the lock, the holder of a lock row, see Jobs
|
|
159
|
+
ID,
|
|
160
|
+
holder,
|
|
161
|
+
lockOptions,
|
|
162
|
+
readLock: read,
|
|
163
|
+
|
|
158
164
|
// for tests, simulates a process which died while holding the lock
|
|
159
165
|
__stopHeartbeat: stopHeartbeat,
|
|
160
166
|
|
package/lib/walker.js
CHANGED
|
@@ -7,6 +7,7 @@ const Promise = require('bluebird');
|
|
|
7
7
|
const File = require('./file.js');
|
|
8
8
|
const Dml = require('./dml');
|
|
9
9
|
const Jobs = require('./jobs');
|
|
10
|
+
const Release = require('./release');
|
|
10
11
|
|
|
11
12
|
const INTERFACES = {
|
|
12
13
|
migration: require('./interface/migratorInterface.js'),
|
|
@@ -39,7 +40,7 @@ const Walker = function (driver, directory, mode, intern, prefix, opts = {}) {
|
|
|
39
40
|
* f: foreignKey
|
|
40
41
|
* e: extra items for the schema (like ENUM types)
|
|
41
42
|
*/
|
|
42
|
-
this.internals.schema = { i: {}, c: {}, f: {}, e: {} };
|
|
43
|
+
this.internals.schema = { i: {}, c: {}, f: {}, e: {}, d: {} };
|
|
43
44
|
/**
|
|
44
45
|
* s: commands
|
|
45
46
|
* i: index
|
|
@@ -127,10 +128,20 @@ Walker.prototype = {
|
|
|
127
128
|
return run(toRun);
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
let paused = false;
|
|
130
132
|
try {
|
|
133
|
+
// migrations take precedence over the jobs of background migrations
|
|
134
|
+
await Jobs.pause(this._pdriver, this.internals, State.ID);
|
|
135
|
+
paused = true;
|
|
131
136
|
await State.reloadSchema(this._pdriver, this.internals);
|
|
132
137
|
return await run(await determine());
|
|
133
138
|
} finally {
|
|
139
|
+
if (paused) {
|
|
140
|
+
await Jobs.resume(this._pdriver, this.internals, State.ID).catch(err =>
|
|
141
|
+
log.warn(`[jobs] could not resume the jobs: ${err.message}`)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
134
145
|
await State.release(this._pdriver, this.internals);
|
|
135
146
|
}
|
|
136
147
|
},
|
|
@@ -158,6 +169,52 @@ Walker.prototype = {
|
|
|
158
169
|
});
|
|
159
170
|
},
|
|
160
171
|
|
|
172
|
+
/**
|
|
173
|
+
* The migrations of the jobs, newest first.
|
|
174
|
+
*/
|
|
175
|
+
_jobFiles: async function () {
|
|
176
|
+
if (this.prefix !== 'migration' || !this._pdriver || typeof this._pdriver._getKV !== 'function') {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const jobs = await Jobs.list(this._pdriver, this.internals);
|
|
181
|
+
const names = Object.keys(jobs);
|
|
182
|
+
if (!names.length) {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const files = await File.loadFromFileystem(this.directory, this.prefix, this.internals);
|
|
187
|
+
return names
|
|
188
|
+
.sort()
|
|
189
|
+
.reverse()
|
|
190
|
+
.map(name => {
|
|
191
|
+
const file = files.find(f => f.name === name);
|
|
192
|
+
if (!file) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`The migration of the background job "${name}" is missing, it ` +
|
|
195
|
+
'can not be reverted'
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
file.job = true;
|
|
200
|
+
return file;
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Reverts what the job of a background migration did so far, it was
|
|
206
|
+
* paused by taking the lock, and forgets the job.
|
|
207
|
+
*/
|
|
208
|
+
_revertJob: async function (file) {
|
|
209
|
+
log.info(`${this.title}reverting the background job of ${file.name}`);
|
|
210
|
+
this.internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
|
|
211
|
+
|
|
212
|
+
if (!this.internals.dryRun) {
|
|
213
|
+
await Dml.down(this, this.driver, file);
|
|
214
|
+
await Jobs.remove(this._pdriver, this.internals, file.name);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
|
|
161
218
|
_registerJob: async function (file) {
|
|
162
219
|
Dml.checkType(file);
|
|
163
220
|
|
|
@@ -244,14 +301,18 @@ Walker.prototype = {
|
|
|
244
301
|
this.internals
|
|
245
302
|
)
|
|
246
303
|
])
|
|
247
|
-
.spread((allFiles, completedFiles) =>
|
|
248
|
-
|
|
249
|
-
|
|
304
|
+
.spread((allFiles, completedFiles) => {
|
|
305
|
+
this._releases(allFiles, completedFiles);
|
|
306
|
+
return dbmUtil.filterUp(allFiles, completedFiles, partialName, count);
|
|
307
|
+
})
|
|
250
308
|
.then(toRun => this._withoutJobs(toRun));
|
|
251
309
|
|
|
252
310
|
if (this.internals.check) {
|
|
253
311
|
return determine()
|
|
254
|
-
.then(toRun =>
|
|
312
|
+
.then(async toRun => {
|
|
313
|
+
await this._warnDue();
|
|
314
|
+
return this._checkResult(toRun);
|
|
315
|
+
})
|
|
255
316
|
.nodeify(callback);
|
|
256
317
|
}
|
|
257
318
|
|
|
@@ -261,8 +322,10 @@ Walker.prototype = {
|
|
|
261
322
|
log.info(this.title + 'Nothing to run');
|
|
262
323
|
}
|
|
263
324
|
|
|
264
|
-
return Promise.each(toRun, file => {
|
|
325
|
+
return Promise.each(toRun, async file => {
|
|
265
326
|
log.verbose(this.title + 'preparing to run up:', file.name);
|
|
327
|
+
await this._enterRelease(file).catch(failedIn(file));
|
|
328
|
+
|
|
266
329
|
if (Dml.isBackground(file)) {
|
|
267
330
|
return this._registerJob(file).catch(failedIn(file));
|
|
268
331
|
}
|
|
@@ -277,11 +340,94 @@ Walker.prototype = {
|
|
|
277
340
|
file
|
|
278
341
|
)
|
|
279
342
|
).catch(failedIn(file));
|
|
343
|
+
}).then(async res => {
|
|
344
|
+
await this._warnDue();
|
|
345
|
+
return res;
|
|
280
346
|
});
|
|
281
347
|
})
|
|
282
348
|
).nodeify(callback);
|
|
283
349
|
},
|
|
284
350
|
|
|
351
|
+
/**
|
|
352
|
+
* The releases of the migrations, and the latest release migrated.
|
|
353
|
+
*/
|
|
354
|
+
_releases: function (allFiles, completedFiles) {
|
|
355
|
+
const release = Release.releases(allFiles);
|
|
356
|
+
this._release = release;
|
|
357
|
+
this._applied = completedFiles.reduce(
|
|
358
|
+
(max, file) => Math.max(max, (release.map[file.name] || { index: 0 }).index),
|
|
359
|
+
0
|
|
360
|
+
);
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Runs the steps due, before the first migration of a new release.
|
|
365
|
+
*/
|
|
366
|
+
_enterRelease: async function (file) {
|
|
367
|
+
const release = this._release || { map: {}, index: {} };
|
|
368
|
+
const r = release.map[file.name] || { label: null, index: 0 };
|
|
369
|
+
|
|
370
|
+
if (r.index > this._applied) {
|
|
371
|
+
this.internals.releaseIndex = release.index;
|
|
372
|
+
await Release.start(this, r.label, r.index, release.index);
|
|
373
|
+
this._applied = r.index;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
this.internals.releaseLabel = r.label;
|
|
377
|
+
this.internals.releaseIndex = release.index;
|
|
378
|
+
this.internals.releaseCurrent = this._applied;
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The label of the release whose steps are reverted after reverting file,
|
|
383
|
+
* its last migration. Refuses before reverting anything, if the steps of
|
|
384
|
+
* the release can not be reverted.
|
|
385
|
+
*/
|
|
386
|
+
_leavingRelease: async function (file) {
|
|
387
|
+
const release = this._release;
|
|
388
|
+
const r = release && release.map[file.name];
|
|
389
|
+
|
|
390
|
+
if (!r || !r.label) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const completed = await File.loadFromDatabase(
|
|
395
|
+
this.directory,
|
|
396
|
+
this.prefix,
|
|
397
|
+
this._driver,
|
|
398
|
+
this.internals
|
|
399
|
+
);
|
|
400
|
+
const remaining = completed
|
|
401
|
+
.filter(f => f.name !== file.name)
|
|
402
|
+
.reduce(
|
|
403
|
+
(max, f) => Math.max(max, (release.map[f.name] || { index: 0 }).index),
|
|
404
|
+
0
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
if (remaining >= r.index) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
await Release.assertRevertible(this, r.label);
|
|
412
|
+
return r.label;
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
_warnDue: async function () {
|
|
416
|
+
if (!this._release) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
Release.warnDue(this.internals, this._applied, this._release.index);
|
|
421
|
+
if (this._pdriver && typeof this._pdriver._getKV === 'function') {
|
|
422
|
+
await Release.warnDuePurges(
|
|
423
|
+
this._pdriver,
|
|
424
|
+
this.internals,
|
|
425
|
+
this._applied,
|
|
426
|
+
this._release.index
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
|
|
285
431
|
down: function (options, callback) {
|
|
286
432
|
const partialName = options.destination;
|
|
287
433
|
const count = options.count;
|
|
@@ -291,9 +437,28 @@ Walker.prototype = {
|
|
|
291
437
|
this.prefix,
|
|
292
438
|
this._driver,
|
|
293
439
|
this.internals
|
|
294
|
-
).then(completedFiles =>
|
|
295
|
-
|
|
296
|
-
|
|
440
|
+
).then(async completedFiles => {
|
|
441
|
+
// the releases, from the migrations still there
|
|
442
|
+
const allFiles = await File.loadFromFileystem(
|
|
443
|
+
this.directory,
|
|
444
|
+
this.prefix,
|
|
445
|
+
this.internals
|
|
446
|
+
).catch(err => {
|
|
447
|
+
if (err.code === 'ENOENT') {
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
throw err;
|
|
452
|
+
});
|
|
453
|
+
this._releases(allFiles, completedFiles);
|
|
454
|
+
|
|
455
|
+
return dbmUtil.filterDown(
|
|
456
|
+
// jobs of background migrations are the latest, still running
|
|
457
|
+
(await this._jobFiles()).concat(completedFiles),
|
|
458
|
+
partialName,
|
|
459
|
+
count
|
|
460
|
+
);
|
|
461
|
+
});
|
|
297
462
|
|
|
298
463
|
if (this.internals.check) {
|
|
299
464
|
return determine()
|
|
@@ -309,16 +474,20 @@ Walker.prototype = {
|
|
|
309
474
|
|
|
310
475
|
return Promise.each(toRun, file => {
|
|
311
476
|
log.verbose(this.title + 'preparing to run down:', file.name);
|
|
477
|
+
if (file.job) {
|
|
478
|
+
return this._revertJob(file).catch(failedIn(file));
|
|
479
|
+
}
|
|
480
|
+
|
|
312
481
|
const _meta = file.get()._meta || {};
|
|
313
482
|
const version = _meta.version || 1;
|
|
314
483
|
this.internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
|
|
315
|
-
return
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
484
|
+
return this._leavingRelease(file)
|
|
485
|
+
.then(leaving =>
|
|
486
|
+
require(`./executors/versioned/v${version}`)
|
|
487
|
+
.down(this, this.driver, file)
|
|
488
|
+
.then(() => leaving && Release.revert(this, leaving))
|
|
320
489
|
)
|
|
321
|
-
|
|
490
|
+
.catch(failedIn(file));
|
|
322
491
|
});
|
|
323
492
|
})
|
|
324
493
|
).nodeify(callback);
|
package/lib/work.js
CHANGED
|
@@ -8,6 +8,7 @@ const State = require('./state');
|
|
|
8
8
|
const File = require('./file');
|
|
9
9
|
const Jobs = require('./jobs');
|
|
10
10
|
const Dml = require('./dml');
|
|
11
|
+
const Release = require('./release');
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Runs the jobs of background migrations, see Jobs.
|
|
@@ -85,7 +86,7 @@ const hashFile = file =>
|
|
|
85
86
|
.update(require('fs').readFileSync(file.path))
|
|
86
87
|
.digest('hex');
|
|
87
88
|
|
|
88
|
-
const runJob = async (walker, name, id, options, stopping, result) => {
|
|
89
|
+
const runJob = async (walker, name, id, options, stopping, result, seen) => {
|
|
89
90
|
const internals = walker.internals;
|
|
90
91
|
const pdriver = walker._pdriver;
|
|
91
92
|
let lost = false;
|
|
@@ -100,6 +101,8 @@ const runJob = async (walker, name, id, options, stopping, result) => {
|
|
|
100
101
|
internals
|
|
101
102
|
);
|
|
102
103
|
const file = files.find(f => f.name === name);
|
|
104
|
+
// the release of the migration, a soft delete schedules its purge by it
|
|
105
|
+
internals.releaseLabel = (Release.releases(files).map[name] || {}).label || null;
|
|
103
106
|
|
|
104
107
|
if (!file) {
|
|
105
108
|
await update(job => {
|
|
@@ -153,7 +156,15 @@ const runJob = async (walker, name, id, options, stopping, result) => {
|
|
|
153
156
|
{
|
|
154
157
|
batch: options.batch,
|
|
155
158
|
pause: options.pause,
|
|
156
|
-
stopping: () => lost || stopping()
|
|
159
|
+
stopping: () => lost || stopping(),
|
|
160
|
+
yielding: async () => {
|
|
161
|
+
if (await Jobs.paused(pdriver, internals, seen)) {
|
|
162
|
+
log.info(`[jobs] ${name} pauses for migrations`);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
157
168
|
},
|
|
158
169
|
{
|
|
159
170
|
rollingBack: () => update(job => { job.rb = 1; }),
|
|
@@ -227,8 +238,14 @@ module.exports = function executeWork (internals, config, opts = {}) {
|
|
|
227
238
|
options.timeout
|
|
228
239
|
);
|
|
229
240
|
|
|
241
|
+
if (name === Jobs.WAIT) {
|
|
242
|
+
// paused by migrations, the jobs continue afterwards
|
|
243
|
+
await delay(options.interval);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
230
247
|
if (name) {
|
|
231
|
-
await runJob(walker, name, id, options, stopping, result);
|
|
248
|
+
await runJob(walker, name, id, options, stopping, result, seen);
|
|
232
249
|
continue;
|
|
233
250
|
}
|
|
234
251
|
|