@alexify/migronaut 1.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 +128 -0
- package/LICENSE +22 -0
- package/README.md +773 -0
- package/bin/migronaut.js +36 -0
- package/index.d.ts +903 -0
- package/index.js +1 -0
- package/migronaut.schema.json +135 -0
- package/package.json +105 -0
- package/src/cli/args.js +314 -0
- package/src/cli/commands/audit.js +40 -0
- package/src/cli/commands/create.js +29 -0
- package/src/cli/commands/down.js +30 -0
- package/src/cli/commands/dry-run.js +36 -0
- package/src/cli/commands/import.js +33 -0
- package/src/cli/commands/init.js +64 -0
- package/src/cli/commands/list.js +19 -0
- package/src/cli/commands/lock.js +35 -0
- package/src/cli/commands/redo.js +16 -0
- package/src/cli/commands/status.js +52 -0
- package/src/cli/commands/unlock.js +43 -0
- package/src/cli/commands/up.js +53 -0
- package/src/cli/exit-codes.js +38 -0
- package/src/cli/index.js +91 -0
- package/src/cli/shared.js +343 -0
- package/src/cli/spinner.js +59 -0
- package/src/cli/table.js +259 -0
- package/src/core/audit.js +152 -0
- package/src/core/changelog.js +231 -0
- package/src/core/config.js +479 -0
- package/src/core/context.js +16 -0
- package/src/core/import-runner.js +164 -0
- package/src/core/import.js +60 -0
- package/src/core/lock.js +348 -0
- package/src/core/migrator.js +1475 -0
- package/src/core/run.js +144 -0
- package/src/core/runner.js +150 -0
- package/src/errors/index.js +215 -0
- package/src/index.js +59 -0
- package/src/utils/checksum.js +23 -0
- package/src/utils/colors.js +68 -0
- package/src/utils/concurrency.js +32 -0
- package/src/utils/date.js +28 -0
- package/src/utils/env.js +51 -0
- package/src/utils/error.js +11 -0
- package/src/utils/loader.js +131 -0
- package/src/utils/logger.js +109 -0
- package/src/utils/redact.js +39 -0
- package/src/utils/sanitize.js +43 -0
- package/src/utils/template.js +615 -0
- package/src/utils/user.js +25 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Returns true when a value looks like a usable migrate-mongo changelog doc */
|
|
2
|
+
function isMigrateMongoDoc(value) {
|
|
3
|
+
return (
|
|
4
|
+
typeof value === 'object' &&
|
|
5
|
+
value !== null &&
|
|
6
|
+
typeof value.fileName === 'string' &&
|
|
7
|
+
value.fileName.length > 0
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Apply-order sort key for a doc — its run timestamp, falling back to apply time */
|
|
12
|
+
function orderKey(doc) {
|
|
13
|
+
return doc.migrationBlock ?? new Date(doc.appliedAt).getTime();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Map migrate-mongo changelog docs into migronaut MigrationRecords.
|
|
18
|
+
* Pure: all impure inputs (disk checksums, env identity) arrive via `options`.
|
|
19
|
+
*
|
|
20
|
+
* Each migration gets a **unique** batch number, assigned sequentially in apply
|
|
21
|
+
* order (`migrationBlock`, then `appliedAt`, then filename) starting at
|
|
22
|
+
* `batchOffset + 1`. Run-grouping is deliberately not preserved: imported records
|
|
23
|
+
* are forward-only (migronaut refuses to `down` them), so a shared batch would only
|
|
24
|
+
* produce confusing duplicate batch ids with no rollback benefit.
|
|
25
|
+
*/
|
|
26
|
+
async function mapMigrateMongoDocs(docs, options) {
|
|
27
|
+
const offset = options.batchOffset ?? 0;
|
|
28
|
+
const sorted = [...docs].sort((a, b) => {
|
|
29
|
+
const delta = orderKey(a) - orderKey(b);
|
|
30
|
+
return delta !== 0 ? delta : a.fileName.localeCompare(b.fileName);
|
|
31
|
+
});
|
|
32
|
+
|
|
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);
|
|
40
|
+
|
|
41
|
+
const records = [];
|
|
42
|
+
for (let index = 0; index < sorted.length; index++) {
|
|
43
|
+
const doc = sorted[index];
|
|
44
|
+
records.push({
|
|
45
|
+
name: doc.fileName,
|
|
46
|
+
batch: offset + index + 1,
|
|
47
|
+
status: 'applied',
|
|
48
|
+
appliedAt: new Date(doc.appliedAt),
|
|
49
|
+
duration: 0,
|
|
50
|
+
checksum: resolutions[index].checksum,
|
|
51
|
+
environment: options.environment,
|
|
52
|
+
executedBy: options.executedBy,
|
|
53
|
+
// Marks the record forward-only: migronaut down/redo will refuse it.
|
|
54
|
+
origin: 'migrate-mongo',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return records;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { isMigrateMongoDoc, mapMigrateMongoDocs };
|
package/src/core/lock.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
const { randomUUID } = require('node:crypto');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const {
|
|
4
|
+
LockAlreadyHeldError,
|
|
5
|
+
LockLostError,
|
|
6
|
+
LockReleaseFailedError,
|
|
7
|
+
} = require('../errors/index.js');
|
|
8
|
+
const { errorText } = require('../utils/error.js');
|
|
9
|
+
const { safeUsername } = require('../utils/user.js');
|
|
10
|
+
|
|
11
|
+
/** Fixed `_id` of the singleton lock document */
|
|
12
|
+
const LOCK_ID = 'migronaut_lock';
|
|
13
|
+
|
|
14
|
+
/** Returns true when an error is a MongoDB duplicate-key error (code 11000) */
|
|
15
|
+
function isDuplicateKeyError(error) {
|
|
16
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 11000;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Map a raw lock document to the public LockInfo shape. Strips internal fields —
|
|
21
|
+
* most importantly the `owner` token, which proves lock ownership and must never
|
|
22
|
+
* leak into error context or CLI output.
|
|
23
|
+
*/
|
|
24
|
+
function toLockInfo(doc) {
|
|
25
|
+
if (!doc) return null;
|
|
26
|
+
return { lockedAt: doc.lockedAt, pid: doc.pid, host: doc.host, executedBy: doc.executedBy };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* MongoDB-native distributed lock backed by a single document, using an atomic
|
|
31
|
+
* upsert as a test-and-set. A lock older than `ttlSeconds` is considered stale
|
|
32
|
+
* and may be reclaimed.
|
|
33
|
+
*/
|
|
34
|
+
class MigrationLock {
|
|
35
|
+
#db;
|
|
36
|
+
#collectionName;
|
|
37
|
+
#ttlSeconds;
|
|
38
|
+
/** Token proving this instance is the current holder; set on acquire, cleared on release */
|
|
39
|
+
#owner;
|
|
40
|
+
|
|
41
|
+
constructor(db, collectionName, ttlSeconds) {
|
|
42
|
+
this.#db = db;
|
|
43
|
+
this.#collectionName = collectionName;
|
|
44
|
+
this.#ttlSeconds = ttlSeconds;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The token identifying this holder, or undefined when the lock is not held */
|
|
48
|
+
get owner() {
|
|
49
|
+
return this.#owner;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** TTL in milliseconds — used to size the renewal heartbeat */
|
|
53
|
+
get ttlMs() {
|
|
54
|
+
return this.#ttlSeconds * 1000;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Acquire the lock, reclaiming it if the existing one is stale.
|
|
59
|
+
*
|
|
60
|
+
* Staleness is judged and `lockedAt` stamped in **server time** (`$$NOW` via
|
|
61
|
+
* an aggregation-pipeline update): comparing one host's clock against a
|
|
62
|
+
* timestamp another host wrote means a pod running 90s fast steals every
|
|
63
|
+
* healthy lock, and one running slow never reclaims a dead one.
|
|
64
|
+
*
|
|
65
|
+
* @throws {LockAlreadyHeldError} when another process holds a fresh lock
|
|
66
|
+
*/
|
|
67
|
+
async acquire(token) {
|
|
68
|
+
const collection = this.#db.collection(this.#collectionName);
|
|
69
|
+
// The caller may supply the run id so the lock document, the changelog
|
|
70
|
+
// records and the log lines of one run all carry the same token.
|
|
71
|
+
const owner = token ?? randomUUID();
|
|
72
|
+
// The replacement document for the taken branch. $literal guards the
|
|
73
|
+
// strings: a pipeline expression would otherwise interpret a leading `$`
|
|
74
|
+
// in a value as a field path.
|
|
75
|
+
const lockDoc = {
|
|
76
|
+
_id: LOCK_ID,
|
|
77
|
+
lockedAt: '$$NOW',
|
|
78
|
+
pid: { $literal: process.pid },
|
|
79
|
+
host: { $literal: os.hostname() },
|
|
80
|
+
executedBy: { $literal: safeUsername() },
|
|
81
|
+
owner: { $literal: owner },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// Upsert on plain `_id` — upserts reject `$expr` filters (server error
|
|
86
|
+
// 224), so the staleness decision lives in the pipeline instead, still
|
|
87
|
+
// in server time: take the lock when no `lockedAt` exists (fresh insert)
|
|
88
|
+
// or the holder is stale; otherwise keep the current document untouched.
|
|
89
|
+
// The read-back below tells those outcomes apart.
|
|
90
|
+
await collection.updateOne(
|
|
91
|
+
{ _id: LOCK_ID },
|
|
92
|
+
[
|
|
93
|
+
{
|
|
94
|
+
$replaceWith: {
|
|
95
|
+
$cond: [
|
|
96
|
+
{
|
|
97
|
+
$lt: [
|
|
98
|
+
{ $ifNull: ['$lockedAt', new Date(0)] },
|
|
99
|
+
{ $subtract: ['$$NOW', this.ttlMs] },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
lockDoc,
|
|
103
|
+
'$$ROOT',
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
{ upsert: true },
|
|
109
|
+
);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (isDuplicateKeyError(error)) {
|
|
112
|
+
// Two processes raced the very first insert; the loser lands here.
|
|
113
|
+
const holder = await collection.findOne({ _id: LOCK_ID });
|
|
114
|
+
throw new LockAlreadyHeldError('Migration lock is already held', {
|
|
115
|
+
holder: toLockInfo(holder) ?? undefined,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Confirm we are the holder. A fresh lock left the document untouched, and
|
|
122
|
+
// if two processes raced to reclaim the same stale lock only the last
|
|
123
|
+
// writer's `owner` wins; either way the loser reads a different token here
|
|
124
|
+
// and backs off instead of running concurrently.
|
|
125
|
+
const current = await collection.findOne({ _id: LOCK_ID });
|
|
126
|
+
if (!current || current.owner !== owner) {
|
|
127
|
+
throw new LockAlreadyHeldError('Migration lock is already held', {
|
|
128
|
+
holder: toLockInfo(current) ?? undefined,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
this.#owner = owner;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Refresh `lockedAt` so a long-running migration's lock never goes stale and
|
|
136
|
+
* gets reclaimed mid-run. Scoped to our `owner` token, so it is a no-op if the
|
|
137
|
+
* lock was already lost. Server time, for the same reason as acquire().
|
|
138
|
+
* Returns true while we still hold the lock.
|
|
139
|
+
*/
|
|
140
|
+
async renew() {
|
|
141
|
+
if (!this.#owner) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const result = await this.#db
|
|
145
|
+
.collection(this.#collectionName)
|
|
146
|
+
.updateOne({ _id: LOCK_ID, owner: this.#owner }, [{ $set: { lockedAt: '$$NOW' } }]);
|
|
147
|
+
return result.matchedCount === 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Read the current lock document, or null when no lock is held */
|
|
151
|
+
async inspect() {
|
|
152
|
+
return this.#db.collection(this.#collectionName).findOne({ _id: LOCK_ID });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Force-delete the lock regardless of who holds it, returning the document
|
|
157
|
+
* that was removed (or null if none). Used by `migronaut unlock` to clear a lock
|
|
158
|
+
* left behind by a crashed run — bypasses the owner scoping of {@link release}.
|
|
159
|
+
*/
|
|
160
|
+
async forceRelease() {
|
|
161
|
+
const collection = this.#db.collection(this.#collectionName);
|
|
162
|
+
const existing = await collection.findOne({ _id: LOCK_ID });
|
|
163
|
+
await collection.deleteOne({ _id: LOCK_ID });
|
|
164
|
+
return existing;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 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.
|
|
171
|
+
* @throws {LockReleaseFailedError} when the delete operation fails
|
|
172
|
+
*/
|
|
173
|
+
async release() {
|
|
174
|
+
const filter = this.#owner ? { _id: LOCK_ID, owner: this.#owner } : { _id: LOCK_ID };
|
|
175
|
+
try {
|
|
176
|
+
await this.#db.collection(this.#collectionName).deleteOne(filter);
|
|
177
|
+
this.#owner = undefined;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw new LockReleaseFailedError(
|
|
180
|
+
'Failed to release migration lock',
|
|
181
|
+
{ error: errorText(error) },
|
|
182
|
+
{ cause: error },
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Run `fn(signal)` while holding the migration lock. The lock is always
|
|
190
|
+
* released in a `finally` block. While `fn` runs, a heartbeat renews the lock
|
|
191
|
+
* every `ttlMs/2` so a migration that takes longer than the TTL never lets its
|
|
192
|
+
* lock go stale and get reclaimed by another process.
|
|
193
|
+
*
|
|
194
|
+
* If the lock is nonetheless lost — another process reclaimed it, or the
|
|
195
|
+
* heartbeat cannot reach the database — the `signal` is aborted with a
|
|
196
|
+
* LockLostError. `fn` is expected to check it between migrations and stop:
|
|
197
|
+
* continuing would mean two processes migrating the same database at once.
|
|
198
|
+
* Set `onLockLost: 'warn'` to keep going with only a warning.
|
|
199
|
+
*
|
|
200
|
+
* When `noLock` is true, acquisition is skipped and a loud warning is emitted —
|
|
201
|
+
* intended for local development only.
|
|
202
|
+
*/
|
|
203
|
+
async function runWithLock(lock, options, fn) {
|
|
204
|
+
const controller = new AbortController();
|
|
205
|
+
|
|
206
|
+
if (options.noLock) {
|
|
207
|
+
options.logger.warn('⚠ Running without a lock (--no-lock) — concurrent runs are unsafe', {
|
|
208
|
+
noLock: true,
|
|
209
|
+
});
|
|
210
|
+
// Events stay balanced for subscribers even when acquisition is skipped;
|
|
211
|
+
// `skipped: true` tells them apart from a real lock.
|
|
212
|
+
options.onLockAcquired?.({ skipped: true });
|
|
213
|
+
try {
|
|
214
|
+
return await fn(controller.signal);
|
|
215
|
+
} finally {
|
|
216
|
+
options.onLockReleased?.({ skipped: true });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const acquireStart = Date.now();
|
|
221
|
+
await lock.acquire(options.owner);
|
|
222
|
+
// ttlMs and the acquisition latency give a subscriber enough to alert on
|
|
223
|
+
// ("acquire took 4s — the lock collection is contended") without a log parse.
|
|
224
|
+
options.onLockAcquired?.({ ttlMs: lock.ttlMs, acquireMs: Date.now() - acquireStart });
|
|
225
|
+
|
|
226
|
+
const abortOnLoss = (options.onLockLost ?? 'abort') === 'abort';
|
|
227
|
+
const loseLock = (reason) => {
|
|
228
|
+
// The most alert-worthy line this module emits — structured fields so a
|
|
229
|
+
// JSON sink can trigger on it without parsing the human string.
|
|
230
|
+
options.logger.warn(`⚠ Lost the migration lock mid-run (${reason})`, {
|
|
231
|
+
event: 'lock:lost',
|
|
232
|
+
reason,
|
|
233
|
+
});
|
|
234
|
+
options.onLockLostEvent?.(reason);
|
|
235
|
+
if (abortOnLoss && !controller.signal.aborted) {
|
|
236
|
+
controller.abort(
|
|
237
|
+
new LockLostError('Lost the migration lock mid-run', { reason, aborted: true }),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Renew at half the TTL so the lock is refreshed comfortably before it would
|
|
243
|
+
// be considered stale. unref() keeps the heartbeat from holding the process
|
|
244
|
+
// open on its own.
|
|
245
|
+
const intervalMs = Math.max(1, Math.floor(lock.ttlMs / 2));
|
|
246
|
+
let consecutiveFailures = 0;
|
|
247
|
+
let stopped = false;
|
|
248
|
+
|
|
249
|
+
// The hard deadline is the single failure escalation: renewal errors are
|
|
250
|
+
// tolerated (warned about below) exactly as long as the lock cannot be
|
|
251
|
+
// reclaimed yet. The lock goes stale-reclaimable at 1×TTL — the deadline
|
|
252
|
+
// aborts strictly before that window opens, and each successful renewal
|
|
253
|
+
// re-arms it. (A consecutive-failure counter used to sit alongside this,
|
|
254
|
+
// but firing at 1.5×TTL it could mathematically never beat the deadline.)
|
|
255
|
+
const deadlineMarginMs = Math.min(Math.floor(intervalMs / 2), 5000);
|
|
256
|
+
let deadline;
|
|
257
|
+
const armDeadline = () => {
|
|
258
|
+
clearTimeout(deadline);
|
|
259
|
+
deadline = setTimeout(
|
|
260
|
+
() => {
|
|
261
|
+
if (!stopped) loseLock('no successful renewal within the TTL');
|
|
262
|
+
},
|
|
263
|
+
Math.max(1, lock.ttlMs - deadlineMarginMs),
|
|
264
|
+
);
|
|
265
|
+
deadline.unref?.();
|
|
266
|
+
};
|
|
267
|
+
armDeadline();
|
|
268
|
+
|
|
269
|
+
// Tracked so the caller never returns with a renewal still in flight: a
|
|
270
|
+
// stray query landing after disconnect() fails against a closed client.
|
|
271
|
+
// `pending` guards re-entrancy — when the DB is slow enough that a renewal
|
|
272
|
+
// spans a whole interval (the exact condition the heartbeat exists to
|
|
273
|
+
// survive), overlapping ticks would otherwise overwrite `inFlight` and let
|
|
274
|
+
// an earlier, still-unsettled renewal escape the final await.
|
|
275
|
+
let inFlight;
|
|
276
|
+
let pending = false;
|
|
277
|
+
const heartbeat = setInterval(() => {
|
|
278
|
+
if (pending) return;
|
|
279
|
+
pending = true;
|
|
280
|
+
inFlight = lock
|
|
281
|
+
.renew()
|
|
282
|
+
.then((held) => {
|
|
283
|
+
consecutiveFailures = 0;
|
|
284
|
+
// A renewal that finds no matching document means someone else owns the
|
|
285
|
+
// lock now — unrecoverable, so it aborts on the first occurrence.
|
|
286
|
+
if (!held && !stopped) {
|
|
287
|
+
loseLock('another run reclaimed it');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
armDeadline();
|
|
291
|
+
})
|
|
292
|
+
.catch((error) => {
|
|
293
|
+
if (stopped) return;
|
|
294
|
+
// A failing renewal may just be a blip and is only warned about here.
|
|
295
|
+
// The TTL deadline (armDeadline above) is the sole escalation: it
|
|
296
|
+
// fires strictly before the lock becomes stale-reclaimable, so
|
|
297
|
+
// failures are tolerated exactly as long as they are harmless.
|
|
298
|
+
consecutiveFailures += 1;
|
|
299
|
+
const message = errorText(error);
|
|
300
|
+
options.logger.warn(`⚠ Lock renewal failed (${consecutiveFailures} in a row): ${message}`, {
|
|
301
|
+
event: 'lock:renew-failed',
|
|
302
|
+
consecutiveFailures,
|
|
303
|
+
error: message,
|
|
304
|
+
});
|
|
305
|
+
})
|
|
306
|
+
.finally(() => {
|
|
307
|
+
pending = false;
|
|
308
|
+
});
|
|
309
|
+
}, intervalMs);
|
|
310
|
+
heartbeat.unref?.();
|
|
311
|
+
|
|
312
|
+
let result;
|
|
313
|
+
let runError;
|
|
314
|
+
let failed = false;
|
|
315
|
+
try {
|
|
316
|
+
result = await fn(controller.signal);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
failed = true;
|
|
319
|
+
runError = error;
|
|
320
|
+
} finally {
|
|
321
|
+
stopped = true;
|
|
322
|
+
clearInterval(heartbeat);
|
|
323
|
+
clearTimeout(deadline);
|
|
324
|
+
// Let any renewal already in flight settle, so no stray query outlives this
|
|
325
|
+
// call and lands after the caller has closed the client.
|
|
326
|
+
await inFlight;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
await lock.release();
|
|
331
|
+
options.onLockReleased?.();
|
|
332
|
+
} catch (releaseError) {
|
|
333
|
+
// Never let a release failure replace the reason the run failed: that would
|
|
334
|
+
// 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;
|
|
337
|
+
const message = errorText(releaseError);
|
|
338
|
+
options.logger.warn(`⚠ Failed to release the migration lock: ${message}`, {
|
|
339
|
+
event: 'lock:release-failed',
|
|
340
|
+
error: message,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (failed) throw runError;
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports = { LOCK_ID, MigrationLock, runWithLock, toLockInfo };
|