@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.
- package/CHANGELOG.md +409 -1
- package/README.md +248 -24
- package/bin/migronaut.js +11 -3
- package/bullmq.d.ts +845 -0
- package/bullmq.js +1 -0
- package/index.d.ts +757 -29
- package/migronaut.schema.json +191 -1
- package/package.json +27 -6
- package/src/bullmq/index.js +55 -0
- package/src/bullmq/jobs.js +454 -0
- package/src/bullmq/processor.js +608 -0
- package/src/bullmq/producer.js +424 -0
- package/src/bullmq/service.js +653 -0
- package/src/bullmq/wait.js +124 -0
- package/src/cli/args.js +12 -2
- package/src/cli/commands/baseline.js +45 -0
- package/src/cli/commands/converge.js +160 -0
- package/src/cli/commands/down.js +2 -0
- package/src/cli/commands/lock.js +2 -1
- package/src/cli/commands/redo.js +8 -1
- package/src/cli/commands/unlock.js +12 -2
- package/src/cli/commands/up.js +14 -1
- package/src/cli/exit-codes.js +10 -2
- package/src/cli/index.js +4 -0
- package/src/cli/shared.js +29 -7
- package/src/cli/table.js +105 -0
- package/src/core/audit.js +17 -3
- package/src/core/baseline.js +80 -0
- package/src/core/changelog.js +140 -24
- package/src/core/collections.js +372 -0
- package/src/core/config.js +125 -27
- package/src/core/converge-log.js +47 -0
- package/src/core/converge-plan.js +483 -0
- package/src/core/converge.js +867 -0
- package/src/core/import-runner.js +34 -6
- package/src/core/import.js +14 -7
- package/src/core/index-spec.js +496 -0
- package/src/core/lock-wait.js +260 -0
- package/src/core/lock.js +71 -20
- package/src/core/migrator.js +805 -304
- package/src/core/options.js +251 -0
- package/src/core/run-recorder.js +157 -0
- package/src/core/run.js +71 -71
- package/src/core/runner.js +70 -20
- package/src/core/sequence.js +134 -0
- package/src/errors/index.js +71 -1
- package/src/index.js +16 -0
- package/src/utils/actor.js +48 -0
- package/src/utils/canonical.js +179 -0
- package/src/utils/collection-name.js +21 -0
- package/src/utils/error.js +18 -1
- package/src/utils/id.js +77 -0
- package/src/utils/loader.js +39 -21
- package/src/utils/logger.js +30 -12
- package/src/utils/migration-name.js +32 -0
- package/src/utils/redact.js +57 -4
- package/src/utils/sanitize.js +8 -3
- package/src/utils/telemetry.js +393 -0
- 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
|
+
};
|
package/src/utils/template.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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 `
|
|
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 ────────────────────────────────────────────
|