@alexify/migronaut 1.0.0 → 2.1.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/CHANGELOG.md +409 -1
- package/README.md +248 -24
- package/bin/migronaut.js +11 -3
- package/bullmq.d.ts +845 -0
- package/bullmq.js +1 -0
- package/index.d.ts +757 -29
- package/migronaut.schema.json +191 -1
- package/package.json +27 -6
- package/src/bullmq/index.js +55 -0
- package/src/bullmq/jobs.js +454 -0
- package/src/bullmq/processor.js +608 -0
- package/src/bullmq/producer.js +424 -0
- package/src/bullmq/service.js +653 -0
- package/src/bullmq/wait.js +124 -0
- package/src/cli/args.js +12 -2
- package/src/cli/commands/baseline.js +45 -0
- package/src/cli/commands/converge.js +160 -0
- package/src/cli/commands/down.js +2 -0
- package/src/cli/commands/lock.js +2 -1
- package/src/cli/commands/redo.js +8 -1
- package/src/cli/commands/unlock.js +12 -2
- package/src/cli/commands/up.js +14 -1
- package/src/cli/exit-codes.js +10 -2
- package/src/cli/index.js +4 -0
- package/src/cli/shared.js +29 -7
- package/src/cli/table.js +105 -0
- package/src/core/audit.js +17 -3
- package/src/core/baseline.js +80 -0
- package/src/core/changelog.js +140 -24
- package/src/core/collections.js +372 -0
- package/src/core/config.js +125 -27
- package/src/core/converge-log.js +47 -0
- package/src/core/converge-plan.js +483 -0
- package/src/core/converge.js +867 -0
- package/src/core/import-runner.js +34 -6
- package/src/core/import.js +14 -7
- package/src/core/index-spec.js +496 -0
- package/src/core/lock-wait.js +260 -0
- package/src/core/lock.js +71 -20
- package/src/core/migrator.js +805 -304
- package/src/core/options.js +251 -0
- package/src/core/run-recorder.js +157 -0
- package/src/core/run.js +71 -71
- package/src/core/runner.js +70 -20
- package/src/core/sequence.js +134 -0
- package/src/errors/index.js +71 -1
- package/src/index.js +16 -0
- package/src/utils/actor.js +48 -0
- package/src/utils/canonical.js +179 -0
- package/src/utils/collection-name.js +21 -0
- package/src/utils/error.js +18 -1
- package/src/utils/id.js +77 -0
- package/src/utils/loader.js +39 -21
- package/src/utils/logger.js +30 -12
- package/src/utils/migration-name.js +32 -0
- package/src/utils/redact.js +57 -4
- package/src/utils/sanitize.js +8 -3
- package/src/utils/telemetry.js +393 -0
- package/src/utils/template.js +60 -12
package/src/cli/table.js
CHANGED
|
@@ -247,6 +247,109 @@ function renderImportTable(rows) {
|
|
|
247
247
|
return renderTable(head, cells);
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
/** Longest index or collection name rendered before it is ellipsized */
|
|
251
|
+
const MAX_NAME_WIDTH = 48;
|
|
252
|
+
|
|
253
|
+
/** Longest converge detail rendered before it is ellipsized */
|
|
254
|
+
const MAX_DETAIL_WIDTH = 72;
|
|
255
|
+
|
|
256
|
+
/** Render a converge action cell: what changes stands out, what does not recedes */
|
|
257
|
+
function convergeActionCell(colors, action) {
|
|
258
|
+
switch (action) {
|
|
259
|
+
case 'create':
|
|
260
|
+
return colors.green(action);
|
|
261
|
+
case 'modify':
|
|
262
|
+
return colors.cyan(action);
|
|
263
|
+
case 'recreate':
|
|
264
|
+
case 'drop':
|
|
265
|
+
return colors.yellow(action);
|
|
266
|
+
case 'conflict':
|
|
267
|
+
return colors.red(action);
|
|
268
|
+
default:
|
|
269
|
+
return colors.dim(action);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The detail column: what differs, or why the row is what it is */
|
|
274
|
+
function convergeDetail(action) {
|
|
275
|
+
if (action.reason === 'name' && action.liveName !== undefined) {
|
|
276
|
+
return `renamed from "${action.liveName}"`;
|
|
277
|
+
}
|
|
278
|
+
return action.reason ?? '';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Render a converge plan (or result) as a table plus a one-line summary.
|
|
283
|
+
* Rows that need nothing are folded into the summary unless `all` — a large
|
|
284
|
+
* schema would otherwise bury its two changes under fifty "unchanged" lines.
|
|
285
|
+
*/
|
|
286
|
+
function renderConvergeTable(result, { all = false } = {}) {
|
|
287
|
+
const colors = palette();
|
|
288
|
+
const cells = [];
|
|
289
|
+
const counts = { change: 0, destructive: 0, conflict: 0, keep: 0, unchanged: 0, touched: 0 };
|
|
290
|
+
for (const collection of result.collections) {
|
|
291
|
+
let changes = 0;
|
|
292
|
+
for (const action of collection.actions) {
|
|
293
|
+
if (action.action === 'unchanged') counts.unchanged += 1;
|
|
294
|
+
else if (action.action === 'keep') counts.keep += 1;
|
|
295
|
+
else if (action.action === 'conflict') counts.conflict += 1;
|
|
296
|
+
else changes += 1;
|
|
297
|
+
if (action.target === 'index' && (action.action === 'drop' || action.action === 'recreate')) {
|
|
298
|
+
counts.destructive += 1;
|
|
299
|
+
}
|
|
300
|
+
if (action.action === 'unchanged' && !all) continue;
|
|
301
|
+
cells.push([
|
|
302
|
+
truncate(sanitize(collection.name), MAX_NAME_WIDTH),
|
|
303
|
+
action.target,
|
|
304
|
+
action.target === 'index' ? truncate(sanitize(action.name), MAX_NAME_WIDTH) : '',
|
|
305
|
+
convergeActionCell(colors, action.action),
|
|
306
|
+
truncate(sanitize(convergeDetail(action)), MAX_DETAIL_WIDTH),
|
|
307
|
+
]);
|
|
308
|
+
}
|
|
309
|
+
counts.change += changes;
|
|
310
|
+
if (changes > 0) counts.touched += 1;
|
|
311
|
+
}
|
|
312
|
+
const collections = result.collections.length;
|
|
313
|
+
let summary;
|
|
314
|
+
if (counts.change === 0 && counts.conflict === 0) {
|
|
315
|
+
summary = `✔ ${collections} collection(s) match their declarations`;
|
|
316
|
+
} else {
|
|
317
|
+
const verb = result.dryRun ? 'Would make' : 'Made';
|
|
318
|
+
summary =
|
|
319
|
+
`${verb} ${counts.change} change(s) in ${counts.touched} of ${collections} ` +
|
|
320
|
+
'collection(s)';
|
|
321
|
+
}
|
|
322
|
+
const parts = [summary];
|
|
323
|
+
if (counts.destructive > 0) parts.push(`${counts.destructive} drop/rebuild`);
|
|
324
|
+
if (counts.conflict > 0) parts.push(colors.red(`${counts.conflict} conflict(s)`));
|
|
325
|
+
if (counts.keep > 0) parts.push(`${counts.keep} undeclared index(es) kept`);
|
|
326
|
+
if (counts.unchanged > 0 && !all) parts.push(`${counts.unchanged} unchanged`);
|
|
327
|
+
const line = parts.join(' · ');
|
|
328
|
+
if (cells.length === 0) return line;
|
|
329
|
+
return `${renderTable(['Collection', 'Target', 'Index', 'Action', 'Detail'], cells)}\n${line}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Render the converge history: one line per converge that changed something
|
|
334
|
+
* or failed, newest first — the audit view (`migronaut converge --history`).
|
|
335
|
+
*/
|
|
336
|
+
function renderConvergeHistory(entries) {
|
|
337
|
+
if (entries.length === 0) return 'No converge has changed anything yet';
|
|
338
|
+
const colors = palette();
|
|
339
|
+
const cells = entries.map((entry) => [
|
|
340
|
+
entry.startedAt instanceof Date ? entry.startedAt.toISOString() : String(entry.startedAt),
|
|
341
|
+
entry.trigger,
|
|
342
|
+
entry.success ? colors.green('ok') : colors.red('failed'),
|
|
343
|
+
String(entry.changed),
|
|
344
|
+
truncate(sanitize(entry.requestedBy ?? entry.executedBy ?? ''), MAX_NAME_WIDTH),
|
|
345
|
+
truncate(
|
|
346
|
+
sanitize(entry.reason ?? (entry.success ? '' : (entry.error ?? ''))),
|
|
347
|
+
MAX_DETAIL_WIDTH,
|
|
348
|
+
),
|
|
349
|
+
]);
|
|
350
|
+
return renderTable(['When', 'Trigger', 'Result', 'Changes', 'Who', 'Why'], cells);
|
|
351
|
+
}
|
|
352
|
+
|
|
250
353
|
module.exports = {
|
|
251
354
|
charWidth,
|
|
252
355
|
sanitize,
|
|
@@ -256,4 +359,6 @@ module.exports = {
|
|
|
256
359
|
renderStatusTable,
|
|
257
360
|
renderImportTable,
|
|
258
361
|
renderRowsOrEmpty,
|
|
362
|
+
renderConvergeTable,
|
|
363
|
+
renderConvergeHistory,
|
|
259
364
|
};
|
package/src/core/audit.js
CHANGED
|
@@ -99,15 +99,20 @@ async function runAudit(deps) {
|
|
|
99
99
|
record('lock', 'warn', `Could not read the lock: ${errorText(error)}`);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// 6. Checksum drift
|
|
102
|
+
// 6. Checksum drift, missing files, pending count and ordering, from the
|
|
103
|
+
// same rows `status` renders.
|
|
103
104
|
try {
|
|
104
105
|
const rows = await deps.status();
|
|
105
|
-
// One pass over the rows collects
|
|
106
|
+
// One pass over the rows collects every signal.
|
|
106
107
|
const drifted = [];
|
|
108
|
+
const outOfOrder = [];
|
|
107
109
|
let pending = 0;
|
|
108
110
|
for (const row of rows) {
|
|
109
111
|
if (row.checksumOk === false) drifted.push(row.file);
|
|
110
|
-
|
|
112
|
+
// A recorded failed attempt still counts as pending work — the file
|
|
113
|
+
// will be retried by the next `up`.
|
|
114
|
+
if (row.status === 'pending' || row.status === 'failed') pending += 1;
|
|
115
|
+
if (row.outOfOrder) outOfOrder.push(row.file);
|
|
111
116
|
}
|
|
112
117
|
if (drifted.length > 0) {
|
|
113
118
|
record('checksums', 'fail', `Edited after being applied: ${drifted.join(', ')}`);
|
|
@@ -115,6 +120,15 @@ async function runAudit(deps) {
|
|
|
115
120
|
record('checksums', 'pass', 'No drift among applied migrations');
|
|
116
121
|
}
|
|
117
122
|
record('pending', pending === 0 ? 'pass' : 'warn', `${pending} pending migration(s)`);
|
|
123
|
+
if (outOfOrder.length > 0) {
|
|
124
|
+
record(
|
|
125
|
+
'ordering',
|
|
126
|
+
'warn',
|
|
127
|
+
`Pending but older than the newest applied migration: ${outOfOrder.join(', ')}`,
|
|
128
|
+
);
|
|
129
|
+
} else {
|
|
130
|
+
record('ordering', 'pass', 'No out-of-order pending migrations');
|
|
131
|
+
}
|
|
118
132
|
} catch (error) {
|
|
119
133
|
record('checksums', 'warn', `Could not read status: ${errorText(error)}`);
|
|
120
134
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const { computeChecksum } = require('../utils/checksum.js');
|
|
2
|
+
const { mapLimit } = require('../utils/concurrency.js');
|
|
3
|
+
|
|
4
|
+
/** Simultaneous file hashes — same EMFILE bound as every other multi-file path */
|
|
5
|
+
const FS_CONCURRENCY = 16;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Adopt an existing database with no prior migration tool: mark migration
|
|
9
|
+
* files on disk as applied — checksums taken from disk, one shared batch,
|
|
10
|
+
* `origin: 'baseline'` — without executing anything. The database is assumed
|
|
11
|
+
* to already be in the state those files describe (they were applied by hand,
|
|
12
|
+
* by a home-grown script, or reconstructed after the fact).
|
|
13
|
+
*
|
|
14
|
+
* Forward-only: baselined records were never executed by migronaut, so
|
|
15
|
+
* `down`/`redo` refuse them (the same `origin` preflight import uses).
|
|
16
|
+
* Idempotent: already-applied names are skipped, so a partial baseline can
|
|
17
|
+
* simply be re-run.
|
|
18
|
+
*
|
|
19
|
+
* Pure orchestration over capabilities the MigratorKit injects (`deps`):
|
|
20
|
+
* `{db, changelog, logger, fields, filepath, listMigrationFiles, nextBatch,
|
|
21
|
+
* truncateAtTarget, environment, executedBy, runId, assertNotAborted}`.
|
|
22
|
+
*/
|
|
23
|
+
async function runBaseline(deps, options, signal) {
|
|
24
|
+
const { db, changelog, logger } = deps;
|
|
25
|
+
|
|
26
|
+
const files = await deps.listMigrationFiles();
|
|
27
|
+
const applied = new Set(await changelog.getAppliedNames(db));
|
|
28
|
+
let targets = [];
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
if (!applied.has(file)) targets.push(file);
|
|
31
|
+
}
|
|
32
|
+
if (options.to !== undefined) {
|
|
33
|
+
targets = deps.truncateAtTarget(targets, files, options.to);
|
|
34
|
+
}
|
|
35
|
+
const skipped = files.length - targets.length;
|
|
36
|
+
|
|
37
|
+
if (targets.length === 0) {
|
|
38
|
+
logger.info('Nothing to baseline', deps.fields({ skipped }));
|
|
39
|
+
return { baselined: [], skipped, batch: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Checksums come from the files as they are NOW — that is the contract: the
|
|
43
|
+
// baseline asserts "the database already matches these exact files", and
|
|
44
|
+
// later drift checks police edits against this snapshot.
|
|
45
|
+
const checksums = await mapLimit(targets, FS_CONCURRENCY, (name) =>
|
|
46
|
+
computeChecksum(deps.filepath(name)),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const batch = await deps.nextBatch();
|
|
50
|
+
const records = new Array(targets.length);
|
|
51
|
+
for (let i = 0; i < targets.length; i++) {
|
|
52
|
+
records[i] = {
|
|
53
|
+
name: targets[i],
|
|
54
|
+
batch,
|
|
55
|
+
status: 'applied',
|
|
56
|
+
// No appliedAt: the changelog stamps it in server time.
|
|
57
|
+
duration: 0,
|
|
58
|
+
checksum: checksums[i],
|
|
59
|
+
environment: deps.environment(),
|
|
60
|
+
executedBy: deps.executedBy(),
|
|
61
|
+
origin: 'baseline',
|
|
62
|
+
...(deps.runId() ? { runId: deps.runId() } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One write, checked against the abort signal first: a baseline is all
|
|
67
|
+
// bookkeeping, so there is no safe partial point worth resuming from — and
|
|
68
|
+
// markAppliedBulk's upsert-by-name makes a re-run after any failure
|
|
69
|
+
// idempotent anyway.
|
|
70
|
+
deps.assertNotAborted(signal);
|
|
71
|
+
await changelog.markAppliedBulk(db, records);
|
|
72
|
+
|
|
73
|
+
logger.info(
|
|
74
|
+
`✔ Baselined ${records.length} migration(s) as applied (batch ${batch})`,
|
|
75
|
+
deps.fields({ baselined: records.length, skipped, batch }),
|
|
76
|
+
);
|
|
77
|
+
return { baselined: targets, skipped, batch };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { runBaseline };
|
package/src/core/changelog.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { actorFields } = require('../utils/actor.js');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Reads and writes migration records in the changelog collection
|
|
3
5
|
* (`_migronaut_migrations` by default).
|
|
@@ -90,14 +92,40 @@ class Changelog {
|
|
|
90
92
|
return names;
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Which of `names` carry a `'failed'` trace — what tells a migration that
|
|
97
|
+
* failed (the line is stopped) from one that simply has not run yet (it may
|
|
98
|
+
* be in flight elsewhere). Served by the `status_name` index.
|
|
99
|
+
*/
|
|
100
|
+
async getFailedNames(db, names) {
|
|
101
|
+
if (names.length === 0) return [];
|
|
102
|
+
const docs = await this.#coll(db)
|
|
103
|
+
.find({ status: 'failed', name: { $in: names } })
|
|
104
|
+
.sort({ name: 1 })
|
|
105
|
+
.project({ name: 1, _id: 0 })
|
|
106
|
+
.toArray();
|
|
107
|
+
const failed = [];
|
|
108
|
+
for (const doc of docs) failed.push(doc.name);
|
|
109
|
+
return failed;
|
|
110
|
+
}
|
|
111
|
+
|
|
93
112
|
/** Return a single record by migration name, or null */
|
|
94
113
|
async getByName(db, name) {
|
|
95
114
|
return this.#coll(db).findOne({ name });
|
|
96
115
|
}
|
|
97
116
|
|
|
98
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Every currently-applied record's `{name, checksum}`, sorted by name
|
|
119
|
+
* ascending — exactly what the strict bulk drift check consumes (the name
|
|
120
|
+
* doubles as the applied-set key). Projected like the module's other reads;
|
|
121
|
+
* widen the projection if a new caller needs more.
|
|
122
|
+
*/
|
|
99
123
|
async getApplied(db) {
|
|
100
|
-
return this.#coll(db)
|
|
124
|
+
return this.#coll(db)
|
|
125
|
+
.find({ status: 'applied' })
|
|
126
|
+
.sort({ name: 1 })
|
|
127
|
+
.project({ _id: 0, name: 1, checksum: 1 })
|
|
128
|
+
.toArray();
|
|
101
129
|
}
|
|
102
130
|
|
|
103
131
|
/**
|
|
@@ -126,6 +154,29 @@ class Changelog {
|
|
|
126
154
|
.toArray();
|
|
127
155
|
}
|
|
128
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Applied records that were applied *after* `record`, newest first — the
|
|
159
|
+
* revert order `down --steps` uses (`appliedAt`, name-desc tiebreak). An
|
|
160
|
+
* `ordered` rollback refuses while any exist: undoing effects is only safe in
|
|
161
|
+
* reverse of the order they were made. A record with no `appliedAt` (a
|
|
162
|
+
* hand-edited or legacy document) treats every other applied record as
|
|
163
|
+
* newer — the conservative answer.
|
|
164
|
+
*/
|
|
165
|
+
async getAppliedNewerThan(db, { appliedAt, name }) {
|
|
166
|
+
const filter =
|
|
167
|
+
appliedAt instanceof Date
|
|
168
|
+
? {
|
|
169
|
+
status: 'applied',
|
|
170
|
+
$or: [{ appliedAt: { $gt: appliedAt } }, { appliedAt, name: { $gt: name } }],
|
|
171
|
+
}
|
|
172
|
+
: { status: 'applied', name: { $ne: name } };
|
|
173
|
+
return this.#coll(db)
|
|
174
|
+
.find(filter)
|
|
175
|
+
.sort({ appliedAt: -1, name: -1 })
|
|
176
|
+
.project({ _id: 0, name: 1, appliedAt: 1, batch: 1 })
|
|
177
|
+
.toArray();
|
|
178
|
+
}
|
|
179
|
+
|
|
129
180
|
/** Return the highest batch number among currently-applied migrations, or null */
|
|
130
181
|
async getLastBatch(db) {
|
|
131
182
|
const docs = await this.#coll(db)
|
|
@@ -157,29 +208,66 @@ class Changelog {
|
|
|
157
208
|
return this.#coll(db).find({ batch }).sort({ name: 1 }).toArray();
|
|
158
209
|
}
|
|
159
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The update document shared by markApplied and markAppliedBulk.
|
|
213
|
+
*
|
|
214
|
+
* `appliedAt` is stamped in **server time** (`$currentDate`) when the record
|
|
215
|
+
* does not carry one — the same clock discipline the lock's `$$NOW` uses:
|
|
216
|
+
* `redo` and `down --steps` sort by `appliedAt`, and a client-stamped value
|
|
217
|
+
* lets a skewed host mis-order the revert selection. An explicit `appliedAt`
|
|
218
|
+
* (import adopting a legacy changelog's historical timestamps) is written
|
|
219
|
+
* verbatim. `firstAppliedAt` is audit-only metadata, never sorted on, so its
|
|
220
|
+
* client-clock `$setOnInsert` fallback is acceptable ($setOnInsert cannot
|
|
221
|
+
* express server time).
|
|
222
|
+
*/
|
|
223
|
+
static #appliedUpdate(record) {
|
|
224
|
+
// `name` comes from the filter on insert, so it must not also appear in an
|
|
225
|
+
// update operator (MongoDB rejects the conflicting path).
|
|
226
|
+
const { name, appliedAt, ...fields } = record;
|
|
227
|
+
const update = {
|
|
228
|
+
$set: fields,
|
|
229
|
+
// A re-apply clears the stale revert marker (and who asked for the
|
|
230
|
+
// revert, and why) — and the failure trace a markFailed() from an earlier
|
|
231
|
+
// crashed attempt may have left.
|
|
232
|
+
$unset: {
|
|
233
|
+
revertedAt: '',
|
|
234
|
+
revertRequestedBy: '',
|
|
235
|
+
revertReason: '',
|
|
236
|
+
failedAt: '',
|
|
237
|
+
error: '',
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
// Who asked for this apply, and why — or nobody said: then the previous
|
|
241
|
+
// apply's answer must not linger as if it were this one's.
|
|
242
|
+
for (const key of ['requestedBy', 'reason']) {
|
|
243
|
+
if (fields[key] === undefined) update.$unset[key] = '';
|
|
244
|
+
}
|
|
245
|
+
if (appliedAt !== undefined) {
|
|
246
|
+
update.$set.appliedAt = appliedAt;
|
|
247
|
+
update.$setOnInsert = { firstAppliedAt: appliedAt };
|
|
248
|
+
} else {
|
|
249
|
+
update.$currentDate = { appliedAt: true };
|
|
250
|
+
update.$setOnInsert = { firstAppliedAt: new Date() };
|
|
251
|
+
}
|
|
252
|
+
return update;
|
|
253
|
+
}
|
|
254
|
+
|
|
160
255
|
/**
|
|
161
256
|
* Record a migration as applied. Upserts on `name` so re-applying a
|
|
162
257
|
* previously-reverted migration (e.g. via `redo`) cannot violate the unique
|
|
163
258
|
* index. Uses `$set` rather than a whole-document replace so audit fields
|
|
164
259
|
* survive a re-apply: `firstAppliedAt` is stamped once, and the stale
|
|
165
|
-
* `revertedAt` from an earlier rollback is cleared.
|
|
260
|
+
* `revertedAt` from an earlier rollback is cleared. `appliedAt` is stamped
|
|
261
|
+
* server-side unless the record carries one — see {@link #appliedUpdate}.
|
|
166
262
|
*
|
|
167
263
|
* Pass `session` to make this write part of the migration's transaction, so
|
|
168
264
|
* the migration and its changelog record commit together.
|
|
169
265
|
*/
|
|
170
266
|
async markApplied(db, record, session) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
{ name },
|
|
176
|
-
{
|
|
177
|
-
$set: fields,
|
|
178
|
-
$setOnInsert: { firstAppliedAt: record.appliedAt },
|
|
179
|
-
$unset: { revertedAt: '' },
|
|
180
|
-
},
|
|
181
|
-
{ upsert: true, ...(session ? { session } : {}) },
|
|
182
|
-
);
|
|
267
|
+
await this.#coll(db).updateOne({ name: record.name }, Changelog.#appliedUpdate(record), {
|
|
268
|
+
upsert: true,
|
|
269
|
+
...(session ? { session } : {}),
|
|
270
|
+
});
|
|
183
271
|
}
|
|
184
272
|
|
|
185
273
|
/**
|
|
@@ -193,15 +281,10 @@ class Changelog {
|
|
|
193
281
|
if (records.length === 0) return;
|
|
194
282
|
const ops = new Array(records.length);
|
|
195
283
|
for (let i = 0; i < records.length; i++) {
|
|
196
|
-
const { name, ...fields } = records[i];
|
|
197
284
|
ops[i] = {
|
|
198
285
|
updateOne: {
|
|
199
|
-
filter: { name },
|
|
200
|
-
update:
|
|
201
|
-
$set: fields,
|
|
202
|
-
$setOnInsert: { firstAppliedAt: records[i].appliedAt },
|
|
203
|
-
$unset: { revertedAt: '' },
|
|
204
|
-
},
|
|
286
|
+
filter: { name: records[i].name },
|
|
287
|
+
update: Changelog.#appliedUpdate(records[i]),
|
|
205
288
|
upsert: true,
|
|
206
289
|
},
|
|
207
290
|
};
|
|
@@ -219,13 +302,46 @@ class Changelog {
|
|
|
219
302
|
* was no longer `'applied'` (a concurrent peer got there first) — the caller
|
|
220
303
|
* decides what to do with that, since this module stays logger-free.
|
|
221
304
|
*/
|
|
222
|
-
async markReverted(db, name, session) {
|
|
305
|
+
async markReverted(db, name, session, actor = {}) {
|
|
306
|
+
const update = {
|
|
307
|
+
$set: {
|
|
308
|
+
status: 'reverted',
|
|
309
|
+
...actorFields(actor, 'revert'),
|
|
310
|
+
},
|
|
311
|
+
// Server time, like markApplied's appliedAt — one clock for the whole trail.
|
|
312
|
+
$currentDate: { revertedAt: true },
|
|
313
|
+
};
|
|
314
|
+
const unset = {};
|
|
315
|
+
if (actor.requestedBy === undefined) unset.revertRequestedBy = '';
|
|
316
|
+
if (actor.reason === undefined) unset.revertReason = '';
|
|
317
|
+
if (Object.keys(unset).length > 0) update.$unset = unset;
|
|
223
318
|
return this.#coll(db).updateOne(
|
|
224
319
|
{ name, status: 'applied' },
|
|
225
|
-
|
|
320
|
+
update,
|
|
226
321
|
session ? { session } : {},
|
|
227
322
|
);
|
|
228
323
|
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Best-effort trace of a failed `up` attempt, so a crash-and-restart leaves
|
|
327
|
+
* DB-side evidence of what was in flight ("did the crashed run start X?")
|
|
328
|
+
* instead of depending on process logs that may not have been captured.
|
|
329
|
+
*
|
|
330
|
+
* The filter excludes `'applied'` records: a forced re-run's failure must
|
|
331
|
+
* never demote a migration the changelog says is applied — the upsert then
|
|
332
|
+
* collides on the unique `name` index, and the caller swallows that. Every
|
|
333
|
+
* read path filters on `status: 'applied'`, so a `'failed'` record never
|
|
334
|
+
* changes what runs; the next successful apply overwrites it (and clears
|
|
335
|
+
* `failedAt`/`error`).
|
|
336
|
+
*/
|
|
337
|
+
async markFailed(db, record) {
|
|
338
|
+
const { name, ...fields } = record;
|
|
339
|
+
await this.#coll(db).updateOne(
|
|
340
|
+
{ name, status: { $ne: 'applied' } },
|
|
341
|
+
{ $set: { ...fields, status: 'failed' }, $currentDate: { failedAt: true } },
|
|
342
|
+
{ upsert: true },
|
|
343
|
+
);
|
|
344
|
+
}
|
|
229
345
|
}
|
|
230
346
|
|
|
231
347
|
module.exports = { Changelog };
|