@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +409 -1
  2. package/README.md +248 -24
  3. package/bin/migronaut.js +11 -3
  4. package/bullmq.d.ts +845 -0
  5. package/bullmq.js +1 -0
  6. package/index.d.ts +757 -29
  7. package/migronaut.schema.json +191 -1
  8. package/package.json +27 -6
  9. package/src/bullmq/index.js +55 -0
  10. package/src/bullmq/jobs.js +454 -0
  11. package/src/bullmq/processor.js +608 -0
  12. package/src/bullmq/producer.js +424 -0
  13. package/src/bullmq/service.js +653 -0
  14. package/src/bullmq/wait.js +124 -0
  15. package/src/cli/args.js +12 -2
  16. package/src/cli/commands/baseline.js +45 -0
  17. package/src/cli/commands/converge.js +160 -0
  18. package/src/cli/commands/down.js +2 -0
  19. package/src/cli/commands/lock.js +2 -1
  20. package/src/cli/commands/redo.js +8 -1
  21. package/src/cli/commands/unlock.js +12 -2
  22. package/src/cli/commands/up.js +14 -1
  23. package/src/cli/exit-codes.js +10 -2
  24. package/src/cli/index.js +4 -0
  25. package/src/cli/shared.js +29 -7
  26. package/src/cli/table.js +105 -0
  27. package/src/core/audit.js +17 -3
  28. package/src/core/baseline.js +80 -0
  29. package/src/core/changelog.js +140 -24
  30. package/src/core/collections.js +372 -0
  31. package/src/core/config.js +125 -27
  32. package/src/core/converge-log.js +47 -0
  33. package/src/core/converge-plan.js +483 -0
  34. package/src/core/converge.js +867 -0
  35. package/src/core/import-runner.js +34 -6
  36. package/src/core/import.js +14 -7
  37. package/src/core/index-spec.js +496 -0
  38. package/src/core/lock-wait.js +260 -0
  39. package/src/core/lock.js +71 -20
  40. package/src/core/migrator.js +805 -304
  41. package/src/core/options.js +251 -0
  42. package/src/core/run-recorder.js +157 -0
  43. package/src/core/run.js +71 -71
  44. package/src/core/runner.js +70 -20
  45. package/src/core/sequence.js +134 -0
  46. package/src/errors/index.js +71 -1
  47. package/src/index.js +16 -0
  48. package/src/utils/actor.js +48 -0
  49. package/src/utils/canonical.js +179 -0
  50. package/src/utils/collection-name.js +21 -0
  51. package/src/utils/error.js +18 -1
  52. package/src/utils/id.js +77 -0
  53. package/src/utils/loader.js +39 -21
  54. package/src/utils/logger.js +30 -12
  55. package/src/utils/migration-name.js +32 -0
  56. package/src/utils/redact.js +57 -4
  57. package/src/utils/sanitize.js +8 -3
  58. package/src/utils/telemetry.js +393 -0
  59. package/src/utils/template.js +60 -12
