@alexify/migronaut 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -1
- package/README.md +42 -20
- package/bin/migronaut.js +11 -3
- package/index.d.ts +126 -14
- package/migronaut.schema.json +9 -0
- package/package.json +7 -2
- package/src/cli/commands/baseline.js +45 -0
- package/src/cli/commands/unlock.js +12 -2
- package/src/cli/exit-codes.js +1 -0
- package/src/cli/index.js +2 -0
- package/src/cli/shared.js +15 -3
- package/src/core/audit.js +17 -3
- package/src/core/baseline.js +80 -0
- package/src/core/changelog.js +74 -23
- package/src/core/config.js +25 -2
- package/src/core/import-runner.js +34 -6
- package/src/core/import.js +14 -7
- package/src/core/lock.js +28 -6
- package/src/core/migrator.js +296 -75
- package/src/core/run.js +33 -1
- package/src/core/runner.js +70 -20
- package/src/errors/index.js +15 -1
- package/src/index.js +8 -0
- package/src/utils/logger.js +30 -12
- package/src/utils/redact.js +36 -3
- package/src/utils/sanitize.js +8 -3
- package/src/utils/template.js +24 -10
package/src/cli/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { Command } = require('./args.js');
|
|
2
2
|
const { registerAudit } = require('./commands/audit.js');
|
|
3
|
+
const { registerBaseline } = require('./commands/baseline.js');
|
|
3
4
|
const { registerCreate } = require('./commands/create.js');
|
|
4
5
|
const { registerDown } = require('./commands/down.js');
|
|
5
6
|
const { registerDryRun } = require('./commands/dry-run.js');
|
|
@@ -37,6 +38,7 @@ function buildProgram() {
|
|
|
37
38
|
|
|
38
39
|
registerInit(program);
|
|
39
40
|
registerImport(program);
|
|
41
|
+
registerBaseline(program);
|
|
40
42
|
registerUp(program);
|
|
41
43
|
registerDown(program);
|
|
42
44
|
registerRedo(program);
|
package/src/cli/shared.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { createInterface } = require('node:readline/promises');
|
|
2
2
|
const { createSpinner } = require('./spinner.js');
|
|
3
|
-
const { ConfigInvalidError, MigronautError } = require('../errors/index.js');
|
|
3
|
+
const { ConfigInvalidError, MigronautError, RunAbortedError } = require('../errors/index.js');
|
|
4
4
|
const { errorText } = require('../utils/error.js');
|
|
5
5
|
const { createLogger } = require('../utils/logger.js');
|
|
6
6
|
const { redactDeep, redactUris } = require('../utils/redact.js');
|
|
@@ -50,7 +50,11 @@ const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143 };
|
|
|
50
50
|
* exits immediately for an operator who cannot wait.
|
|
51
51
|
*
|
|
52
52
|
* Returns a function that removes the handlers again, so a long-lived process
|
|
53
|
-
* calling the CLI repeatedly does not accumulate them.
|
|
53
|
+
* calling the CLI repeatedly does not accumulate them. The function carries a
|
|
54
|
+
* `stopRequested()` accessor: a signal that lands while nothing is running yet
|
|
55
|
+
* (the cosmetic pre-connect) makes `migrator.stop()` a no-op, so the caller
|
|
56
|
+
* must consult this flag itself before starting the run — otherwise the
|
|
57
|
+
* handler's "then stopping" promise above would be a lie.
|
|
54
58
|
*/
|
|
55
59
|
function attachSignalHandlers(migrator, spinner, logger) {
|
|
56
60
|
let stopping = false;
|
|
@@ -76,9 +80,11 @@ function attachSignalHandlers(migrator, spinner, logger) {
|
|
|
76
80
|
process.on(signal, handler);
|
|
77
81
|
handlers.push([signal, handler]);
|
|
78
82
|
}
|
|
79
|
-
|
|
83
|
+
const detach = () => {
|
|
80
84
|
for (const [signal, handler] of handlers) process.off(signal, handler);
|
|
81
85
|
};
|
|
86
|
+
detach.stopRequested = () => stopping;
|
|
87
|
+
return detach;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/**
|
|
@@ -225,6 +231,12 @@ async function withMigrator(opts, fn, options = {}) {
|
|
|
225
231
|
throw error;
|
|
226
232
|
}
|
|
227
233
|
}
|
|
234
|
+
// A signal during the pre-connect lands before any run window exists, so
|
|
235
|
+
// migrator.stop() was a no-op — honor it here, before starting the run the
|
|
236
|
+
// handler already told the operator would be stopped.
|
|
237
|
+
if (detachSignals.stopRequested?.()) {
|
|
238
|
+
throw new RunAbortedError('Stopped by signal before the run started', { results: [] });
|
|
239
|
+
}
|
|
228
240
|
await fn(migrator, { logger, json, opts });
|
|
229
241
|
} catch (error) {
|
|
230
242
|
// Safety net: clear any spinner still spinning before printing the error.
|
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
|
@@ -95,9 +95,18 @@ class Changelog {
|
|
|
95
95
|
return this.#coll(db).findOne({ name });
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Every currently-applied record's `{name, checksum}`, sorted by name
|
|
100
|
+
* ascending — exactly what the strict bulk drift check consumes (the name
|
|
101
|
+
* doubles as the applied-set key). Projected like the module's other reads;
|
|
102
|
+
* widen the projection if a new caller needs more.
|
|
103
|
+
*/
|
|
99
104
|
async getApplied(db) {
|
|
100
|
-
return this.#coll(db)
|
|
105
|
+
return this.#coll(db)
|
|
106
|
+
.find({ status: 'applied' })
|
|
107
|
+
.sort({ name: 1 })
|
|
108
|
+
.project({ _id: 0, name: 1, checksum: 1 })
|
|
109
|
+
.toArray();
|
|
101
110
|
}
|
|
102
111
|
|
|
103
112
|
/**
|
|
@@ -157,29 +166,54 @@ class Changelog {
|
|
|
157
166
|
return this.#coll(db).find({ batch }).sort({ name: 1 }).toArray();
|
|
158
167
|
}
|
|
159
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The update document shared by markApplied and markAppliedBulk.
|
|
171
|
+
*
|
|
172
|
+
* `appliedAt` is stamped in **server time** (`$currentDate`) when the record
|
|
173
|
+
* does not carry one — the same clock discipline the lock's `$$NOW` uses:
|
|
174
|
+
* `redo` and `down --steps` sort by `appliedAt`, and a client-stamped value
|
|
175
|
+
* lets a skewed host mis-order the revert selection. An explicit `appliedAt`
|
|
176
|
+
* (import adopting a legacy changelog's historical timestamps) is written
|
|
177
|
+
* verbatim. `firstAppliedAt` is audit-only metadata, never sorted on, so its
|
|
178
|
+
* client-clock `$setOnInsert` fallback is acceptable ($setOnInsert cannot
|
|
179
|
+
* express server time).
|
|
180
|
+
*/
|
|
181
|
+
static #appliedUpdate(record) {
|
|
182
|
+
// `name` comes from the filter on insert, so it must not also appear in an
|
|
183
|
+
// update operator (MongoDB rejects the conflicting path).
|
|
184
|
+
const { name, appliedAt, ...fields } = record;
|
|
185
|
+
const update = {
|
|
186
|
+
$set: fields,
|
|
187
|
+
// A re-apply clears the stale revert marker — and the failure trace a
|
|
188
|
+
// markFailed() from an earlier crashed attempt may have left.
|
|
189
|
+
$unset: { revertedAt: '', failedAt: '', error: '' },
|
|
190
|
+
};
|
|
191
|
+
if (appliedAt !== undefined) {
|
|
192
|
+
update.$set.appliedAt = appliedAt;
|
|
193
|
+
update.$setOnInsert = { firstAppliedAt: appliedAt };
|
|
194
|
+
} else {
|
|
195
|
+
update.$currentDate = { appliedAt: true };
|
|
196
|
+
update.$setOnInsert = { firstAppliedAt: new Date() };
|
|
197
|
+
}
|
|
198
|
+
return update;
|
|
199
|
+
}
|
|
200
|
+
|
|
160
201
|
/**
|
|
161
202
|
* Record a migration as applied. Upserts on `name` so re-applying a
|
|
162
203
|
* previously-reverted migration (e.g. via `redo`) cannot violate the unique
|
|
163
204
|
* index. Uses `$set` rather than a whole-document replace so audit fields
|
|
164
205
|
* survive a re-apply: `firstAppliedAt` is stamped once, and the stale
|
|
165
|
-
* `revertedAt` from an earlier rollback is cleared.
|
|
206
|
+
* `revertedAt` from an earlier rollback is cleared. `appliedAt` is stamped
|
|
207
|
+
* server-side unless the record carries one — see {@link #appliedUpdate}.
|
|
166
208
|
*
|
|
167
209
|
* Pass `session` to make this write part of the migration's transaction, so
|
|
168
210
|
* the migration and its changelog record commit together.
|
|
169
211
|
*/
|
|
170
212
|
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
|
-
);
|
|
213
|
+
await this.#coll(db).updateOne({ name: record.name }, Changelog.#appliedUpdate(record), {
|
|
214
|
+
upsert: true,
|
|
215
|
+
...(session ? { session } : {}),
|
|
216
|
+
});
|
|
183
217
|
}
|
|
184
218
|
|
|
185
219
|
/**
|
|
@@ -193,15 +227,10 @@ class Changelog {
|
|
|
193
227
|
if (records.length === 0) return;
|
|
194
228
|
const ops = new Array(records.length);
|
|
195
229
|
for (let i = 0; i < records.length; i++) {
|
|
196
|
-
const { name, ...fields } = records[i];
|
|
197
230
|
ops[i] = {
|
|
198
231
|
updateOne: {
|
|
199
|
-
filter: { name },
|
|
200
|
-
update:
|
|
201
|
-
$set: fields,
|
|
202
|
-
$setOnInsert: { firstAppliedAt: records[i].appliedAt },
|
|
203
|
-
$unset: { revertedAt: '' },
|
|
204
|
-
},
|
|
232
|
+
filter: { name: records[i].name },
|
|
233
|
+
update: Changelog.#appliedUpdate(records[i]),
|
|
205
234
|
upsert: true,
|
|
206
235
|
},
|
|
207
236
|
};
|
|
@@ -222,10 +251,32 @@ class Changelog {
|
|
|
222
251
|
async markReverted(db, name, session) {
|
|
223
252
|
return this.#coll(db).updateOne(
|
|
224
253
|
{ name, status: 'applied' },
|
|
225
|
-
|
|
254
|
+
// Server time, like markApplied's appliedAt — one clock for the whole trail.
|
|
255
|
+
{ $set: { status: 'reverted' }, $currentDate: { revertedAt: true } },
|
|
226
256
|
session ? { session } : {},
|
|
227
257
|
);
|
|
228
258
|
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Best-effort trace of a failed `up` attempt, so a crash-and-restart leaves
|
|
262
|
+
* DB-side evidence of what was in flight ("did the crashed run start X?")
|
|
263
|
+
* instead of depending on process logs that may not have been captured.
|
|
264
|
+
*
|
|
265
|
+
* The filter excludes `'applied'` records: a forced re-run's failure must
|
|
266
|
+
* never demote a migration the changelog says is applied — the upsert then
|
|
267
|
+
* collides on the unique `name` index, and the caller swallows that. Every
|
|
268
|
+
* read path filters on `status: 'applied'`, so a `'failed'` record never
|
|
269
|
+
* changes what runs; the next successful apply overwrites it (and clears
|
|
270
|
+
* `failedAt`/`error`).
|
|
271
|
+
*/
|
|
272
|
+
async markFailed(db, record) {
|
|
273
|
+
const { name, ...fields } = record;
|
|
274
|
+
await this.#coll(db).updateOne(
|
|
275
|
+
{ name, status: { $ne: 'applied' } },
|
|
276
|
+
{ $set: { ...fields, status: 'failed' }, $currentDate: { failedAt: true } },
|
|
277
|
+
{ upsert: true },
|
|
278
|
+
);
|
|
279
|
+
}
|
|
229
280
|
}
|
|
230
281
|
|
|
231
282
|
module.exports = { Changelog };
|
package/src/core/config.js
CHANGED
|
@@ -7,7 +7,12 @@ const { errorText } = require('../utils/error.js');
|
|
|
7
7
|
const { resolveLogger } = require('../utils/logger.js');
|
|
8
8
|
const { redactDeep } = require('../utils/redact.js');
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Default values applied when no flag, env var, or config-file value is
|
|
12
|
+
* present. The single canonical home for every effective default — a use-site
|
|
13
|
+
* `??` fallback would hide these from the schema/template sync tests and let
|
|
14
|
+
* the same fact drift across hand-written copies.
|
|
15
|
+
*/
|
|
11
16
|
const DEFAULT_CONFIG = {
|
|
12
17
|
migrationsDir: './migrations',
|
|
13
18
|
migrationsCollection: '_migronaut_migrations',
|
|
@@ -18,6 +23,10 @@ const DEFAULT_CONFIG = {
|
|
|
18
23
|
fileExtensions: ['.ts', '.js'],
|
|
19
24
|
createExtension: 'js',
|
|
20
25
|
sequential: false,
|
|
26
|
+
ensureIndexes: true,
|
|
27
|
+
onLockLost: 'abort',
|
|
28
|
+
onOutOfOrder: 'warn',
|
|
29
|
+
reloadMigrations: false,
|
|
21
30
|
};
|
|
22
31
|
|
|
23
32
|
/** Candidate config file names, checked in priority order within the cwd */
|
|
@@ -98,6 +107,12 @@ const CONFIG_KEYS = [
|
|
|
98
107
|
message: "must be 'abort' or 'warn'",
|
|
99
108
|
optional: true,
|
|
100
109
|
},
|
|
110
|
+
{
|
|
111
|
+
path: 'onOutOfOrder',
|
|
112
|
+
check: (value) => value === 'warn' || value === 'error' || value === 'allow',
|
|
113
|
+
message: "must be 'warn', 'error' or 'allow'",
|
|
114
|
+
optional: true,
|
|
115
|
+
},
|
|
101
116
|
{
|
|
102
117
|
path: 'envFile',
|
|
103
118
|
check: (value) => value === false || isNonEmptyString(value),
|
|
@@ -248,6 +263,11 @@ const ENV_KEYS = [
|
|
|
248
263
|
{ env: 'MIGRONAUT_TEMPLATE_PATH', path: 'templatePath', parse: parseString },
|
|
249
264
|
{ env: 'MIGRONAUT_TIMEOUT_MS', path: 'timeoutMs', parse: parsePositiveInteger },
|
|
250
265
|
{ env: 'MIGRONAUT_ON_LOCK_LOST', path: 'onLockLost', parse: parseEnum(['abort', 'warn']) },
|
|
266
|
+
{
|
|
267
|
+
env: 'MIGRONAUT_ON_OUT_OF_ORDER',
|
|
268
|
+
path: 'onOutOfOrder',
|
|
269
|
+
parse: parseEnum(['warn', 'error', 'allow']),
|
|
270
|
+
},
|
|
251
271
|
{ env: 'MIGRONAUT_ENSURE_INDEXES', path: 'ensureIndexes', parse: parseBoolean },
|
|
252
272
|
{ env: 'MIGRONAUT_RELOAD_MIGRATIONS', path: 'reloadMigrations', parse: parseBoolean },
|
|
253
273
|
];
|
|
@@ -394,7 +414,10 @@ async function loadConfig(options = {}) {
|
|
|
394
414
|
: await discoverConfigFile(cwd);
|
|
395
415
|
|
|
396
416
|
if (configFilePath) {
|
|
397
|
-
|
|
417
|
+
// Only an explicit --config path needs the probe (a typo deserves a clear
|
|
418
|
+
// "not found") — discovery already proved existence, and re-checking it
|
|
419
|
+
// would pay a redundant fs.access on every invocation.
|
|
420
|
+
if (options.configPath && !(await pathExists(configFilePath))) {
|
|
398
421
|
throw new ConfigInvalidError('Config file not found', { path: configFilePath });
|
|
399
422
|
}
|
|
400
423
|
const fileConfig = await loadConfigFile(configFilePath, options.lenient ?? false);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { ImportTargetNotEmptyError } = require('../errors/index.js');
|
|
1
|
+
const { ImportTargetNotEmptyError, MigronautError } = require('../errors/index.js');
|
|
2
2
|
const { computeChecksum } = require('../utils/checksum.js');
|
|
3
3
|
const { Changelog } = require('./changelog.js');
|
|
4
4
|
const { isMigrateMongoDoc, mapMigrateMongoDocs } = require('./import.js');
|
|
@@ -52,7 +52,14 @@ async function runImport(deps, options, signal) {
|
|
|
52
52
|
valid.push(doc);
|
|
53
53
|
} else {
|
|
54
54
|
skipped += 1;
|
|
55
|
-
|
|
55
|
+
// The _id is the only handle the operator has for locating the offending
|
|
56
|
+
// source document — an anonymous count is undebuggable after the fact.
|
|
57
|
+
// String() keeps an ObjectId safe for any sink; the default logger
|
|
58
|
+
// already sanitizes terminal escapes in DB-derived text.
|
|
59
|
+
logger.warn(
|
|
60
|
+
`⚠ Skipping source doc without a usable fileName (_id: ${String(doc?._id)})`,
|
|
61
|
+
deps.fields({ source, docId: String(doc?._id) }),
|
|
62
|
+
);
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
|
|
@@ -91,7 +98,10 @@ async function runImport(deps, options, signal) {
|
|
|
91
98
|
);
|
|
92
99
|
rowSources.set(fileName, resolved.source);
|
|
93
100
|
if (resolved.source === 'missing') {
|
|
94
|
-
logger.warn(
|
|
101
|
+
logger.warn(
|
|
102
|
+
`⚠ File not found on disk: ${fileName} — checksum unverifiable`,
|
|
103
|
+
deps.fields({ file: fileName, checksumSource: 'missing' }),
|
|
104
|
+
);
|
|
95
105
|
}
|
|
96
106
|
return resolved;
|
|
97
107
|
},
|
|
@@ -120,9 +130,27 @@ async function runImport(deps, options, signal) {
|
|
|
120
130
|
// adopting a 5,000-record changelog is 5 round trips, not 5,000, all while
|
|
121
131
|
// holding the migration lock. The abort check between chunks lets stop() /
|
|
122
132
|
// a lost lock halt a long import instead of running it to completion.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
133
|
+
let written = 0;
|
|
134
|
+
try {
|
|
135
|
+
for (let start = 0; start < records.length; start += IMPORT_CHUNK_SIZE) {
|
|
136
|
+
deps.assertNotAborted(signal);
|
|
137
|
+
await targetChangelog.markAppliedBulk(db, records.slice(start, start + IMPORT_CHUNK_SIZE));
|
|
138
|
+
written += Math.min(IMPORT_CHUNK_SIZE, records.length - start);
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// Earlier chunks are already committed; without a count the operator only
|
|
142
|
+
// discovers the half-populated target when the next plain `import` throws
|
|
143
|
+
// ImportTargetNotEmptyError. Recovery is safe — the upserts are keyed on
|
|
144
|
+
// `name`, so a --force re-run is idempotent and simply resumes.
|
|
145
|
+
logger.warn(
|
|
146
|
+
`⚠ Import interrupted after ${written}/${records.length} record(s) — ` +
|
|
147
|
+
'a --force re-run is idempotent and will resume',
|
|
148
|
+
deps.fields({ source, target, imported: written, total: records.length }),
|
|
149
|
+
);
|
|
150
|
+
if (error instanceof MigronautError && error.context) {
|
|
151
|
+
error.context = { ...error.context, imported: written, total: records.length };
|
|
152
|
+
}
|
|
153
|
+
throw error;
|
|
126
154
|
}
|
|
127
155
|
|
|
128
156
|
logger.info(
|
package/src/core/import.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
const { mapLimit } = require('../utils/concurrency.js');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Simultaneous checksum resolutions. Each one is a file read — an unbounded
|
|
5
|
+
* fan-out over a thousands-record legacy changelog would exhaust the
|
|
6
|
+
* descriptor limit (EMFILE), the exact hazard mapLimit exists for.
|
|
7
|
+
*/
|
|
8
|
+
const CHECKSUM_CONCURRENCY = 16;
|
|
9
|
+
|
|
1
10
|
/** Returns true when a value looks like a usable migrate-mongo changelog doc */
|
|
2
11
|
function isMigrateMongoDoc(value) {
|
|
3
12
|
return (
|
|
@@ -30,13 +39,11 @@ async function mapMigrateMongoDocs(docs, options) {
|
|
|
30
39
|
return delta !== 0 ? delta : a.fileName.localeCompare(b.fileName);
|
|
31
40
|
});
|
|
32
41
|
|
|
33
|
-
// Independent per-doc disk reads —
|
|
34
|
-
//
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
const resolutions = await Promise.all(checksumPromises);
|
|
42
|
+
// Independent per-doc disk reads — resolved concurrently, but bounded:
|
|
43
|
+
// mapLimit preserves input order exactly like Promise.all would.
|
|
44
|
+
const resolutions = await mapLimit(sorted, CHECKSUM_CONCURRENCY, (doc) =>
|
|
45
|
+
options.resolveChecksum(doc.fileName, doc.fileHash),
|
|
46
|
+
);
|
|
40
47
|
|
|
41
48
|
const records = [];
|
|
42
49
|
for (let index = 0; index < sorted.length; index++) {
|
package/src/core/lock.js
CHANGED
|
@@ -81,13 +81,14 @@ class MigrationLock {
|
|
|
81
81
|
owner: { $literal: owner },
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
+
let result;
|
|
84
85
|
try {
|
|
85
86
|
// Upsert on plain `_id` — upserts reject `$expr` filters (server error
|
|
86
87
|
// 224), so the staleness decision lives in the pipeline instead, still
|
|
87
88
|
// in server time: take the lock when no `lockedAt` exists (fresh insert)
|
|
88
89
|
// or the holder is stale; otherwise keep the current document untouched.
|
|
89
90
|
// The read-back below tells those outcomes apart.
|
|
90
|
-
await collection.updateOne(
|
|
91
|
+
result = await collection.updateOne(
|
|
91
92
|
{ _id: LOCK_ID },
|
|
92
93
|
[
|
|
93
94
|
{
|
|
@@ -118,6 +119,15 @@ class MigrationLock {
|
|
|
118
119
|
throw error;
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
// A fresh upsert-insert on the unique `_id` already proves ownership — no
|
|
123
|
+
// other outcome can insert — and it is the common uncontended path, since
|
|
124
|
+
// release() deletes the document after every clean run. Skipping the
|
|
125
|
+
// read-back halves that path's round trips.
|
|
126
|
+
if (result.upsertedCount === 1) {
|
|
127
|
+
this.#owner = owner;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
121
131
|
// Confirm we are the holder. A fresh lock left the document untouched, and
|
|
122
132
|
// if two processes raced to reclaim the same stale lock only the last
|
|
123
133
|
// writer's `owner` wins; either way the loser reads a different token here
|
|
@@ -166,12 +176,16 @@ class MigrationLock {
|
|
|
166
176
|
|
|
167
177
|
/**
|
|
168
178
|
* Release the lock by deleting the lock document. Scoped to our `owner` token
|
|
169
|
-
*
|
|
170
|
-
*
|
|
179
|
+
* so we never delete a lock that has since been reclaimed by another process.
|
|
180
|
+
* With no token held this is a no-op — an unscoped delete here would be
|
|
181
|
+
* `forceRelease()` without its deliberate opt-in, and a future caller
|
|
182
|
+
* releasing twice (or before acquiring) must not silently steal a peer's
|
|
183
|
+
* live lock.
|
|
171
184
|
* @throws {LockReleaseFailedError} when the delete operation fails
|
|
172
185
|
*/
|
|
173
186
|
async release() {
|
|
174
|
-
|
|
187
|
+
if (!this.#owner) return;
|
|
188
|
+
const filter = { _id: LOCK_ID, owner: this.#owner };
|
|
175
189
|
try {
|
|
176
190
|
await this.#db.collection(this.#collectionName).deleteOne(filter);
|
|
177
191
|
this.#owner = undefined;
|
|
@@ -332,8 +346,16 @@ async function runWithLock(lock, options, fn) {
|
|
|
332
346
|
} catch (releaseError) {
|
|
333
347
|
// Never let a release failure replace the reason the run failed: that would
|
|
334
348
|
// report "Failed to release migration lock" instead of the actual migration
|
|
335
|
-
// error. When the run succeeded, the release failure is the only news
|
|
336
|
-
|
|
349
|
+
// error. When the run succeeded, the release failure is the only news —
|
|
350
|
+
// but the migrations DID apply, and that list must survive onto the error,
|
|
351
|
+
// or "everything applied, only the lock cleanup failed" (the lock document
|
|
352
|
+
// self-heals via its TTL) is indistinguishable from a failed run.
|
|
353
|
+
if (!failed) {
|
|
354
|
+
if (releaseError instanceof LockReleaseFailedError && Array.isArray(result)) {
|
|
355
|
+
releaseError.context = { ...releaseError.context, results: [...result] };
|
|
356
|
+
}
|
|
357
|
+
throw releaseError;
|
|
358
|
+
}
|
|
337
359
|
const message = errorText(releaseError);
|
|
338
360
|
options.logger.warn(`⚠ Failed to release the migration lock: ${message}`, {
|
|
339
361
|
event: 'lock:release-failed',
|