@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/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
- return () => {
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 and missing files, from the same rows `status` renders.
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 both signals.
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
- if (row.status === 'pending') pending += 1;
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 };
@@ -95,9 +95,18 @@ class Changelog {
95
95
  return this.#coll(db).findOne({ name });
96
96
  }
97
97
 
98
- /** Return every currently-applied record, sorted by name ascending */
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).find({ status: 'applied' }).sort({ name: 1 }).toArray();
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
- // `name` comes from the filter on insert, so it must not also appear in an
172
- // update operator (MongoDB rejects the conflicting path).
173
- const { name, ...fields } = record;
174
- await this.#coll(db).updateOne(
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
- { $set: { status: 'reverted', revertedAt: new Date() } },
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 };
@@ -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
- /** Default values applied when no flag, env var, or config-file value is present */
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
- if (!(await pathExists(configFilePath))) {
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
- logger.warn('⚠ Skipping source doc without a usable fileName');
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(`⚠ File not found on disk: ${fileName} — checksum unverifiable`);
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
- for (let start = 0; start < records.length; start += IMPORT_CHUNK_SIZE) {
124
- deps.assertNotAborted(signal);
125
- await targetChangelog.markAppliedBulk(db, records.slice(start, start + IMPORT_CHUNK_SIZE));
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(
@@ -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 — resolve them concurrently rather than
34
- // one at a time.
35
- const checksumPromises = [];
36
- for (const doc of sorted) {
37
- checksumPromises.push(options.resolveChecksum(doc.fileName, doc.fileHash));
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
- * (when held) so we never delete a lock that has since been reclaimed by
170
- * another process.
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
- const filter = this.#owner ? { _id: LOCK_ID, owner: this.#owner } : { _id: LOCK_ID };
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
- if (!failed) throw releaseError;
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',