@@ -0,0 +1,251 @@
1
+ const { ConfigInvalidError, MigrationInvalidNameError } = require('../errors/index.js');
2
+ const { actorIssue } = require('../utils/actor.js');
3
+ const { isCollectionName } = require('../utils/collection-name.js');
4
+
5
+ /**
6
+ * Validation of the options the kit's run methods take. Pure — no config, no
7
+ * database, no file system — so every one runs before a run method resolves
8
+ * its config or connects: a caller mistake costs neither a round trip nor the
9
+ * lock, and is reported as itself rather than as whatever it would have
10
+ * broken later.
11
+ *
12
+ * Each `assert*Options` is the whole preamble of one method, its checks in
13
+ * the order that decides which error a caller sees when several apply.
14
+ */
15
+
16
+ /**
17
+ * Reject non-string filenames before they reach a changelog query or a path
18
+ * join. A programmatic caller passing e.g. `{ $ne: null }` would otherwise
19
+ * become a query-operator injection in `findOne({ name })`.
20
+ */
21
+ function assertFilename(filename) {
22
+ if (filename !== undefined && typeof filename !== 'string') {
23
+ throw new MigrationInvalidNameError('Migration name must be a string', {
24
+ name: filename,
25
+ });
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Validate the `--steps` option for `down`/`dry-run down`: a positive integer,
31
+ * mutually exclusive with a filename and `--batch`. No-op when steps is unset.
32
+ */
33
+ function assertStepsValid(steps, filename, batch) {
34
+ if (steps === undefined) {
35
+ return;
36
+ }
37
+ if (filename) {
38
+ throw new ConfigInvalidError('Cannot combine a filename with --steps', { filename });
39
+ }
40
+ if (batch !== undefined) {
41
+ throw new ConfigInvalidError('Cannot combine --batch with --steps', { batch, steps });
42
+ }
43
+ if (!Number.isInteger(steps) || steps < 1) {
44
+ throw new ConfigInvalidError('--steps must be a positive integer', { steps });
45
+ }
46
+ }
47
+
48
+ /**
49
+ * `--to` names a point in the sequence, so it cannot be combined with the
50
+ * other ways of choosing targets.
51
+ */
52
+ function assertToValid(to, filename, options = {}) {
53
+ if (to === undefined) return;
54
+ assertFilename(to);
55
+ if (filename) {
56
+ throw new ConfigInvalidError('Cannot combine a filename with --to', { filename, to });
57
+ }
58
+ if (options.steps !== undefined) {
59
+ throw new ConfigInvalidError('Cannot combine --steps with --to', {
60
+ steps: options.steps,
61
+ to,
62
+ });
63
+ }
64
+ if (options.batch !== undefined) {
65
+ throw new ConfigInvalidError('Cannot combine --batch with --to', {
66
+ batch: options.batch,
67
+ to,
68
+ });
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Validate `--batch`. Without this a typo (`--batch abc` → NaN) matches no
74
+ * records, so the run prints "Nothing to rollback" and exits 0 — the worst
75
+ * possible answer to a mistyped rollback.
76
+ */
77
+ function assertBatchValid(batch) {
78
+ if (batch === undefined) return;
79
+ if (!Number.isInteger(batch) || batch < 1) {
80
+ throw new ConfigInvalidError('--batch must be a positive integer', { batch });
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Validate `requestedBy` / `reason`: who asked for a run, and why — stamped
86
+ * on what it writes to the changelog (and on a converge's history entry).
87
+ * The OS user that ran it is `executedBy` already; on a queue worker that is
88
+ * the container's, which is why the requester has a field of its own.
89
+ */
90
+ function assertActorValid(options) {
91
+ for (const key of ['requestedBy', 'reason']) {
92
+ const issue = actorIssue(key, options?.[key]);
93
+ if (issue) {
94
+ throw new ConfigInvalidError(issue, {
95
+ [key]:
96
+ typeof options[key] === 'string'
97
+ ? `${options[key].length} characters`
98
+ : typeof options[key],
99
+ });
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Validate `checksum`: the SHA-256 the caller expects the named file to have
106
+ * — how a queue job says which version of the file it was planned with.
107
+ */
108
+ function assertChecksumValid(checksum, filename) {
109
+ if (checksum === undefined) return;
110
+ if (typeof checksum !== 'string' || !/^[0-9a-f]{64}$/.test(checksum)) {
111
+ throw new ConfigInvalidError('checksum must be a SHA-256 hex digest', {
112
+ checksum: typeof checksum,
113
+ });
114
+ }
115
+ if (!filename) {
116
+ throw new ConfigInvalidError('checksum requires a filename', {});
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Validate `ordered`: a boolean, and only meaningful for a named file — a
122
+ * bulk run is in order by construction, so asking for it there is a caller
123
+ * mistake worth naming rather than silently ignoring.
124
+ */
125
+ function assertOrderedValid(ordered, filename) {
126
+ if (ordered === undefined) return;
127
+ if (typeof ordered !== 'boolean') {
128
+ throw new ConfigInvalidError('ordered must be a boolean', { ordered });
129
+ }
130
+ if (ordered && !filename) {
131
+ throw new ConfigInvalidError('ordered requires a filename', { ordered });
132
+ }
133
+ }
134
+
135
+ /** `up`'s `converge` option: a boolean, and only for a bulk run that reaches the head */
136
+ function assertConvergeAfterUpValid(converge, filename, to) {
137
+ if (converge === undefined) return;
138
+ if (typeof converge !== 'boolean') {
139
+ throw new ConfigInvalidError('converge must be a boolean', { converge });
140
+ }
141
+ if (converge && filename !== undefined) {
142
+ throw new ConfigInvalidError('converge cannot follow a single-file up', {
143
+ converge,
144
+ filename,
145
+ });
146
+ }
147
+ if (converge && to !== undefined) {
148
+ throw new ConfigInvalidError(
149
+ 'converge cannot follow up --to: the declared state describes the newest migration',
150
+ { converge, to },
151
+ );
152
+ }
153
+ }
154
+
155
+ /** `up(filename, options)` */
156
+ function assertUpOptions(filename, options) {
157
+ assertFilename(filename);
158
+ // `to` is checked against a filename only: unlike `down`, an explicit
159
+ // `batch` here is a label for whatever gets applied, not a selector.
160
+ assertToValid(options.to, filename);
161
+ assertBatchValid(options.batch);
162
+ if (options.batch !== undefined && options.step) {
163
+ throw new ConfigInvalidError('Cannot combine --batch with --step', {
164
+ batch: options.batch,
165
+ });
166
+ }
167
+ assertOrderedValid(options.ordered, filename);
168
+ assertConvergeAfterUpValid(options.converge, filename, options.to);
169
+ assertChecksumValid(options.checksum, filename);
170
+ assertActorValid(options);
171
+ }
172
+
173
+ /** `down(filename, options)` */
174
+ function assertDownOptions(filename, options) {
175
+ assertFilename(filename);
176
+ assertStepsValid(options.steps, filename, options.batch);
177
+ assertBatchValid(options.batch);
178
+ assertToValid(options.to, filename, options);
179
+ assertOrderedValid(options.ordered, filename);
180
+ assertActorValid(options);
181
+ }
182
+
183
+ /** `redo(filename, options)` */
184
+ function assertRedoOptions(filename, options) {
185
+ assertFilename(filename);
186
+ assertActorValid(options);
187
+ }
188
+
189
+ /** `dryRun(direction, filename, options)` */
190
+ function assertDryRunOptions(filename, options) {
191
+ assertFilename(filename);
192
+ // `batch`/`to` must be passed too, or a conflict that `down` rejects would
193
+ // be silently allowed in its own preview.
194
+ assertStepsValid(options.steps, filename, options.batch);
195
+ assertBatchValid(options.batch);
196
+ assertToValid(options.to, filename, options);
197
+ }
198
+
199
+ /** `converge(options)` */
200
+ function assertConvergeOptions(options) {
201
+ for (const key of ['dryRun', 'prune', 'noLock', 'ordered', 'rebuildUnique']) {
202
+ if (options[key] !== undefined && typeof options[key] !== 'boolean') {
203
+ throw new ConfigInvalidError(`${key} must be a boolean`, { [key]: options[key] });
204
+ }
205
+ }
206
+ assertActorValid(options);
207
+ }
208
+
209
+ /** `convergeHistory({ limit })` */
210
+ function assertHistoryLimit(limit) {
211
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
212
+ throw new ConfigInvalidError('limit must be an integer from 1 to 1000', { limit });
213
+ }
214
+ }
215
+
216
+ /** `list(filter, options)` */
217
+ function assertListOptions(filter, options) {
218
+ if (options.checksums !== undefined && typeof options.checksums !== 'boolean') {
219
+ throw new ConfigInvalidError('checksums must be a boolean', { checksums: options.checksums });
220
+ }
221
+ // An unknown filter silently returning [] reads as "nothing to report" —
222
+ // the worst possible answer to a typo.
223
+ if (filter !== 'all' && filter !== 'pending' && filter !== 'applied') {
224
+ throw new ConfigInvalidError("list filter must be 'all', 'pending' or 'applied'", { filter });
225
+ }
226
+ }
227
+
228
+ /**
229
+ * `import(options)`: a bad --from/--to must not cost a round trip or take the
230
+ * lock. Defaults come from the already-validated config.
231
+ */
232
+ function assertImportOptions(options) {
233
+ if (options.from !== undefined && !isCollectionName(options.from)) {
234
+ throw new ConfigInvalidError('Invalid source collection name', { from: options.from });
235
+ }
236
+ if (options.to !== undefined && !isCollectionName(options.to)) {
237
+ throw new ConfigInvalidError('Invalid target collection name', { to: options.to });
238
+ }
239
+ }
240
+
241
+ module.exports = {
242
+ assertConvergeOptions,
243
+ assertDownOptions,
244
+ assertDryRunOptions,
245
+ assertFilename,
246
+ assertHistoryLimit,
247
+ assertImportOptions,
248
+ assertListOptions,
249
+ assertRedoOptions,
250
+ assertUpOptions,
251
+ };
@@ -0,0 +1,157 @@
1
+ const { LockAlreadyHeldError, MigronautError } = require('../errors/index.js');
2
+ const { errorText } = require('../utils/error.js');
3
+ const { ATTRIBUTES } = require('../utils/telemetry.js');
4
+
5
+ /**
6
+ * The record one run under the lock leaves behind: its `run:start` /
7
+ * `run:end` and lock events, its run metrics, the end of its span, and the
8
+ * closing "Done" line. Kept apart from MigratorKit#withLock, which is left
9
+ * with what is its own — reentrancy, the abort wiring, and the lock.
10
+ *
11
+ * Built from the kit's own capabilities: `emit` (runId stamped, listeners
12
+ * contained), `telemetry` (a no-op without one), the resolved `logger` and
13
+ * `fields`. All of them are guarded, so nothing recorded here can fail a run.
14
+ * The span itself is opened by the kit — the run span is one of its two wrap
15
+ * sites — and handed over with {@link RunRecorder#spanOpened}.
16
+ */
17
+ class RunRecorder {
18
+ #info;
19
+ #runId;
20
+ #telemetry;
21
+ #emit;
22
+ #logger;
23
+ #fields;
24
+ #startedAt;
25
+ /** What the lock reported when it was acquired — the run span's lock attributes */
26
+ #acquired;
27
+ /** The first reason the lock was lost */
28
+ #lostReason;
29
+ /** Set once the lock is held and the run span is open */
30
+ #span;
31
+
32
+ constructor({ info, runId, telemetry, emit, logger, fields }) {
33
+ this.#info = info;
34
+ this.#runId = runId;
35
+ this.#telemetry = telemetry;
36
+ this.#emit = emit;
37
+ this.#logger = logger;
38
+ this.#fields = fields;
39
+ }
40
+
41
+ start() {
42
+ this.#startedAt = Date.now();
43
+ this.#emit('run:start', { ...this.#info });
44
+ }
45
+
46
+ lockAcquired(extra) {
47
+ this.#acquired = extra;
48
+ if (typeof extra?.acquireMs === 'number') this.#telemetry.lockAcquired(extra.acquireMs);
49
+ this.#emit('lock:acquired', { owner: this.#runId, ...extra });
50
+ }
51
+
52
+ lockReleased(extra) {
53
+ this.#emit('lock:released', { owner: this.#runId, ...extra });
54
+ }
55
+
56
+ lockLost(reason) {
57
+ // The heartbeat and the TTL deadline can each report the same loss: the
58
+ // first reason is the cause, and it is one lost lock.
59
+ if (this.#lostReason === undefined) {
60
+ this.#lostReason = reason;
61
+ this.#telemetry.lockLost();
62
+ }
63
+ this.#emit('lock:lost', { owner: this.#runId, reason });
64
+ }
65
+
66
+ /** The run span's attributes — complete once the lock is held */
67
+ spanAttributes() {
68
+ return {
69
+ [ATTRIBUTES.RUN_ID]: this.#runId,
70
+ [ATTRIBUTES.RUN_COMMAND]: this.#info.command,
71
+ [ATTRIBUTES.RUN_DIRECTION]: this.#info.direction,
72
+ [ATTRIBUTES.LOCK_ACQUIRE_MS]: this.#acquired?.acquireMs,
73
+ [ATTRIBUTES.LOCK_SKIPPED]: this.#acquired?.skipped,
74
+ };
75
+ }
76
+
77
+ spanOpened(span) {
78
+ this.#span = span;
79
+ }
80
+
81
+ /** Close the record: `result` on success, `failure` (what the run threw) otherwise */
82
+ finish(result, failure) {
83
+ // Result counts, so a metrics subscriber gets "3 applied in 812ms"
84
+ // without reconstructing it from per-migration events. On the failure
85
+ // path the partial rows live on the error's context — exactly the case
86
+ // where "how far did it get?" is the question, so they count too.
87
+ const rows = Array.isArray(result)
88
+ ? result
89
+ : failure instanceof MigronautError && Array.isArray(failure.context?.results)
90
+ ? failure.context.results
91
+ : null;
92
+ const { summary, skipped } = countRows(rows);
93
+ const durationMs = Date.now() - this.#startedAt;
94
+ const telemetry = this.#telemetry;
95
+ if (this.#span) {
96
+ this.#span.finish(
97
+ {
98
+ [ATTRIBUTES.RUN_APPLIED]: summary.applied,
99
+ [ATTRIBUTES.RUN_REVERTED]: summary.reverted,
100
+ [ATTRIBUTES.RUN_SKIPPED]: skipped,
101
+ [ATTRIBUTES.RUN_TOTAL]: summary.total,
102
+ [ATTRIBUTES.LOCK_LOST_REASON]: this.#lostReason,
103
+ },
104
+ failure,
105
+ );
106
+ telemetry.runEnded({ ...this.#info, durationMs, error: failure });
107
+ } else if (failure instanceof LockAlreadyHeldError) {
108
+ // Never held the lock, so there is no run to time — only a refusal to
109
+ // count. Any other failure this early (an unreachable database) is the
110
+ // caller's to report; it is not contention.
111
+ telemetry.lockRefused();
112
+ }
113
+ this.#emit('run:end', {
114
+ ...this.#info,
115
+ success: failure === undefined,
116
+ durationMs,
117
+ ...summary,
118
+ // A raw Error here would hand subscribers an unredacted driver message
119
+ // (which can echo the credentialed URI) — errorText is the same
120
+ // chokepoint every log line and result row already goes through.
121
+ ...(failure ? { error: errorText(failure) } : {}),
122
+ });
123
+ // One human rollup after the per-migration lines: total wall-clock time
124
+ // (lock wait and hooks included) is otherwise unobtainable from the
125
+ // output — per-file durations exclude all overhead. Success path only;
126
+ // a failure already ends with its own error line.
127
+ if (failure === undefined && (summary.applied || summary.reverted)) {
128
+ const parts = [];
129
+ if (summary.applied) parts.push(`${summary.applied} applied`);
130
+ if (summary.reverted) parts.push(`${summary.reverted} reverted`);
131
+ this.#logger.info(
132
+ `✔ Done ${parts.join(', ')} in ${durationMs}ms`,
133
+ this.#fields({ ...this.#info, ...summary, durationMs }),
134
+ );
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * `{ summary: { applied, reverted, total }, skipped }` of a run's rows, in one
141
+ * pass — `summary` is empty when the run produced no rows (a converge, a
142
+ * failure before the first migration).
143
+ */
144
+ function countRows(rows) {
145
+ if (!rows) return { summary: {}, skipped: undefined };
146
+ let applied = 0;
147
+ let reverted = 0;
148
+ let skipped = 0;
149
+ for (const row of rows) {
150
+ if (row.status === 'applied') applied += 1;
151
+ else if (row.status === 'reverted') reverted += 1;
152
+ else if (row.status === 'skipped') skipped += 1;
153
+ }
154
+ return { summary: { applied, reverted, total: rows.length }, skipped };
155
+ }
156
+
157
+ module.exports = { RunRecorder };
package/src/core/run.js CHANGED
@@ -1,21 +1,7 @@
1
- const { setTimeout: delay } = require('node:timers/promises');
2
- const { ConfigInvalidError, LockAlreadyHeldError } = require('../errors/index.js');
3
- const { MigratorKit } = require('./migrator.js');
4
-
5
- /**
6
- * Longer than the default 60s lock TTL on purpose: with a shorter budget, a peer
7
- * migration that outlives it makes every waiting instance fail to boot, even
8
- * though the peer is healthy and still holding a valid lock.
9
- */
10
- const DEFAULT_LOCK_WAIT_TIMEOUT_MS = 90_000;
11
- const DEFAULT_LOCK_POLL_INTERVAL_MS = 500;
12
- /** ±25% jitter so N instances booting together stop polling in lockstep */
13
- const POLL_JITTER_RATIO = 0.25;
14
-
15
- function jitteredDelay(baseMs) {
16
- const spread = baseMs * POLL_JITTER_RATIO;
17
- return Math.max(1, Math.round(baseMs - spread + Math.random() * spread * 2));
18
- }
1
+ const { ConfigInvalidError, RunAbortedError } = require('../errors/index.js');
2
+ const { errorText } = require('../utils/error.js');
3
+ const { assertLockWaitOptions, withLockWait } = require('./lock-wait.js');
4
+ const { MigratorKit, RECORD_LOCK_WAIT } = require('./migrator.js');
19
5
 
20
6
  /**
21
7
  * Run all pending migrations and return a summary — the blessed one-call entry
@@ -31,7 +17,15 @@ function jitteredDelay(baseMs) {
31
17
  *
32
18
  * For multi-instance deploys, set `onLockHeld: 'wait'` so instances that lose
33
19
  * the race to acquire the lock block until the migrating peer finishes, then
34
- * confirm there is nothing left to apply.
20
+ * confirm there is nothing left to apply. Pass a `signal` (wired to SIGTERM)
21
+ * so a pod being shut down stops waiting — and stops between migrations if it
22
+ * already holds the lock — instead of taking the lock just before SIGKILL.
23
+ *
24
+ * Running several kits against several databases in ONE process: pass
25
+ * `envFile: false` and supply `uri`/`dbName` directly. `.env` loading mutates
26
+ * the shared process.env (dotenv semantics, override: false), so two kits
27
+ * with different env files would otherwise leak `MIGRONAUT_*` values into each
28
+ * other's config resolution.
35
29
  *
36
30
  * @example
37
31
  * ```js
@@ -48,69 +42,75 @@ async function runMigrations(config = {}, options = {}) {
48
42
  const {
49
43
  noLock,
50
44
  onLockHeld = 'throw',
51
- lockWaitTimeoutMs = DEFAULT_LOCK_WAIT_TIMEOUT_MS,
52
- lockPollIntervalMs = DEFAULT_LOCK_POLL_INTERVAL_MS,
45
+ // Left undefined unless given: the default then follows the holder's TTL.
46
+ lockWaitTimeoutMs,
47
+ lockPollIntervalMs,
48
+ onKit,
49
+ signal,
53
50
  ...kitOptions
54
51
  } = options;
55
52
 
56
- // Validated before anything connects. A NaN here (or any non-positive value)
57
- // disables every deadline comparison below — `NaN > deadline` is always
58
- // false — turning the wait loop into an unbounded retry storm against the
59
- // lock collection that never returns and never surfaces the real error.
60
- if (!Number.isFinite(lockWaitTimeoutMs) || lockWaitTimeoutMs <= 0) {
61
- throw new ConfigInvalidError('lockWaitTimeoutMs must be a positive finite number', {
62
- lockWaitTimeoutMs,
63
- });
53
+ if (onKit !== undefined && typeof onKit !== 'function') {
54
+ throw new ConfigInvalidError('onKit must be a function', { onKit: typeof onKit });
64
55
  }
65
- if (!Number.isFinite(lockPollIntervalMs) || lockPollIntervalMs <= 0) {
66
- throw new ConfigInvalidError('lockPollIntervalMs must be a positive finite number', {
67
- lockPollIntervalMs,
68
- });
56
+ if (signal !== undefined && !(signal instanceof AbortSignal)) {
57
+ throw new ConfigInvalidError('signal must be an AbortSignal', { signal: typeof signal });
69
58
  }
70
59
 
60
+ // Validated before anything connects — see assertLockWaitOptions.
61
+ assertLockWaitOptions({ onLockHeld, lockWaitTimeoutMs, lockPollIntervalMs });
62
+
71
63
  const kit = new MigratorKit(config, kitOptions);
72
- let waited = false;
73
- let waitedMs = 0;
74
- let attempts = 0;
64
+ // Handed out before connect so listeners catch every lifecycle event —
65
+ // this is the metrics/alerting injection point for apps that embed
66
+ // runMigrations and cannot reach the internally-constructed kit otherwise.
67
+ onKit?.(kit);
68
+ // `up` keeps returning the migration rows; with `convergeAfterUp` the
69
+ // converge outcome rides along in the summary, from the kit's own event.
70
+ let converge;
71
+ kit.on('converge:end', (event) => {
72
+ if (event.trigger === 'up' && event.success) converge = event.result;
73
+ });
75
74
 
75
+ // An abort reaches the run wherever it is: the wait loop sees the signal
76
+ // between polls, and kit.stop() stops a run that is setting up or between
77
+ // migrations (one already executing finishes — as stop() always has).
78
+ const onAbort = () => kit.stop(errorText(signal.reason ?? 'Aborted'));
79
+ signal?.addEventListener('abort', onAbort, { once: true });
76
80
  try {
77
- await kit.connect();
78
- // Resolved AFTER connect, from the kit's own merged config: a `logger:
79
- // null` in the config file must silence this module's lines too, not only
80
- // the kit's own.
81
- const logger = kit.logger;
82
- // The clock starts at the first contention, not before the first attempt —
83
- // otherwise a slow initial attempt eats the whole waiting budget.
84
- let deadline;
85
-
86
- for (;;) {
87
- try {
88
- attempts += 1;
89
- const applied = await kit.up(undefined, noLock ? { noLock: true } : {});
90
- return { applied, upToDate: applied.length === 0, waited, waitedMs, attempts };
91
- } catch (error) {
92
- if (onLockHeld !== 'wait' || !(error instanceof LockAlreadyHeldError)) {
93
- throw error;
94
- }
95
- deadline ??= Date.now() + lockWaitTimeoutMs;
96
- const nextDelay = jitteredDelay(lockPollIntervalMs);
97
- if (Date.now() + nextDelay > deadline) {
98
- throw error;
99
- }
100
- if (!waited) {
101
- logger.info('Migration lock held by another process — waiting for it to release…');
102
- }
103
- waited = true;
104
- logger.debug('Migration lock still held — retrying', {
105
- attempts,
106
- waitedMs,
107
- nextDelayMs: nextDelay,
108
- });
109
- waitedMs += nextDelay;
110
- await delay(nextDelay);
111
- }
81
+ if (signal?.aborted) {
82
+ throw new RunAbortedError('Aborted before the run started', {
83
+ reason: errorText(signal.reason ?? 'aborted'),
84
+ results: [],
85
+ });
112
86
  }
87
+ await kit.connect();
88
+ const {
89
+ result: applied,
90
+ waited,
91
+ waitedMs,
92
+ attempts,
93
+ } = await withLockWait(() => kit.up(undefined, noLock ? { noLock: true } : {}), {
94
+ onLockHeld,
95
+ ...(lockWaitTimeoutMs !== undefined ? { lockWaitTimeoutMs } : {}),
96
+ ...(lockPollIntervalMs !== undefined ? { lockPollIntervalMs } : {}),
97
+ // Resolved AFTER connect, from the kit's own merged config: a `logger:
98
+ // null` in the config file must silence the wait lines too, not only
99
+ // the kit's own.
100
+ logger: kit.logger,
101
+ ...(signal ? { signal } : {}),
102
+ onSettle: (wait) => kit[RECORD_LOCK_WAIT](wait),
103
+ });
104
+ return {
105
+ applied,
106
+ upToDate: applied.length === 0,
107
+ waited,
108
+ waitedMs,
109
+ attempts,
110
+ ...(converge ? { converge } : {}),
111
+ };
113
112
  } finally {
113
+ signal?.removeEventListener('abort', onAbort);
114
114
  await kit.disconnect().catch(() => undefined);
115
115
  }
116
116
  }