@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.
@@ -27,9 +27,9 @@ function isTransactionsUnsupported(error) {
27
27
  * timeout buys is the *run* stopping instead of hanging forever — which also
28
28
  * lets the lock's TTL expire, so a wedged migration no longer blocks every
29
29
  * other instance indefinitely. Migrations that need real cancellation should
30
- * watch `ctx.signal`.
30
+ * watch `ctx.signal`, which `onTimeout` aborts when the timer fires.
31
31
  */
32
- async function withTimeout(promise, timeoutMs, name, direction) {
32
+ async function withTimeout(promise, timeoutMs, name, direction, onTimeout) {
33
33
  if (!timeoutMs) return promise;
34
34
  let timer;
35
35
  try {
@@ -43,16 +43,19 @@ async function withTimeout(promise, timeoutMs, name, direction) {
43
43
  // unhandledRejection long after the run already reported the
44
44
  // timeout. Swallow it: the timeout is the reported failure.
45
45
  Promise.resolve(promise).catch(() => {});
46
- reject(
47
- new MigrationTimeoutError(
48
- `Migration ${direction} timed out after ${timeoutMs}ms: ${name}`,
49
- {
50
- name,
51
- direction,
52
- timeoutMs,
53
- },
54
- ),
46
+ const timeoutError = new MigrationTimeoutError(
47
+ `Migration ${direction} timed out after ${timeoutMs}ms: ${name}`,
48
+ {
49
+ name,
50
+ direction,
51
+ timeoutMs,
52
+ },
55
53
  );
54
+ // Told, not just abandoned: the caller aborts the context's signal
55
+ // with this error, so a body that watches ctx.signal can stop
56
+ // writing instead of racing whoever acquires the lock next.
57
+ onTimeout?.(timeoutError);
58
+ reject(timeoutError);
56
59
  }, timeoutMs);
57
60
  timer.unref?.();
58
61
  }),
@@ -77,7 +80,10 @@ async function withTimeout(promise, timeoutMs, name, direction) {
77
80
  *
78
81
  * On any error the `onError` hook is invoked before a
79
82
  * MigrationExecutionFailedError is thrown — the error is never swallowed, and a
80
- * throwing hook cannot mask the original failure.
83
+ * throwing hook cannot mask the original failure. The one exception: when the
84
+ * body succeeded and only the (non-transactional) changelog write failed, the
85
+ * error carries `context.phase = 'changelog-write'` and `onError` is not fired —
86
+ * the migration itself did not fail.
81
87
  */
82
88
  async function runMigration(params) {
83
89
  const { name, migration, direction, context, useTransaction, hooks, onSuccess, logger } = params;
@@ -87,30 +93,71 @@ async function runMigration(params) {
87
93
 
88
94
  const start = Date.now();
89
95
  let session;
90
- let runtimeContext = context;
96
+ // JavaScript cannot cancel a running body, but it can tell it to stop: this
97
+ // controller feeds the context's signal, so the documented "watch ctx.signal"
98
+ // advice covers the migration's own timeout too — not only lock loss and
99
+ // stop(). Without it a timed-out body keeps writing after the lock is
100
+ // released, racing whoever acquires it next.
101
+ const timedOut = new AbortController();
102
+ const signal = context.signal
103
+ ? AbortSignal.any([context.signal, timedOut.signal])
104
+ : timedOut.signal;
105
+ let runtimeContext = { ...context, signal };
106
+ const onTimeout = (timeoutError) => timedOut.abort(timeoutError);
91
107
  let duration = 0;
108
+ // 'body' while the migration's own code runs; 'changelog' once it committed
109
+ // and only the record write remains. The two failures need different
110
+ // reporting: a changelog failure after a committed body must not read as
111
+ // "the migration failed" — that invites a re-run of already-applied writes.
112
+ let phase = 'body';
92
113
 
93
114
  try {
94
115
  if (useTransaction) {
95
116
  session = context.client.startSession();
96
- runtimeContext = { ...context, session };
117
+ runtimeContext = { ...runtimeContext, session };
97
118
  // withTransaction may run the body more than once when the driver retries
98
- // a transient failure, so duration is re-measured on each attempt.
119
+ // a transient failure, so duration is re-measured on each attempt. The
120
+ // changelog write stays inside the transaction, so a failure there
121
+ // aborts the body's writes too — 'body' phase is accurate throughout.
99
122
  await session.withTransaction(async () => {
100
123
  const attemptStart = Date.now();
101
- await withTimeout(fn(runtimeContext), timeoutMs, name, direction);
124
+ await withTimeout(fn(runtimeContext), timeoutMs, name, direction, onTimeout);
102
125
  duration = Date.now() - attemptStart;
103
126
  await onSuccess?.(duration, session);
104
127
  });
105
128
  } else {
106
- await withTimeout(fn(runtimeContext), timeoutMs, name, direction);
129
+ await withTimeout(fn(runtimeContext), timeoutMs, name, direction, onTimeout);
107
130
  duration = Date.now() - start;
131
+ phase = 'changelog';
108
132
  await onSuccess?.(duration, undefined);
109
133
  }
110
134
 
111
135
  return { duration };
112
136
  } catch (error) {
113
137
  const err = error instanceof Error ? error : new Error(String(error));
138
+ // Failures deserve timing data as much as successes — a slow-then-failing
139
+ // migration is exactly what an operator alerts on.
140
+ const elapsed = Date.now() - start;
141
+
142
+ // Without a transaction the body's writes are already committed when the
143
+ // changelog write fails — say exactly that, instead of the generic
144
+ // "migration failed" that would invite re-running committed writes. The
145
+ // onError hook is for migration failures, so it does not fire here.
146
+ if (phase === 'changelog') {
147
+ throw new MigrationExecutionFailedError(
148
+ `Migration ${direction} succeeded but recording it in the changelog failed: ${name} — ` +
149
+ 'its own writes are committed; verify the changelog before re-running',
150
+ {
151
+ name,
152
+ direction,
153
+ phase: 'changelog-write',
154
+ bodySucceeded: true,
155
+ durationMs: elapsed,
156
+ cause: err.message,
157
+ },
158
+ { cause: err },
159
+ );
160
+ }
114
161
 
115
162
  if (hooks?.onError) {
116
163
  // A throwing onError hook must not replace the real cause.
@@ -122,14 +169,17 @@ async function runMigration(params) {
122
169
  }
123
170
  }
124
171
 
125
- if (err instanceof MigrationTimeoutError) throw err;
172
+ if (err instanceof MigrationTimeoutError) {
173
+ err.context = { durationMs: elapsed, ...err.context };
174
+ throw err;
175
+ }
126
176
  // A standalone deployment refusing the transaction is a topology problem,
127
177
  // not a bug in the migration — say so instead of blaming the file.
128
178
  if (useTransaction && isTransactionsUnsupported(err)) {
129
179
  throw new TransactionsUnsupportedError(
130
180
  `Cannot run ${name} in a transaction — this deployment is standalone. ` +
131
181
  'Set useTransaction: false, or run against a replica set / mongos.',
132
- { name, direction, cause: err.message },
182
+ { name, direction, durationMs: elapsed, cause: err.message },
133
183
  { cause: err },
134
184
  );
135
185
  }
@@ -137,7 +187,7 @@ async function runMigration(params) {
137
187
  `Migration ${direction} failed: ${name}`,
138
188
  // The message is duplicated into context because that is what survives
139
189
  // JSON serialization; `cause` keeps the real Error (and its stack).
140
- { name, direction, cause: err.message },
190
+ { name, direction, durationMs: elapsed, cause: err.message },
141
191
  { cause: err },
142
192
  );
143
193
  } finally {
@@ -183,7 +183,7 @@ class ImportTargetNotEmptyError extends MigronautError {
183
183
  }
184
184
  }
185
185
 
186
- /** Thrown when attempting to roll back a migrate-mongo-imported (forward-only) migration */
186
+ /** Thrown when attempting to roll back a forward-only (imported or baselined) migration */
187
187
  class IrreversibleMigrationError extends MigronautError {
188
188
  constructor(message, context, options) {
189
189
  super('MIGRATION_IRREVERSIBLE', message, context, options);
@@ -191,6 +191,19 @@ class IrreversibleMigrationError extends MigronautError {
191
191
  }
192
192
  }
193
193
 
194
+ /**
195
+ * Thrown by a bulk `up` under `onOutOfOrder: 'error'` when a pending migration
196
+ * sorts before the newest applied one — a file merged late from a parallel
197
+ * branch, which would otherwise run after migrations authored later and leave
198
+ * environments with different effective apply orders.
199
+ */
200
+ class OutOfOrderMigrationError extends MigronautError {
201
+ constructor(message, context, options) {
202
+ super('MIGRATION_OUT_OF_ORDER', message, context, options);
203
+ this.name = 'OutOfOrderMigrationError';
204
+ }
205
+ }
206
+
194
207
  module.exports = {
195
208
  MigronautError,
196
209
  LockAlreadyHeldError,
@@ -212,4 +225,5 @@ module.exports = {
212
225
  NotAppliedError,
213
226
  ImportTargetNotEmptyError,
214
227
  IrreversibleMigrationError,
228
+ OutOfOrderMigrationError,
215
229
  };
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const { EXIT_CODES } = require('./cli/exit-codes.js');
2
2
  const { MigratorKit } = require('./core/migrator.js');
3
3
  const { pendingMigrations, runMigrations } = require('./core/run.js');
4
+ const { createLogger } = require('./utils/logger.js');
4
5
  const {
5
6
  ChecksumMismatchError,
6
7
  ConfigFileExistsError,
@@ -21,6 +22,7 @@ const {
21
22
  TransactionsUnsupportedError,
22
23
  MigronautError,
23
24
  NotAppliedError,
25
+ OutOfOrderMigrationError,
24
26
  RunAbortedError,
25
27
  } = require('./errors/index.js');
26
28
 
@@ -32,6 +34,11 @@ module.exports = {
32
34
  pendingMigrations,
33
35
  runMigrations,
34
36
 
37
+ // The default console logger, for programmatic callers who want migronaut's
38
+ // own output at a chosen level (e.g. createLogger(process.stdout, 'debug'))
39
+ // without hand-writing a four-method logger
40
+ createLogger,
41
+
35
42
  // The CLI's exit-code map, for wrappers that mirror its semantics
36
43
  EXIT_CODES,
37
44
 
@@ -55,5 +62,6 @@ module.exports = {
55
62
  TransactionsUnsupportedError,
56
63
  MigronautError,
57
64
  NotAppliedError,
65
+ OutOfOrderMigrationError,
58
66
  RunAbortedError,
59
67
  };
@@ -52,6 +52,13 @@ function createLogger(stream = process.stdout, level = 'info') {
52
52
 
53
53
  const hasMethod = (value, name) => typeof value?.[name] === 'function';
54
54
 
55
+ /** A sink is usable when it exposes any of the four level methods */
56
+ const isUsableSink = (value) =>
57
+ hasMethod(value, 'info') ||
58
+ hasMethod(value, 'debug') ||
59
+ hasMethod(value, 'warn') ||
60
+ hasMethod(value, 'error');
61
+
55
62
  /**
56
63
  * Wrap a sink method so a throwing user logger can never break a migration run.
57
64
  * `pinoStyle` swaps the argument order to `(fields, msg)`, which is what pino
@@ -75,10 +82,12 @@ const adapters = new WeakMap();
75
82
  * Resolve the effective logger from a config value: `null` → silent,
76
83
  * `undefined` → default console logger, otherwise the user's logger adapted
77
84
  * to the four-method surface. A pino-style `child` is bound once with a
78
- * `component` field; a missing `debug`/`warn`/`error` falls back to `info`
79
- * (or `debug` when only that exists), and every call is guarded so a
80
- * throwing logger can never abort a half-applied run. A structurally unfit
81
- * value (no `info`/`debug` function) silences output instead of crashing.
85
+ * `component` field; a missing method falls back to the nearest present one
86
+ * of similar severity (a warn/error-only logger keeps its warn/error output —
87
+ * its missing debug/info become no-ops, never a reason to silence failures),
88
+ * and every call is guarded so a throwing logger can never abort a
89
+ * half-applied run. A structurally unfit value (no level method at all)
90
+ * silences output instead of crashing.
82
91
  *
83
92
  * A logger exposing `child()` is treated as pino-style, so structured fields
84
93
  * are passed as the first argument rather than the second.
@@ -86,21 +95,30 @@ const adapters = new WeakMap();
86
95
  function resolveLogger(logger) {
87
96
  if (logger === null) return silentLogger;
88
97
  if (logger === undefined) return createLogger();
89
- if (typeof logger !== 'object' || (!hasMethod(logger, 'info') && !hasMethod(logger, 'debug'))) {
98
+ if (typeof logger !== 'object' || !isUsableSink(logger)) {
90
99
  return silentLogger;
91
100
  }
92
101
  const cached = adapters.get(logger);
93
102
  if (cached !== undefined) return cached;
94
103
  const pinoStyle = hasMethod(logger, 'child');
95
104
  const child = pinoStyle ? logger.child({ component: 'migronaut' }) : null;
96
- const sink = child && (hasMethod(child, 'info') || hasMethod(child, 'debug')) ? child : logger;
97
- const base = hasMethod(sink, 'info') ? sink.info.bind(sink) : sink.debug.bind(sink);
98
- const pick = (name) => (hasMethod(sink, name) ? sink[name].bind(sink) : base);
105
+ const sink = child && isUsableSink(child) ? child : logger;
106
+ const noop = () => {};
107
+ // Fallback preference per level: same severity first, then the neighbors a
108
+ // reader of that sink would expect. debug/info never escalate to warn/error
109
+ // (running commentary must not masquerade as problems); warn/error always
110
+ // find SOME sink so failures stay visible.
111
+ const pick = (order) => {
112
+ for (const name of order) {
113
+ if (hasMethod(sink, name)) return sink[name].bind(sink);
114
+ }
115
+ return noop;
116
+ };
99
117
  const adapter = {
100
- debug: guard(pick('debug'), pinoStyle),
101
- info: guard(pick('info'), pinoStyle),
102
- warn: guard(pick('warn'), pinoStyle),
103
- error: guard(pick('error'), pinoStyle),
118
+ debug: guard(pick(['debug', 'info']), pinoStyle),
119
+ info: guard(pick(['info', 'debug']), pinoStyle),
120
+ warn: guard(pick(['warn', 'info', 'debug', 'error']), pinoStyle),
121
+ error: guard(pick(['error', 'warn', 'info', 'debug']), pinoStyle),
104
122
  };
105
123
  adapters.set(logger, adapter);
106
124
  return adapter;
@@ -8,12 +8,45 @@
8
8
  * and the URI may sit anywhere inside a larger message (unlike
9
9
  * `maskUriCredentials` in template.js, which is anchored to a whole-string URI).
10
10
  */
11
- const URI_CREDENTIALS = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^:@/\s]+):([^@/\s]+)@/g;
11
+ const URI_CREDENTIALS = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^:@/\s]*):([^@/\s]+)@/g;
12
12
 
13
- /** Mask `scheme://user:secret@` as `scheme://user:****@` anywhere in `text` */
13
+ /**
14
+ * Query parameters whose value is a secret. The userinfo form is not the only
15
+ * place a MongoDB URI carries credentials: TLS key passphrases and proxy
16
+ * passwords travel as plain query parameters and would otherwise survive
17
+ * redaction into logs, error context and `--json` output.
18
+ */
19
+ const URI_QUERY_SECRETS =
20
+ /([?&](?:tlsCertificateKeyFilePassword|proxyPassword|sslKeyPassword)=)[^&\s]+/gi;
21
+
22
+ /**
23
+ * `authMechanismProperties` is a comma-separated `KEY:VALUE` list; only the
24
+ * values of secret-bearing keys (AWS_SESSION_TOKEN et al.) are masked, so
25
+ * non-secret properties (SERVICE_NAME, …) stay readable.
26
+ */
27
+ const AUTH_MECHANISM_PROPS = /([?&]authMechanismProperties=)([^&\s]+)/gi;
28
+ const SENSITIVE_PROP_KEY = /TOKEN|SECRET|PASSWORD/i;
29
+
30
+ /**
31
+ * Mask credentials anywhere in `text`: `scheme://user:secret@` (an empty
32
+ * username still hides the password), secret-bearing query parameters, and
33
+ * secret values inside `authMechanismProperties`.
34
+ */
14
35
  function redactUris(text) {
15
36
  if (typeof text !== 'string') return text;
16
- return text.replace(URI_CREDENTIALS, '$1$2:****@');
37
+ return text
38
+ .replace(URI_CREDENTIALS, '$1$2:****@')
39
+ .replace(URI_QUERY_SECRETS, '$1****')
40
+ .replace(AUTH_MECHANISM_PROPS, (_match, prefix, value) => {
41
+ const pairs = value.split(',');
42
+ for (let i = 0; i < pairs.length; i++) {
43
+ const colon = pairs[i].indexOf(':');
44
+ if (colon === -1) continue;
45
+ const key = pairs[i].slice(0, colon);
46
+ if (SENSITIVE_PROP_KEY.test(key)) pairs[i] = `${key}:****`;
47
+ }
48
+ return `${prefix}${pairs.join(',')}`;
49
+ });
17
50
  }
18
51
 
19
52
  /**
@@ -8,9 +8,14 @@
8
8
  * clear the screen, or restyle everything printed after them.
9
9
  */
10
10
 
11
- /** Control characters stripped from untrusted values (tab and newline survive) */
11
+ /**
12
+ * Control characters stripped from untrusted values (tab and newline survive).
13
+ * The full C1 block (U+0080-U+009F) is included: CSI, OSC, DCS, PM and APC are
14
+ * single-codepoint escape introducers on terminals that decode C1, and none of
15
+ * them is ever legitimate text.
16
+ */
12
17
  // oxlint-disable-next-line no-control-regex -- stripping control characters is the point
13
- const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f\u009b]/g;
18
+ const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f\u0080-\u009f]/g;
14
19
 
15
20
  /**
16
21
  * SGR color sequences (`ESC[…m`) to preserve, or a control character to drop.
@@ -19,7 +24,7 @@ const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f\u009b]/g;
19
24
  * titles — has no legitimate reason to be in a log line.
20
25
  */
21
26
  // oxlint-disable-next-line no-control-regex -- stripping control characters is the point
22
- const SGR_OR_CONTROL = /(\u001b\[[0-9;]*m)|[\u0000-\u0008\u000b-\u001f\u007f\u009b]/g;
27
+ const SGR_OR_CONTROL = /(\u001b\[[0-9;]*m)|[\u0000-\u0008\u000b-\u001f\u007f\u0080-\u009f]/g;
23
28
 
24
29
  /**
25
30
  * Strip every terminal control character from an untrusted value. For data
@@ -8,6 +8,7 @@ const {
8
8
  } = require('../errors/index.js');
9
9
  const { formatStamp } = require('./date.js');
10
10
  const { errorText } = require('./error.js');
11
+ const { redactUris } = require('./redact.js');
11
12
 
12
13
  /**
13
14
  * Convert an arbitrary migration name into a kebab-case slug. Unicode-aware —
@@ -255,21 +256,34 @@ const exportStatement = (esm, expression) =>
255
256
  * Mask the password in a connection URI (`user:secret@` → `user:****@`) so a
256
257
  * generated config file never carries plaintext credentials. Regex-based, not
257
258
  * `new URL()` — multi-host mongodb URIs (`mongodb://h1:27017,h2:27017/db`) fail
258
- * WHATWG URL parsing. `hasCredentials` is true whenever a userinfo part exists;
259
- * `masked` only when a non-empty password was actually replaced.
259
+ * WHATWG URL parsing. `hasCredentials` is true whenever a userinfo part exists
260
+ * or a query-string secret was masked; `masked` only when a secret was
261
+ * actually replaced.
260
262
  */
261
263
  function maskUriCredentials(uri) {
262
264
  const match = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^@/]+)@(.*)$/.exec(uri);
263
- if (!match) {
264
- return { uri, hasCredentials: false, masked: false };
265
+ let out = uri;
266
+ let hasCredentials = false;
267
+ let masked = false;
268
+ if (match) {
269
+ const [, scheme, userinfo, rest] = match;
270
+ hasCredentials = true;
271
+ const colon = userinfo.indexOf(':');
272
+ if (colon !== -1 && colon !== userinfo.length - 1) {
273
+ const username = userinfo.slice(0, colon);
274
+ out = `${scheme}${username}:****@${rest}`;
275
+ masked = true;
276
+ }
265
277
  }
266
- const [, scheme, userinfo, rest] = match;
267
- const colon = userinfo.indexOf(':');
268
- if (colon === -1 || colon === userinfo.length - 1) {
269
- return { uri, hasCredentials: true, masked: false };
278
+ // Secrets can also travel as query parameters (TLS key passphrases, proxy
279
+ // passwords, session tokens) — redactUris masks exactly those, and an
280
+ // already-masked `user:****@` round-trips unchanged, so comparing the
281
+ // result tells whether a query secret was found.
282
+ const queryMasked = redactUris(out);
283
+ if (queryMasked !== out) {
284
+ return { uri: queryMasked, hasCredentials: true, masked: true };
270
285
  }
271
- const username = userinfo.slice(0, colon);
272
- return { uri: `${scheme}${username}:****@${rest}`, hasCredentials: true, masked: true };
286
+ return { uri: out, hasCredentials, masked };
273
287
  }
274
288
 
275
289
  /** Merge caller-supplied config values over the built-in defaults */