@alexify/migronaut 2.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 +320 -0
- package/README.md +208 -6
- package/bullmq.d.ts +845 -0
- package/bullmq.js +1 -0
- package/index.d.ts +634 -18
- package/migronaut.schema.json +182 -1
- package/package.json +21 -5
- 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/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/up.js +14 -1
- package/src/cli/exit-codes.js +9 -2
- package/src/cli/index.js +2 -0
- package/src/cli/shared.js +14 -4
- package/src/cli/table.js +105 -0
- package/src/core/changelog.js +71 -6
- package/src/core/collections.js +372 -0
- package/src/core/config.js +100 -25
- 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/index-spec.js +496 -0
- package/src/core/lock-wait.js +260 -0
- package/src/core/lock.js +45 -16
- package/src/core/migrator.js +563 -283
- package/src/core/options.js +251 -0
- package/src/core/run-recorder.js +157 -0
- package/src/core/run.js +58 -90
- package/src/core/sequence.js +134 -0
- package/src/errors/index.js +56 -0
- package/src/index.js +8 -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/migration-name.js +32 -0
- package/src/utils/redact.js +21 -1
- package/src/utils/telemetry.js +393 -0
- package/src/utils/template.js +36 -2
package/src/utils/loader.js
CHANGED
|
@@ -7,7 +7,7 @@ const { errorText } = require('./error.js');
|
|
|
7
7
|
/** TypeScript source extensions that require a TS-capable runtime to import */
|
|
8
8
|
const TS_EXTENSIONS = new Set(['.ts', '.mts', '.cts']);
|
|
9
9
|
|
|
10
|
-
/** Distinguishes reload URLs; see
|
|
10
|
+
/** Distinguishes reload URLs; see importUserFile */
|
|
11
11
|
let reloadCounter = 0;
|
|
12
12
|
|
|
13
13
|
/** Narrow an unknown value to a function */
|
|
@@ -36,9 +36,10 @@ function isUnsupportedTsSyntaxError(error) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* An actionable message for a dynamic-import failure whose cause is a
|
|
40
|
+
* `.ts`/`.mts`/`.cts` file the current runtime refused, or null to let the
|
|
41
|
+
* original error speak for itself. `noun` names what the file is ("migration",
|
|
42
|
+
* "collection definition").
|
|
42
43
|
*
|
|
43
44
|
* The shipped CLI runs as plain Node, whose type stripping (always present on
|
|
44
45
|
* the supported Node >= 22.18 range) handles erasable TypeScript only. Two
|
|
@@ -47,18 +48,29 @@ function isUnsupportedTsSyntaxError(error) {
|
|
|
47
48
|
* `--no-experimental-strip-types`), and non-erasable syntax such as `enum` or
|
|
48
49
|
* `namespace` (`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`).
|
|
49
50
|
*/
|
|
50
|
-
function
|
|
51
|
+
function tsLoadMessageOrNull(filepath, error, noun) {
|
|
51
52
|
const ext = path.extname(filepath).toLowerCase();
|
|
52
53
|
if (!TS_EXTENSIONS.has(ext)) {
|
|
53
54
|
return null;
|
|
54
55
|
}
|
|
55
56
|
const name = path.basename(filepath);
|
|
56
|
-
let message;
|
|
57
57
|
if (isUnknownExtensionError(error)) {
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
return `Cannot load TypeScript ${noun} "${name}" — type stripping is disabled in this Node process. Re-enable it, run migronaut under a TypeScript loader (e.g. tsx), or author the ${noun} as .js.`;
|
|
59
|
+
}
|
|
60
|
+
if (isUnsupportedTsSyntaxError(error)) {
|
|
61
|
+
return `Cannot load TypeScript ${noun} "${name}" — it uses syntax Node's type stripping cannot erase (e.g. enum, namespace). Rewrite with erasable-only syntax, or run migronaut under a TypeScript loader (e.g. tsx).`;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Translate a dynamic-import failure of a migration into a clear
|
|
68
|
+
* MigrationInvalidExportError (see {@link tsLoadMessageOrNull}), or return
|
|
69
|
+
* null to let the original error propagate.
|
|
70
|
+
*/
|
|
71
|
+
function tsLoadErrorOrNull(filepath, error) {
|
|
72
|
+
const message = tsLoadMessageOrNull(filepath, error, 'migration');
|
|
73
|
+
if (message === null) {
|
|
62
74
|
return null;
|
|
63
75
|
}
|
|
64
76
|
return new MigrationInvalidExportError(
|
|
@@ -68,6 +80,21 @@ function tsLoadErrorOrNull(filepath, error) {
|
|
|
68
80
|
);
|
|
69
81
|
}
|
|
70
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Import a user-authored module (a migration, a collection definition).
|
|
85
|
+
*
|
|
86
|
+
* Node caches ESM modules by URL forever. A one-shot CLI never notices, but a
|
|
87
|
+
* long-lived process (a test runner, a dev server re-running migrations, a
|
|
88
|
+
* queue worker) would keep evaluating the version it first imported; with
|
|
89
|
+
* `reload` a unique query string forces a fresh evaluation. Off by default —
|
|
90
|
+
* it leaks a module per load. A monotonic counter, not Date.now(): two
|
|
91
|
+
* reloads in one millisecond must still get distinct URLs.
|
|
92
|
+
*/
|
|
93
|
+
function importUserFile(filepath, options = {}) {
|
|
94
|
+
const url = pathToFileURL(filepath).href;
|
|
95
|
+
return import(options.reload ? `${url}?migronaut=${++reloadCounter}` : url);
|
|
96
|
+
}
|
|
97
|
+
|
|
71
98
|
/**
|
|
72
99
|
* Dynamically load a migration file and validate its exports.
|
|
73
100
|
*
|
|
@@ -85,18 +112,9 @@ async function loadMigrationFile(filepath, options = {}) {
|
|
|
85
112
|
throw new MigrationFileNotFoundError('Migration file not found', { filepath });
|
|
86
113
|
}
|
|
87
114
|
|
|
88
|
-
// Node caches ESM modules by URL forever. A one-shot CLI never notices, but a
|
|
89
|
-
// long-lived process (a test runner, a dev server re-running migrations)
|
|
90
|
-
// would keep executing the version it first imported; a unique query string
|
|
91
|
-
// forces a fresh evaluation. Off by default — it leaks a module per load.
|
|
92
|
-
// A monotonic counter, not Date.now(): two reloads in one millisecond must
|
|
93
|
-
// still get distinct URLs.
|
|
94
|
-
const url = pathToFileURL(filepath).href;
|
|
95
|
-
const href = options.reload ? `${url}?migronaut=${++reloadCounter}` : url;
|
|
96
|
-
|
|
97
115
|
let imported;
|
|
98
116
|
try {
|
|
99
|
-
imported = await
|
|
117
|
+
imported = await importUserFile(filepath, { reload: options.reload });
|
|
100
118
|
} catch (error) {
|
|
101
119
|
const tsError = tsLoadErrorOrNull(filepath, error);
|
|
102
120
|
if (tsError) {
|
|
@@ -128,4 +146,4 @@ async function loadMigrationFile(filepath, options = {}) {
|
|
|
128
146
|
return migration;
|
|
129
147
|
}
|
|
130
148
|
|
|
131
|
-
module.exports = {
|
|
149
|
+
module.exports = { importUserFile, loadMigrationFile, tsLoadErrorOrNull, tsLoadMessageOrNull };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const { MigrationInvalidNameError } = require('../errors/index.js');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether `name` is a bare filename: a non-empty string with no path
|
|
5
|
+
* separator, no NUL byte, and not `.`/`..`. The single definition of the rule
|
|
6
|
+
* that keeps a migration name from escaping the migrations directory — shared
|
|
7
|
+
* by the kit's path resolution and by anything that accepts a name from
|
|
8
|
+
* outside the process (a queue job's payload).
|
|
9
|
+
*/
|
|
10
|
+
function isBareFilename(name) {
|
|
11
|
+
return (
|
|
12
|
+
typeof name === 'string' &&
|
|
13
|
+
name.length > 0 &&
|
|
14
|
+
name !== '.' &&
|
|
15
|
+
name !== '..' &&
|
|
16
|
+
!name.includes('/') &&
|
|
17
|
+
!name.includes('\\') &&
|
|
18
|
+
!name.includes('\0')
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Throw MigrationInvalidNameError unless `name` is a bare filename */
|
|
23
|
+
function assertMigrationName(name, context = {}) {
|
|
24
|
+
if (!isBareFilename(name)) {
|
|
25
|
+
throw new MigrationInvalidNameError(
|
|
26
|
+
'Invalid migration name — must be a bare filename with no path segments',
|
|
27
|
+
{ name, ...context },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { assertMigrationName, isBareFilename };
|
package/src/utils/redact.js
CHANGED
|
@@ -49,6 +49,26 @@ function redactUris(text) {
|
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The document values a server error can quote: an E11000 duplicate-key
|
|
54
|
+
* message ends with the offending key's values — an email, a phone number —
|
|
55
|
+
* which is the database's data, not an error's. Everything from `dup key: {`
|
|
56
|
+
* to the last `}` on that line is masked; the index name before it still says
|
|
57
|
+
* which constraint was violated.
|
|
58
|
+
*/
|
|
59
|
+
const DUPLICATE_KEY_VALUES = /(dup key: )\{[^\n]*\}/g;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* For text that leaves the process for a third party — a tracing backend, a
|
|
63
|
+
* queue that keeps failed jobs and serves them to dashboards: credentials
|
|
64
|
+
* masked (as everywhere) and the data values a server error quotes, too.
|
|
65
|
+
* Local log lines keep the values; they are what a developer debugs with.
|
|
66
|
+
*/
|
|
67
|
+
function redactOutbound(text) {
|
|
68
|
+
if (typeof text !== 'string') return text;
|
|
69
|
+
return redactUris(text).replace(DUPLICATE_KEY_VALUES, '$1{ <redacted> }');
|
|
70
|
+
}
|
|
71
|
+
|
|
52
72
|
/**
|
|
53
73
|
* Redact every string reachable from `value` (plain objects and arrays only —
|
|
54
74
|
* class instances are left alone rather than cloned into broken shapes).
|
|
@@ -69,4 +89,4 @@ function redactDeep(value) {
|
|
|
69
89
|
return value;
|
|
70
90
|
}
|
|
71
91
|
|
|
72
|
-
module.exports = {
|
|
92
|
+
module.exports = { redactDeep, redactOutbound, redactUris };
|
|
@@ -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
|
@@ -332,6 +332,8 @@ function configBody(values, createExtension) {
|
|
|
332
332
|
// ── Bookkeeping collections ─────────────────────────────────
|
|
333
333
|
migrationsCollection: '_migronaut_migrations',
|
|
334
334
|
lockCollection: '_migronaut_locks',
|
|
335
|
+
// What each \`migronaut converge\` changed — see \`converge --history\`.
|
|
336
|
+
convergeLogCollection: '_migronaut_converge',
|
|
335
337
|
// Seconds before a held lock is considered stale and reclaimable.
|
|
336
338
|
lockTTLSeconds: 60,
|
|
337
339
|
|
|
@@ -342,6 +344,21 @@ function configBody(values, createExtension) {
|
|
|
342
344
|
// \`export const useTransaction = true\`.
|
|
343
345
|
useTransaction: false,
|
|
344
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
|
+
|
|
345
362
|
// ── Lifecycle hooks (code only — not available in JSON config) ──
|
|
346
363
|
// hooks: {
|
|
347
364
|
// beforeAll: async (ctx) => {},
|
|
@@ -349,6 +366,21 @@ function configBody(values, createExtension) {
|
|
|
349
366
|
// beforeEach: async (name, ctx, info) => {}, // info: { direction, index, total }
|
|
350
367
|
// afterEach: async (name, duration, ctx, info) => {},
|
|
351
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'),
|
|
352
384
|
// },`;
|
|
353
385
|
}
|
|
354
386
|
|
|
@@ -388,8 +420,8 @@ ${exportStatement(esm, 'config')}
|
|
|
388
420
|
|
|
389
421
|
/**
|
|
390
422
|
* The built-in JSON config template. JSON cannot hold comments or functions, so
|
|
391
|
-
* the `hooks`, `mongoose`, and `
|
|
392
|
-
* `.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.
|
|
393
425
|
*/
|
|
394
426
|
function defaultConfigJson(values = {}) {
|
|
395
427
|
const { uri, dbName, migrationsDir } = configFields(values);
|
|
@@ -404,6 +436,7 @@ function defaultConfigJson(values = {}) {
|
|
|
404
436
|
sequential: false,
|
|
405
437
|
migrationsCollection: '_migronaut_migrations',
|
|
406
438
|
lockCollection: '_migronaut_locks',
|
|
439
|
+
convergeLogCollection: '_migronaut_converge',
|
|
407
440
|
lockTTLSeconds: 60,
|
|
408
441
|
strict: false,
|
|
409
442
|
useTransaction: false,
|
|
@@ -453,6 +486,7 @@ function secretConfigOptions(createExtension, migrationsDir) {
|
|
|
453
486
|
// ── Bookkeeping collections ─────────────────────────────
|
|
454
487
|
migrationsCollection: '_migronaut_migrations',
|
|
455
488
|
lockCollection: '_migronaut_locks',
|
|
489
|
+
convergeLogCollection: '_migronaut_converge',
|
|
456
490
|
lockTTLSeconds: 60,
|
|
457
491
|
|
|
458
492
|
// ── Behavior ────────────────────────────────────────────
|