@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,393 @@
1
+ const { MigronautError } = require('../errors/index.js');
2
+ const { errorWithCause } = require('./error.js');
3
+ const { redactOutbound } = require('./redact.js');
4
+
5
+ /**
6
+ * OpenTelemetry's `SpanStatusCode.ERROR`. Spelled as a number because the enum
7
+ * lives in `@opentelemetry/api`, which migronaut never imports — the tracer and
8
+ * the meter are injected, the same way a logger is.
9
+ */
10
+ const SPAN_STATUS_ERROR = 2;
11
+
12
+ /** Span names — static on purpose: the migration's own name is an attribute */
13
+ const SPANS = {
14
+ RUN: 'migronaut.run',
15
+ MIGRATION: 'migronaut.migration',
16
+ };
17
+
18
+ /** Every attribute key migronaut sets, on spans and on metric points */
19
+ const ATTRIBUTES = {
20
+ RUN_ID: 'migronaut.run.id',
21
+ RUN_COMMAND: 'migronaut.run.command',
22
+ RUN_DIRECTION: 'migronaut.run.direction',
23
+ RUN_APPLIED: 'migronaut.run.applied',
24
+ RUN_REVERTED: 'migronaut.run.reverted',
25
+ RUN_SKIPPED: 'migronaut.run.skipped',
26
+ RUN_TOTAL: 'migronaut.run.total',
27
+ LOCK_ACQUIRE_MS: 'migronaut.lock.acquire_ms',
28
+ LOCK_SKIPPED: 'migronaut.lock.skipped',
29
+ LOCK_LOST_REASON: 'migronaut.lock.lost_reason',
30
+ LOCK_WAIT_OUTCOME: 'migronaut.lock.wait.outcome',
31
+ MIGRATION_NAME: 'migronaut.migration.name',
32
+ MIGRATION_DIRECTION: 'migronaut.migration.direction',
33
+ MIGRATION_BATCH: 'migronaut.migration.batch',
34
+ MIGRATION_INDEX: 'migronaut.migration.index',
35
+ MIGRATION_TOTAL: 'migronaut.migration.total',
36
+ MIGRATION_TRANSACTION: 'migronaut.migration.transaction',
37
+ ERROR_TYPE: 'error.type',
38
+ /** The database a run is against — OpenTelemetry's database semantic convention */
39
+ DB_NAMESPACE: 'db.namespace',
40
+ };
41
+
42
+ const METRICS = {
43
+ RUN_DURATION: 'migronaut.run.duration',
44
+ MIGRATION_DURATION: 'migronaut.migration.duration',
45
+ LOCK_ACQUIRE_DURATION: 'migronaut.lock.acquire.duration',
46
+ LOCK_WAIT_DURATION: 'migronaut.lock.wait.duration',
47
+ LOCK_REFUSED: 'migronaut.lock.refused',
48
+ LOCK_LOST: 'migronaut.lock.lost',
49
+ };
50
+
51
+ /**
52
+ * Histogram bucket boundaries, in seconds. An SDK's default boundaries are
53
+ * sized for milliseconds (0…10000), which would put every migration shorter
54
+ * than five seconds into one bucket; these span 10ms to an hour.
55
+ */
56
+ const DURATION_BUCKETS_SECONDS = [0.01, 0.05, 0.1, 0.5, 1, 5, 10, 30, 60, 300, 900, 3600];
57
+
58
+ /** The longest span status message sent — a blocked run can list hundreds of files */
59
+ const MAX_STATUS_MESSAGE_LENGTH = 1024;
60
+
61
+ /**
62
+ * Mark a promise an SDK handed back as handled. A tracer that wraps the work
63
+ * (`return fn(span).finally(…)`) or an instrument that is async returns a
64
+ * promise nobody here awaits — and when it rejects, an unhandled rejection
65
+ * ends the process. The caller's own handlers are unaffected.
66
+ */
67
+ function quiet(value) {
68
+ if (value !== null && (typeof value === 'object' || typeof value === 'function')) {
69
+ try {
70
+ if (typeof value.then === 'function') value.then(undefined, () => {});
71
+ } catch {
72
+ // A `then` getter that throws is one more SDK fault to ignore.
73
+ }
74
+ }
75
+ return value;
76
+ }
77
+
78
+ /**
79
+ * The one guard every tracer, span and instrument call goes through: telemetry
80
+ * must never break a migration run, so a throwing SDK is swallowed here — and
81
+ * a rejecting one too, see {@link quiet}.
82
+ */
83
+ function safe(call) {
84
+ try {
85
+ return quiet(call());
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ /** `attributes` without its undefined values — an SDK warns about (or rejects) those */
92
+ function defined(attributes) {
93
+ const result = {};
94
+ if (!attributes) return result;
95
+ for (const key of Object.keys(attributes)) {
96
+ if (attributes[key] !== undefined) result[key] = attributes[key];
97
+ }
98
+ return result;
99
+ }
100
+
101
+ /**
102
+ * The low-cardinality failure class OpenTelemetry's `error.type` asks for: the
103
+ * typed migronaut code when there is one, the error's class name otherwise.
104
+ */
105
+ function errorType(error) {
106
+ // Never throws: it runs on the way out of a failed run, where a hostile
107
+ // `name` getter would otherwise replace the run's own error.
108
+ try {
109
+ if (error instanceof MigronautError) return error.code;
110
+ if (typeof error?.name === 'string' && error.name.length > 0) return error.name;
111
+ } catch {
112
+ // fall through
113
+ }
114
+ return '_OTHER';
115
+ }
116
+
117
+ /**
118
+ * The status message for a failed span. Redacted like every string that leaves
119
+ * the process — a raw driver message can echo the credentialed URI — and joined
120
+ * with the wrapped cause, since "Migration up failed: X" alone says which
121
+ * migration and not why.
122
+ */
123
+ function failureText(error) {
124
+ // A span goes to a third-party backend: no data values, and a bounded size.
125
+ const text = redactOutbound(errorWithCause(error));
126
+ return text.length > MAX_STATUS_MESSAGE_LENGTH
127
+ ? `${text.slice(0, MAX_STATUS_MESSAGE_LENGTH - 1)}…`
128
+ : text;
129
+ }
130
+
131
+ /** The parts of `telemetry` — anything else in it is a typo, mentioned at debug level */
132
+ const TELEMETRY_KEYS = Object.freeze(['tracer', 'meter', 'attributes']);
133
+ /** Static attributes are dimensions: a handful at most */
134
+ const MAX_STATIC_ATTRIBUTES = 20;
135
+
136
+ const hasMethods = (value, names) => {
137
+ if (typeof value !== 'object' || value === null) return false;
138
+ for (const name of names) {
139
+ if (typeof value[name] !== 'function') return false;
140
+ }
141
+ return true;
142
+ };
143
+
144
+ /**
145
+ * Issues with the `telemetry` option. Absent, `null` and an empty object all
146
+ * mean "off" — a config that builds it conditionally must not have to special-
147
+ * case the disabled branch. What is present has to be usable: a tracer that
148
+ * cannot start a span would fail on the first run, long after the mistake.
149
+ * Lives here, next to the calls it vouches for: no other module knows an
150
+ * OpenTelemetry method name.
151
+ */
152
+ function telemetryIssues(telemetry) {
153
+ if (telemetry === undefined || telemetry === null) return [];
154
+ if (typeof telemetry !== 'object' || Array.isArray(telemetry)) {
155
+ return [{ path: 'telemetry', message: 'must be an object' }];
156
+ }
157
+ const issues = [];
158
+ if (telemetry.tracer != null && !hasMethods(telemetry.tracer, ['startActiveSpan'])) {
159
+ issues.push({
160
+ path: 'telemetry.tracer',
161
+ message: 'must be an OpenTelemetry Tracer (an object with startActiveSpan)',
162
+ });
163
+ }
164
+ if (
165
+ telemetry.meter != null &&
166
+ !hasMethods(telemetry.meter, ['createHistogram', 'createCounter'])
167
+ ) {
168
+ issues.push({
169
+ path: 'telemetry.meter',
170
+ message: 'must be an OpenTelemetry Meter (an object with createHistogram and createCounter)',
171
+ });
172
+ }
173
+ const attributes = telemetry.attributes;
174
+ if (attributes !== undefined) {
175
+ if (attributes === null || typeof attributes !== 'object' || Array.isArray(attributes)) {
176
+ issues.push({ path: 'telemetry.attributes', message: 'must be an object' });
177
+ } else {
178
+ const keys = Object.keys(attributes);
179
+ if (keys.length > MAX_STATIC_ATTRIBUTES) {
180
+ issues.push({
181
+ path: 'telemetry.attributes',
182
+ message: `must hold at most ${MAX_STATIC_ATTRIBUTES} attributes — they are dimensions`,
183
+ });
184
+ }
185
+ for (const key of keys) {
186
+ const value = attributes[key];
187
+ if (!['string', 'number', 'boolean'].includes(typeof value)) {
188
+ issues.push({
189
+ path: `telemetry.attributes.${key}`,
190
+ message: 'must be a string, a number or a boolean',
191
+ });
192
+ }
193
+ }
194
+ }
195
+ }
196
+ return issues;
197
+ }
198
+
199
+ /** What the kit holds when there is no tracer: the same surface, doing nothing */
200
+ const NOOP_SPAN = { set() {}, finish() {} };
201
+
202
+ /** Wrap an SDK span so no call on it can throw into the run, and it ends once */
203
+ function guardSpan(span) {
204
+ let ended = false;
205
+ const set = (attributes) => {
206
+ const values = defined(attributes);
207
+ for (const key of Object.keys(values)) {
208
+ safe(() => span.setAttribute(key, values[key]));
209
+ }
210
+ };
211
+ return {
212
+ set,
213
+ /**
214
+ * End the span. A failure sets the ERROR status and `error.type`; a
215
+ * success leaves the status unset, as the specification asks of
216
+ * instrumentation libraries (OK is the application's to claim).
217
+ */
218
+ finish(attributes, error) {
219
+ if (ended) return;
220
+ ended = true;
221
+ set(attributes);
222
+ if (error !== undefined) {
223
+ safe(() => span.setStatus({ code: SPAN_STATUS_ERROR, message: failureText(error) }));
224
+ safe(() => span.setAttribute(ATTRIBUTES.ERROR_TYPE, errorType(error)));
225
+ }
226
+ safe(() => span.end());
227
+ },
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Turn the `telemetry` config option into what the kit reports through. With
233
+ * no tracer and no meter every method is a no-op that costs a function call.
234
+ *
235
+ * Both parts are the caller's own OpenTelemetry objects
236
+ * (`trace.getTracer(…)`, `metrics.getMeter(…)`): migronaut asks them for a
237
+ * span or an instrument and never looks at the SDK behind them.
238
+ */
239
+ function createTelemetry(telemetry, { dbName } = {}) {
240
+ const tracer = telemetry?.tracer ?? undefined;
241
+ const meter = telemetry?.meter ?? undefined;
242
+ // On every span and every metric point: which database the run was against
243
+ // (one process can migrate many — one kit per tenant), plus the caller's own
244
+ // low-cardinality dimensions. The caller's cannot overwrite migronaut's.
245
+ const base = defined({ ...telemetry?.attributes, [ATTRIBUTES.DB_NAMESPACE]: dbName });
246
+ const withBase = (attributes) => ({ ...base, ...defined(attributes) });
247
+
248
+ /**
249
+ * Run `fn(span)` with a new span as the active one, and leave ending it to
250
+ * the caller — the run span outlives the unit of work it is active around.
251
+ *
252
+ * `fn` runs exactly once whatever the tracer does: a tracer that throws
253
+ * before calling back still gets the work done (with a no-op span), one that
254
+ * throws afterwards cannot turn a finished run into a failed one, and one
255
+ * that calls back twice cannot run a migration twice. The result is taken
256
+ * from `fn` itself rather than from what the tracer returns.
257
+ */
258
+ function open(name, attributes, fn) {
259
+ if (!tracer) return fn(NOOP_SPAN);
260
+ let called = false;
261
+ let threw = false;
262
+ let result;
263
+ const run = (span) => {
264
+ if (called) return result;
265
+ called = true;
266
+ try {
267
+ result = fn(span);
268
+ } catch (error) {
269
+ threw = true;
270
+ result = error;
271
+ throw error;
272
+ }
273
+ return result;
274
+ };
275
+ // Three arguments, always: an SDK picks the overload by argument count.
276
+ safe(() =>
277
+ tracer.startActiveSpan(name, { attributes: withBase(attributes) }, (span) =>
278
+ run(guardSpan(span)),
279
+ ),
280
+ );
281
+ // Through `run`, not `fn`: it marks the work as done, so a tracer that
282
+ // calls back late finds nothing left to run.
283
+ if (!called) return run(NOOP_SPAN);
284
+ if (threw) throw result;
285
+ return result;
286
+ }
287
+
288
+ /** `open`, plus ending the span when `fn` settles — failed when it rejects */
289
+ function wrap(name, attributes, fn) {
290
+ if (!tracer) return fn(NOOP_SPAN);
291
+ return open(name, attributes, async (span) => {
292
+ try {
293
+ const value = await fn(span);
294
+ span.finish();
295
+ return value;
296
+ } catch (error) {
297
+ span.finish(undefined, error);
298
+ throw error;
299
+ }
300
+ });
301
+ }
302
+
303
+ const histogram = (name, description) =>
304
+ meter
305
+ ? safe(() =>
306
+ meter.createHistogram(name, {
307
+ description,
308
+ unit: 's',
309
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS_SECONDS },
310
+ }),
311
+ )
312
+ : undefined;
313
+ const counter = (name, description, unit) =>
314
+ meter ? safe(() => meter.createCounter(name, { description, unit })) : undefined;
315
+
316
+ const runDuration = histogram(METRICS.RUN_DURATION, 'Duration of a migration run');
317
+ const migrationDuration = histogram(METRICS.MIGRATION_DURATION, 'Duration of one migration');
318
+ const lockAcquireDuration = histogram(
319
+ METRICS.LOCK_ACQUIRE_DURATION,
320
+ 'Round trip of the successful migration lock acquisition',
321
+ );
322
+ const lockWaitDuration = histogram(
323
+ METRICS.LOCK_WAIT_DURATION,
324
+ 'Time spent waiting for a held migration lock, by how the wait ended',
325
+ );
326
+ const lockRefused = counter(
327
+ METRICS.LOCK_REFUSED,
328
+ 'Attempts refused because the migration lock was held',
329
+ '{refusal}',
330
+ );
331
+ const lockLost = counter(METRICS.LOCK_LOST, 'Migration locks lost mid-run', '{loss}');
332
+
333
+ // Durations are measured in milliseconds everywhere in migronaut and
334
+ // reported in seconds, the unit OpenTelemetry's conventions settle on.
335
+ const record = (instrument, durationMs, attributes) => {
336
+ if (instrument) safe(() => instrument.record(durationMs / 1000, withBase(attributes)));
337
+ };
338
+ const increment = (instrument) => {
339
+ if (instrument) safe(() => instrument.add(1, withBase()));
340
+ };
341
+ const failure = (error) =>
342
+ error !== undefined ? { [ATTRIBUTES.ERROR_TYPE]: errorType(error) } : {};
343
+
344
+ return {
345
+ open,
346
+ wrap,
347
+ runEnded({ command, direction, durationMs, error }) {
348
+ record(runDuration, durationMs, {
349
+ [ATTRIBUTES.RUN_COMMAND]: command,
350
+ [ATTRIBUTES.RUN_DIRECTION]: direction,
351
+ ...failure(error),
352
+ });
353
+ },
354
+ migrationEnded({ direction, durationMs, error }) {
355
+ record(migrationDuration, durationMs, {
356
+ [ATTRIBUTES.MIGRATION_DIRECTION]: direction,
357
+ ...failure(error),
358
+ });
359
+ },
360
+ lockAcquired(acquireMs) {
361
+ record(lockAcquireDuration, acquireMs);
362
+ },
363
+ /**
364
+ * A wait for a held lock ended — `outcome` is `'acquired'`, `'timeout'`
365
+ * or `'aborted'`. Only waits that happened are recorded: the free-lock
366
+ * path is `lock.acquire.duration`'s.
367
+ */
368
+ lockWaited({ waitedMs, outcome }) {
369
+ record(lockWaitDuration, waitedMs, { [ATTRIBUTES.LOCK_WAIT_OUTCOME]: outcome });
370
+ },
371
+ lockRefused() {
372
+ increment(lockRefused);
373
+ },
374
+ lockLost() {
375
+ increment(lockLost);
376
+ },
377
+ };
378
+ }
379
+
380
+ module.exports = {
381
+ ATTRIBUTES,
382
+ TELEMETRY_KEYS,
383
+ telemetryIssues,
384
+ DURATION_BUCKETS_SECONDS,
385
+ MAX_STATUS_MESSAGE_LENGTH,
386
+ METRICS,
387
+ NOOP_SPAN,
388
+ SPANS,
389
+ SPAN_STATUS_ERROR,
390
+ createTelemetry,
391
+ errorType,
392
+ failureText,
393
+ };
@@ -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 */
@@ -318,6 +332,8 @@ function configBody(values, createExtension) {
318
332
  // ── Bookkeeping collections ─────────────────────────────────
319
333
  migrationsCollection: '_migronaut_migrations',
320
334
  lockCollection: '_migronaut_locks',
335
+ // What each \`migronaut converge\` changed — see \`converge --history\`.
336
+ convergeLogCollection: '_migronaut_converge',
321
337
  // Seconds before a held lock is considered stale and reclaimable.
322
338
  lockTTLSeconds: 60,
323
339
 
@@ -328,6 +344,21 @@ function configBody(values, createExtension) {
328
344
  // \`export const useTransaction = true\`.
329
345
  useTransaction: false,
330
346
 
347
+ // ── Declared collections (experimental) ─────────────────────
348
+ // Indexes and validators as the end state you want: \`migronaut converge\`
349
+ // compares them with the database and makes the difference — no migration
350
+ // file per change. Here, in a directory of one file per collection, or both.
351
+ // collections: [
352
+ // {
353
+ // name: 'users',
354
+ // indexes: [{ key: { email: 1 }, unique: true }],
355
+ // validator: { $jsonSchema: { bsonType: 'object', required: ['email'] } },
356
+ // },
357
+ // ],
358
+ // collectionsDir: './collections',
359
+ // Converge at the end of every bulk \`migronaut up\`.
360
+ // convergeAfterUp: false,
361
+
331
362
  // ── Lifecycle hooks (code only — not available in JSON config) ──
332
363
  // hooks: {
333
364
  // beforeAll: async (ctx) => {},
@@ -335,6 +366,21 @@ function configBody(values, createExtension) {
335
366
  // beforeEach: async (name, ctx, info) => {}, // info: { direction, index, total }
336
367
  // afterEach: async (name, duration, ctx, info) => {},
337
368
  // onError: async (name, error, ctx) => {},
369
+ // },
370
+
371
+ // ── Identifiers (code only — not available in JSON config) ──
372
+ // Run ids are random UUIDs. Pass a generator for another format (ULID,
373
+ // CUID, …): called with no arguments, it must return a unique string
374
+ // synchronously — so \`generateId: ulid\` works as is.
375
+ // generateId: () => crypto.randomUUID(),
376
+
377
+ // ── OpenTelemetry (code only — not available in JSON config) ──
378
+ // Pass a tracer and/or a meter from your own @opentelemetry/api: every run
379
+ // and every migration becomes a span, and their durations become metrics.
380
+ // (\`trace\` and \`metrics\` come from '@opentelemetry/api' — import them at the top)
381
+ // telemetry: {
382
+ // tracer: trace.getTracer('@alexify/migronaut'),
383
+ // meter: metrics.getMeter('@alexify/migronaut'),
338
384
  // },`;
339
385
  }
340
386
 
@@ -374,8 +420,8 @@ ${exportStatement(esm, 'config')}
374
420
 
375
421
  /**
376
422
  * The built-in JSON config template. JSON cannot hold comments or functions, so
377
- * the `hooks`, `mongoose`, and `logger` options are unavailable here — use a
378
- * `.ts`/`.js` config if you need them.
423
+ * the `hooks`, `mongoose`, `logger`, `generateId` and `telemetry` options are
424
+ * unavailable here — use a `.ts`/`.js` config if you need them.
379
425
  */
380
426
  function defaultConfigJson(values = {}) {
381
427
  const { uri, dbName, migrationsDir } = configFields(values);
@@ -390,6 +436,7 @@ function defaultConfigJson(values = {}) {
390
436
  sequential: false,
391
437
  migrationsCollection: '_migronaut_migrations',
392
438
  lockCollection: '_migronaut_locks',
439
+ convergeLogCollection: '_migronaut_converge',
393
440
  lockTTLSeconds: 60,
394
441
  strict: false,
395
442
  useTransaction: false,
@@ -439,6 +486,7 @@ function secretConfigOptions(createExtension, migrationsDir) {
439
486
  // ── Bookkeeping collections ─────────────────────────────
440
487
  migrationsCollection: '_migronaut_migrations',
441
488
  lockCollection: '_migronaut_locks',
489
+ convergeLogCollection: '_migronaut_converge',
442
490
  lockTTLSeconds: 60,
443
491
 
444
492
  // ── Behavior ────────────────────────────────────────────