@alexify/migronaut 2.0.0 → 2.2.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 +436 -0
- package/README.md +235 -6
- package/bullmq.d.ts +860 -0
- package/bullmq.js +1 -0
- package/index.d.ts +888 -19
- package/migronaut.schema.json +238 -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 +632 -0
- package/src/bullmq/producer.js +427 -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 +188 -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 +164 -0
- package/src/core/audit.js +88 -3
- package/src/core/changelog.js +71 -6
- package/src/core/collections.js +396 -0
- package/src/core/config.js +130 -25
- package/src/core/converge-log.js +47 -0
- package/src/core/converge-plan.js +686 -0
- package/src/core/converge-search-run.js +440 -0
- package/src/core/converge-search.js +404 -0
- package/src/core/converge.js +1024 -0
- package/src/core/index-spec.js +507 -0
- package/src/core/lock-wait.js +260 -0
- package/src/core/lock.js +95 -28
- package/src/core/migrator.js +600 -287
- package/src/core/options.js +266 -0
- package/src/core/run-recorder.js +157 -0
- package/src/core/run.js +58 -90
- package/src/core/search-index-spec.js +758 -0
- package/src/core/sequence.js +134 -0
- package/src/core/server-info.js +63 -0
- package/src/errors/index.js +60 -0
- package/src/index.js +8 -0
- package/src/utils/actor.js +48 -0
- package/src/utils/canonical.js +212 -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 +410 -0
- package/src/utils/template.js +43 -2
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const { ConfigInvalidError, QueueJobFailedError } = require('../errors/index.js');
|
|
2
|
+
const { redactOutbound } = require('../utils/redact.js');
|
|
3
|
+
|
|
4
|
+
/** Rejects a budgeted await that outlived the deadline — told apart from every other failure */
|
|
5
|
+
const DEADLINE = Symbol('wait deadline');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How much longer than the remaining budget BullMQ's own `waitUntilFinished`
|
|
9
|
+
* timer is given. That timer is only there to take its listeners off
|
|
10
|
+
* QueueEvents; whether the wait timed out is this module's clock to say.
|
|
11
|
+
* Given the same remaining time, BullMQ's timer can fire first — while
|
|
12
|
+
* `Date.now()` is still a millisecond short of the deadline — and a timeout
|
|
13
|
+
* would be reported as an ordinary job failure.
|
|
14
|
+
*/
|
|
15
|
+
const LISTENER_GRACE_MS = 1000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wait for every job of an enqueue group, in group order — and for the
|
|
19
|
+
* group's converge job last, when it has one — and resolve
|
|
20
|
+
* `{ groupId, direction, batch, results, converge? }`.
|
|
21
|
+
*
|
|
22
|
+
* Rejects with QueueJobFailedError at the first job that fails or outlives the
|
|
23
|
+
* budget — the jobs after it cannot succeed anyway (they fail as blocked), and
|
|
24
|
+
* `context.results` keeps what finished before it. `timeoutMs` is one budget
|
|
25
|
+
* for the whole group, not per job.
|
|
26
|
+
*
|
|
27
|
+
* Finished jobs must still exist in the queue when this attaches: a
|
|
28
|
+
* `removeOnComplete: true` queue gives it nothing to read.
|
|
29
|
+
*/
|
|
30
|
+
async function waitForGroup({
|
|
31
|
+
queue,
|
|
32
|
+
queueEvents,
|
|
33
|
+
groupId,
|
|
34
|
+
direction,
|
|
35
|
+
batch,
|
|
36
|
+
jobs,
|
|
37
|
+
converge,
|
|
38
|
+
timeoutMs,
|
|
39
|
+
}) {
|
|
40
|
+
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
|
|
41
|
+
throw new ConfigInvalidError('timeoutMs must be a positive finite number', { timeoutMs });
|
|
42
|
+
}
|
|
43
|
+
const results = [];
|
|
44
|
+
// Nothing was enqueued: nothing to wait for, and no reason to need QueueEvents.
|
|
45
|
+
if (jobs.length === 0 && !converge) return { groupId, direction, batch, results };
|
|
46
|
+
if (!queueEvents) {
|
|
47
|
+
throw new ConfigInvalidError(
|
|
48
|
+
'wait() needs QueueEvents — pass bullmq.QueueEvents to createMigrationQueue, ' +
|
|
49
|
+
'or a queueEvents instance to wait()',
|
|
50
|
+
{ groupId },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// One budget for everything this call awaits — connecting QueueEvents and
|
|
55
|
+
// reading a job included: with a Redis that is down, those are the awaits
|
|
56
|
+
// that would otherwise hang.
|
|
57
|
+
const deadline = timeoutMs !== undefined ? Date.now() + timeoutMs : undefined;
|
|
58
|
+
const budgeted = (promise) => {
|
|
59
|
+
if (deadline === undefined) return promise;
|
|
60
|
+
let timer;
|
|
61
|
+
const expired = new Promise((_resolve, reject) => {
|
|
62
|
+
timer = setTimeout(() => reject(DEADLINE), Math.max(0, deadline - Date.now()));
|
|
63
|
+
});
|
|
64
|
+
return Promise.race([promise, expired]).finally(() => clearTimeout(timer));
|
|
65
|
+
};
|
|
66
|
+
const timedOut = () => deadline !== undefined && Date.now() >= deadline;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The typed code a failed job reported in its progress — what a caller
|
|
70
|
+
* branches on (MIGRATION_BLOCKED, CHECKSUM_MISMATCH, …) instead of parsing
|
|
71
|
+
* `failedReason`. Best-effort: the job may be gone, or Redis unreachable.
|
|
72
|
+
*/
|
|
73
|
+
async function failureCode(id) {
|
|
74
|
+
try {
|
|
75
|
+
const code = (await budgeted(queue.getJob(id)))?.progress?.code;
|
|
76
|
+
return typeof code === 'string' && code.length <= 64 ? code : undefined;
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Wait for one job; `message` and `fields` say which one in a failure */
|
|
83
|
+
async function finish(id, message, fields) {
|
|
84
|
+
const fail = (failedReason, timedOut, code) =>
|
|
85
|
+
new QueueJobFailedError(message, {
|
|
86
|
+
groupId,
|
|
87
|
+
jobId: id,
|
|
88
|
+
...fields,
|
|
89
|
+
failedReason,
|
|
90
|
+
timedOut,
|
|
91
|
+
...(code !== undefined ? { code } : {}),
|
|
92
|
+
results: [...results],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const outOfTime = () => fail(`wait timed out after ${timeoutMs}ms`, true);
|
|
96
|
+
try {
|
|
97
|
+
await budgeted(queueEvents.waitUntilReady?.());
|
|
98
|
+
const job = await budgeted(queue.getJob(id));
|
|
99
|
+
if (!job) throw fail('job not found — it was removed before wait() could read it', false);
|
|
100
|
+
if (timedOut()) throw outOfTime();
|
|
101
|
+
return await budgeted(
|
|
102
|
+
job.waitUntilFinished(
|
|
103
|
+
queueEvents,
|
|
104
|
+
deadline === undefined ? undefined : deadline - Date.now() + LISTENER_GRACE_MS,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof QueueJobFailedError) throw error;
|
|
109
|
+
// Decided by the clock, not by how BullMQ happens to word its timeout.
|
|
110
|
+
if (error === DEADLINE || timedOut()) throw outOfTime();
|
|
111
|
+
const reason = redactOutbound(error instanceof Error ? error.message : String(error));
|
|
112
|
+
throw fail(reason, false, await failureCode(id));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const { id, migration } of jobs) {
|
|
117
|
+
results.push(await finish(id, `Migration job failed: ${migration}`, { migration, direction }));
|
|
118
|
+
}
|
|
119
|
+
if (!converge) return { groupId, direction, batch, results };
|
|
120
|
+
const converged = await finish(converge.id, 'Converge job failed', { kind: 'converge' });
|
|
121
|
+
return { groupId, direction, batch, results, converge: converged };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { waitForGroup };
|
package/src/cli/args.js
CHANGED
|
@@ -221,10 +221,20 @@ class Command {
|
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
/**
|
|
224
|
+
/**
|
|
225
|
+
* Seed negatable options: declaring `--no-x` makes `x` default to true —
|
|
226
|
+
* unless `--x` is declared too. Then, as in commander, the pair is
|
|
227
|
+
* tri-state: `true`, `false`, or unset (`undefined`) when neither was given,
|
|
228
|
+
* which is how a flag can override a config value only when actually passed.
|
|
229
|
+
*/
|
|
225
230
|
#seedNegatableDefaults() {
|
|
226
231
|
for (const option of this.#options) {
|
|
227
|
-
if (option.negated)
|
|
232
|
+
if (!option.negated) continue;
|
|
233
|
+
let twin = false;
|
|
234
|
+
for (const other of this.#options) {
|
|
235
|
+
if (!other.negated && other.key === option.key) twin = true;
|
|
236
|
+
}
|
|
237
|
+
if (!twin) this.#values[option.key] = true;
|
|
228
238
|
}
|
|
229
239
|
}
|
|
230
240
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
const { needsConfirmation } = require('../../core/converge-plan.js');
|
|
2
|
+
const { searchBuildState } = require('../../core/search-index-spec.js');
|
|
3
|
+
const { ConfigInvalidError, RunAbortedError } = require('../../errors/index.js');
|
|
4
|
+
const { confirm, defineCommand, EXIT_CODES } = require('../shared.js');
|
|
5
|
+
const { renderConvergeHistory, renderConvergeTable } = require('../table.js');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every row the operator must confirm, across all collections: a dropped or
|
|
9
|
+
* rebuilt index, a dropped search index, or a validator change on a
|
|
10
|
+
* collection that holds data.
|
|
11
|
+
*/
|
|
12
|
+
function actionsToConfirm(plan) {
|
|
13
|
+
const found = [];
|
|
14
|
+
for (const collection of plan.collections) {
|
|
15
|
+
for (const action of collection.actions) {
|
|
16
|
+
if (needsConfirmation(action, collection.actions)) {
|
|
17
|
+
found.push({ collection: collection.name, ...action });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return found;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a plan holds a conflict — the run refuses it whatever the answer */
|
|
25
|
+
function hasConflict(plan) {
|
|
26
|
+
return plan.collections.some((collection) =>
|
|
27
|
+
collection.actions.some((action) => action.action === 'conflict'),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assertNotStopped(stopRequested) {
|
|
32
|
+
if (stopRequested()) {
|
|
33
|
+
throw new RunAbortedError('Stopped by signal before anything was changed', { results: [] });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Register the `converge` command (declared indexes and validators → the database) */
|
|
38
|
+
function registerConverge(program) {
|
|
39
|
+
defineCommand(program, {
|
|
40
|
+
name: 'converge',
|
|
41
|
+
description:
|
|
42
|
+
'Bring declared collections (indexes, search indexes, validators) to their declared state',
|
|
43
|
+
options: [
|
|
44
|
+
['--dry-run', 'Show what would change without changing anything'],
|
|
45
|
+
[
|
|
46
|
+
'--check',
|
|
47
|
+
`Exit with code ${EXIT_CODES.COLLECTIONS_DRIFT} if anything would change (CI gate; implies --dry-run)`,
|
|
48
|
+
],
|
|
49
|
+
[
|
|
50
|
+
'--prune',
|
|
51
|
+
'Drop undeclared indexes and search indexes (in collections whose definition does not decide)',
|
|
52
|
+
],
|
|
53
|
+
['--ordered', 'Refuse while any migration is still pending'],
|
|
54
|
+
['--reason <text>', 'Why — recorded in the converge history (who: the OS user)'],
|
|
55
|
+
['--history', 'Show the converge history instead of converging (read-only)'],
|
|
56
|
+
['--limit <n>', 'How many history entries to show (with --history; default 20)'],
|
|
57
|
+
[
|
|
58
|
+
'--rebuild-unique',
|
|
59
|
+
'Allow rebuilding a unique index (drops the constraint until the new one is built)',
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
'--wait-search',
|
|
63
|
+
'Wait until every declared search index is queryable (overrides waitForSearchIndexes)',
|
|
64
|
+
],
|
|
65
|
+
['--no-wait-search', 'Do not wait for search indexes, whatever waitForSearchIndexes says'],
|
|
66
|
+
[
|
|
67
|
+
'-y, --yes',
|
|
68
|
+
'Drop and rebuild indexes, and change validators, without asking (required with --json)',
|
|
69
|
+
],
|
|
70
|
+
],
|
|
71
|
+
lockable: true,
|
|
72
|
+
mutating: true,
|
|
73
|
+
// Whether a run is destructive is only known once the live database has
|
|
74
|
+
// been read, so the confirmation cannot live in a preflight: plan first,
|
|
75
|
+
// ask only when the plan drops or rebuilds an index, then apply — the way
|
|
76
|
+
// `unlock` reads the lock before asking.
|
|
77
|
+
run: async (migrator, opts, _positionals, { logger, json, spinner, stopRequested }) => {
|
|
78
|
+
const waitSearch = typeof opts.waitSearch === 'boolean' ? opts.waitSearch : undefined;
|
|
79
|
+
if (waitSearch !== undefined && (opts.history || opts.dryRun || opts.check)) {
|
|
80
|
+
throw new ConfigInvalidError(
|
|
81
|
+
`--${waitSearch ? '' : 'no-'}wait-search applies to a real converge — not to ` +
|
|
82
|
+
`${opts.history ? '--history' : opts.check ? '--check' : '--dry-run'}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (opts.history) {
|
|
86
|
+
return migrator.convergeHistory(
|
|
87
|
+
opts.limit !== undefined ? { limit: Number(opts.limit) } : {},
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (opts.limit !== undefined) {
|
|
91
|
+
throw new ConfigInvalidError('--limit only applies to --history');
|
|
92
|
+
}
|
|
93
|
+
const prune = {
|
|
94
|
+
...(opts.prune ? { prune: true } : {}),
|
|
95
|
+
...(opts.rebuildUnique ? { rebuildUnique: true } : {}),
|
|
96
|
+
};
|
|
97
|
+
const ordered = opts.ordered ? { ordered: true } : {};
|
|
98
|
+
const planOnly = Boolean(opts.dryRun || opts.check);
|
|
99
|
+
if (planOnly || !opts.yes) {
|
|
100
|
+
spinner?.start('Comparing declared collections with the database…');
|
|
101
|
+
let plan;
|
|
102
|
+
try {
|
|
103
|
+
plan = await migrator.converge({ dryRun: true, ...prune });
|
|
104
|
+
} finally {
|
|
105
|
+
spinner?.stop();
|
|
106
|
+
}
|
|
107
|
+
if (planOnly) return plan;
|
|
108
|
+
assertNotStopped(stopRequested);
|
|
109
|
+
const destructive = actionsToConfirm(plan);
|
|
110
|
+
// A plan with a conflict is refused by the run itself — asking first
|
|
111
|
+
// would be a question whose answer changes nothing.
|
|
112
|
+
if (destructive.length > 0 && !hasConflict(plan)) {
|
|
113
|
+
// --json is non-interactive: dropping an index the operator never
|
|
114
|
+
// saw listed is exactly what the confirmation is for, so it needs an
|
|
115
|
+
// explicit --yes rather than a silent go-ahead. An additive plan
|
|
116
|
+
// applies without one.
|
|
117
|
+
if (json) {
|
|
118
|
+
throw new ConfigInvalidError(
|
|
119
|
+
`converge would drop or rebuild an index, drop a search index, or change a validator ` +
|
|
120
|
+
`(${destructive.length} change(s)) — pass --yes to confirm in --json mode`,
|
|
121
|
+
{ destructive },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
logger.info(renderConvergeTable(plan));
|
|
125
|
+
if (opts.rebuildUnique && destructive.some((action) => action.action === 'recreate')) {
|
|
126
|
+
logger.warn(
|
|
127
|
+
'⚠ --rebuild-unique: a rebuilt unique index enforces nothing until it is built ' +
|
|
128
|
+
'again — a duplicate written in between makes it unbuildable',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const proceed = await confirm('Apply these changes? [y/N] ');
|
|
132
|
+
if (!proceed) {
|
|
133
|
+
logger.info('Aborted');
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
assertNotStopped(stopRequested);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
spinner?.start('Converging…');
|
|
140
|
+
try {
|
|
141
|
+
return await migrator.converge({
|
|
142
|
+
noLock: opts.noLock,
|
|
143
|
+
...prune,
|
|
144
|
+
...ordered,
|
|
145
|
+
...(waitSearch !== undefined ? { waitForSearchIndexes: waitSearch } : {}),
|
|
146
|
+
...(opts.reason !== undefined ? { reason: opts.reason } : {}),
|
|
147
|
+
});
|
|
148
|
+
} finally {
|
|
149
|
+
spinner?.stop();
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
render: (result, { logger, opts }) => {
|
|
153
|
+
if (Array.isArray(result)) {
|
|
154
|
+
logger.info(renderConvergeHistory(result));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// A real run's own lines (✔ Created …, the rollup) are already out.
|
|
158
|
+
if (!result.dryRun) return;
|
|
159
|
+
if (result.collections.length === 0) {
|
|
160
|
+
logger.info('No collections declared — set collections or collectionsDir');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
logger.info(renderConvergeTable(result, { all: Boolean(opts.verbose) }));
|
|
164
|
+
},
|
|
165
|
+
after: (result, { logger, opts }) => {
|
|
166
|
+
if (!opts.check || result === undefined) return;
|
|
167
|
+
// A search index that failed to build serves nothing, whatever its
|
|
168
|
+
// definition says — the gate fails on it too, though converge cannot fix it.
|
|
169
|
+
// .error writes to stderr, so JSON stdout stays a single clean document.
|
|
170
|
+
let failed = 0;
|
|
171
|
+
for (const index of result.search?.notReady ?? []) {
|
|
172
|
+
if (searchBuildState(index) !== 'failed') continue;
|
|
173
|
+
failed += 1;
|
|
174
|
+
logger.error(
|
|
175
|
+
`✖ Search index ${index.collection} "${index.name}" failed to build` +
|
|
176
|
+
`${index.message ? `: ${index.message}` : ''}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (result.inSync && failed === 0) return;
|
|
180
|
+
if (!result.inSync) logger.error('✖ The database differs from the declared collections');
|
|
181
|
+
// A dedicated code: a CI gate must tell "out of step" (act: converge)
|
|
182
|
+
// from "the check itself crashed" (act: page).
|
|
183
|
+
process.exitCode = EXIT_CODES.COLLECTIONS_DRIFT;
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { registerConverge };
|
package/src/cli/commands/down.js
CHANGED
|
@@ -11,6 +11,7 @@ function registerDown(program) {
|
|
|
11
11
|
['--batch <n>', 'Revert a specific batch number'],
|
|
12
12
|
['--steps <n>', 'Revert the last N migrations, regardless of batch'],
|
|
13
13
|
['--to <file>', 'Revert everything applied after this file (it stays applied)'],
|
|
14
|
+
['--reason <text>', 'Why — recorded on the changelog with the run (who: the OS user)'],
|
|
14
15
|
],
|
|
15
16
|
lockable: true,
|
|
16
17
|
mutating: true,
|
|
@@ -22,6 +23,7 @@ function registerDown(program) {
|
|
|
22
23
|
...(opts.batch !== undefined ? { batch: Number(opts.batch) } : {}),
|
|
23
24
|
...(opts.steps !== undefined ? { steps: Number(opts.steps) } : {}),
|
|
24
25
|
...(opts.to ? { to: opts.to } : {}),
|
|
26
|
+
...(opts.reason !== undefined ? { reason: opts.reason } : {}),
|
|
25
27
|
}),
|
|
26
28
|
// No render: core logs every ↩ Reverted line itself.
|
|
27
29
|
});
|
package/src/cli/commands/lock.js
CHANGED
|
@@ -26,7 +26,8 @@ function registerLock(program) {
|
|
|
26
26
|
}
|
|
27
27
|
logger.info(
|
|
28
28
|
`Lock held by pid ${holder.pid} on ${holder.host} (${holder.executedBy}) ` +
|
|
29
|
-
`since ${lockedAtText(holder.lockedAt)}
|
|
29
|
+
`since ${lockedAtText(holder.lockedAt)}` +
|
|
30
|
+
(holder.runId ? ` — run ${holder.runId}` : ''),
|
|
30
31
|
);
|
|
31
32
|
},
|
|
32
33
|
});
|
package/src/cli/commands/redo.js
CHANGED
|
@@ -6,9 +6,16 @@ function registerRedo(program) {
|
|
|
6
6
|
name: 'redo',
|
|
7
7
|
description: 'Rollback then re-apply the last applied migration, or a specific file',
|
|
8
8
|
args: [['[file]', 'Specific migration file to redo']],
|
|
9
|
+
options: [
|
|
10
|
+
['--reason <text>', 'Why — recorded on the changelog with the run (who: the OS user)'],
|
|
11
|
+
],
|
|
9
12
|
lockable: true,
|
|
10
13
|
mutating: true,
|
|
11
|
-
run: (migrator, opts, [file]) =>
|
|
14
|
+
run: (migrator, opts, [file]) =>
|
|
15
|
+
migrator.redo(file, {
|
|
16
|
+
noLock: opts.noLock,
|
|
17
|
+
...(opts.reason !== undefined ? { reason: opts.reason } : {}),
|
|
18
|
+
}),
|
|
12
19
|
// No render: core logs the ↩/✔ lines itself.
|
|
13
20
|
});
|
|
14
21
|
}
|
package/src/cli/commands/up.js
CHANGED
|
@@ -13,6 +13,9 @@ function registerUp(program) {
|
|
|
13
13
|
['-f, --force', 'Re-run an already-applied migration (requires a file)'],
|
|
14
14
|
['-y, --yes', 'Confirm --force non-interactively (required with --json)'],
|
|
15
15
|
['--step', 'Apply each migration as its own batch (revert individually later)'],
|
|
16
|
+
['--converge', 'Converge the declared collections afterwards (overrides convergeAfterUp)'],
|
|
17
|
+
['--no-converge', 'Do not converge afterwards, whatever convergeAfterUp says'],
|
|
18
|
+
['--reason <text>', 'Why — recorded on the changelog with the run (who: the OS user)'],
|
|
16
19
|
],
|
|
17
20
|
lockable: true,
|
|
18
21
|
mutating: true,
|
|
@@ -23,6 +26,11 @@ function registerUp(program) {
|
|
|
23
26
|
if (opts.force && !file) {
|
|
24
27
|
throw new ConfigInvalidError('--force requires a specific migration file');
|
|
25
28
|
}
|
|
29
|
+
if (opts.converge === true && (file || opts.to)) {
|
|
30
|
+
throw new ConfigInvalidError(
|
|
31
|
+
'--converge needs a bulk up — it cannot follow a single file or --to',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
26
34
|
if (opts.force && file && !opts.yes) {
|
|
27
35
|
// --json is non-interactive: refuse rather than silently re-running or
|
|
28
36
|
// hanging on a prompt that can't be answered. --yes is the explicit opt-in.
|
|
@@ -45,8 +53,13 @@ function registerUp(program) {
|
|
|
45
53
|
...(opts.force ? { force: true } : {}),
|
|
46
54
|
...(opts.step ? { step: true } : {}),
|
|
47
55
|
...(opts.to ? { to: opts.to } : {}),
|
|
56
|
+
...(typeof opts.converge === 'boolean' ? { converge: opts.converge } : {}),
|
|
57
|
+
...(opts.reason !== undefined ? { reason: opts.reason } : {}),
|
|
48
58
|
}),
|
|
49
|
-
// No render: core logs every ✔ Applied line itself
|
|
59
|
+
// No render: core logs every ✔ Applied line itself — and, after a bulk run
|
|
60
|
+
// that converges, every converge line too. `--json` stays the migration
|
|
61
|
+
// rows: an array cannot carry the converge result without breaking its
|
|
62
|
+
// consumers (that is what `migronaut converge --json` is for).
|
|
50
63
|
});
|
|
51
64
|
}
|
|
52
65
|
|
package/src/cli/exit-codes.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* script testing `!= 0` is unaffected.
|
|
5
5
|
*
|
|
6
6
|
* Every MigronautError code has an entry (pinned by a superset test), plus
|
|
7
|
-
*
|
|
8
|
-
* (`status --check` found work)
|
|
7
|
+
* three CLI-condition codes that have no error class: PENDING_MIGRATIONS
|
|
8
|
+
* (`status --check` found work), AUDIT_FAILED (an audit check failed) and
|
|
9
|
+
* COLLECTIONS_DRIFT (`converge --check` found the database out of step with
|
|
10
|
+
* the declared collections).
|
|
9
11
|
*
|
|
10
12
|
* Exported from the package root so a programmatic wrapper can mirror the
|
|
11
13
|
* CLI's exit semantics without hardcoding numbers from the docs table. Kept
|
|
@@ -34,6 +36,11 @@ const EXIT_CODES = {
|
|
|
34
36
|
LOCK_RELEASE_FAILED: 21,
|
|
35
37
|
AUDIT_FAILED: 22,
|
|
36
38
|
MIGRATION_OUT_OF_ORDER: 23,
|
|
39
|
+
MIGRATION_BLOCKED: 24,
|
|
40
|
+
QUEUE_JOB_INVALID: 25,
|
|
41
|
+
QUEUE_JOB_FAILED: 26,
|
|
42
|
+
CONVERGE_FAILED: 27,
|
|
43
|
+
COLLECTIONS_DRIFT: 28,
|
|
37
44
|
};
|
|
38
45
|
|
|
39
46
|
module.exports = { EXIT_CODES };
|
package/src/cli/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { Command } = require('./args.js');
|
|
2
2
|
const { registerAudit } = require('./commands/audit.js');
|
|
3
3
|
const { registerBaseline } = require('./commands/baseline.js');
|
|
4
|
+
const { registerConverge } = require('./commands/converge.js');
|
|
4
5
|
const { registerCreate } = require('./commands/create.js');
|
|
5
6
|
const { registerDown } = require('./commands/down.js');
|
|
6
7
|
const { registerDryRun } = require('./commands/dry-run.js');
|
|
@@ -42,6 +43,7 @@ function buildProgram() {
|
|
|
42
43
|
registerUp(program);
|
|
43
44
|
registerDown(program);
|
|
44
45
|
registerRedo(program);
|
|
46
|
+
registerConverge(program);
|
|
45
47
|
registerStatus(program);
|
|
46
48
|
registerList(program);
|
|
47
49
|
registerDryRun(program);
|
package/src/cli/shared.js
CHANGED
|
@@ -162,9 +162,13 @@ function reportError(error, { json, verbose, logger }) {
|
|
|
162
162
|
/**
|
|
163
163
|
* Construct a MigratorKit from CLI options, run `fn(migrator, cli)`, always
|
|
164
164
|
* disconnect, and translate failures into a non-zero exit code with a
|
|
165
|
-
* readable message. `cli` is `{ logger, json, opts
|
|
166
|
-
* logger every command must render through, so
|
|
167
|
-
* command output and not only to core's log
|
|
165
|
+
* readable message. `cli` is `{ logger, json, opts, spinner, stopRequested }`:
|
|
166
|
+
* the one level-aware logger every command must render through, so
|
|
167
|
+
* `--quiet`/`--verbose` apply to command output and not only to core's log
|
|
168
|
+
* lines; the spinner (undefined in JSON or quiet mode) for a command that
|
|
169
|
+
* drives its own progress text; and whether a signal asked to stop — which a
|
|
170
|
+
* command that reads, asks, then acts must check between those steps, since
|
|
171
|
+
* `migrator.stop()` is a no-op while no run is in flight.
|
|
168
172
|
*/
|
|
169
173
|
async function withMigrator(opts, fn, options = {}) {
|
|
170
174
|
// Required here, not at module top: the orchestrator is the CLI's one heavy
|
|
@@ -237,7 +241,13 @@ async function withMigrator(opts, fn, options = {}) {
|
|
|
237
241
|
if (detachSignals.stopRequested?.()) {
|
|
238
242
|
throw new RunAbortedError('Stopped by signal before the run started', { results: [] });
|
|
239
243
|
}
|
|
240
|
-
await fn(migrator, {
|
|
244
|
+
await fn(migrator, {
|
|
245
|
+
logger,
|
|
246
|
+
json,
|
|
247
|
+
opts,
|
|
248
|
+
spinner,
|
|
249
|
+
stopRequested: () => detachSignals.stopRequested?.() ?? false,
|
|
250
|
+
});
|
|
241
251
|
} catch (error) {
|
|
242
252
|
// Safety net: clear any spinner still spinning before printing the error.
|
|
243
253
|
spinner?.stop();
|
package/src/cli/table.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { TARGET_LABELS, isDestructive } = require('../core/converge-plan.js');
|
|
2
|
+
const { searchBuildState } = require('../core/search-index-spec.js');
|
|
1
3
|
const { createColors, stripAnsi } = require('../utils/colors.js');
|
|
2
4
|
const { formatDateTime } = require('../utils/date.js');
|
|
3
5
|
// Shared with the logger and spinner — cell values come from the changelog and
|
|
@@ -247,6 +249,166 @@ function renderImportTable(rows) {
|
|
|
247
249
|
return renderTable(head, cells);
|
|
248
250
|
}
|
|
249
251
|
|
|
252
|
+
/** Longest index or collection name rendered before it is ellipsized */
|
|
253
|
+
const MAX_NAME_WIDTH = 48;
|
|
254
|
+
|
|
255
|
+
/** Longest converge detail rendered before it is ellipsized */
|
|
256
|
+
const MAX_DETAIL_WIDTH = 72;
|
|
257
|
+
|
|
258
|
+
/** Render a converge action cell: what changes stands out, what does not recedes */
|
|
259
|
+
function convergeActionCell(colors, action) {
|
|
260
|
+
switch (action) {
|
|
261
|
+
case 'create':
|
|
262
|
+
return colors.green(action);
|
|
263
|
+
case 'modify':
|
|
264
|
+
return colors.cyan(action);
|
|
265
|
+
case 'recreate':
|
|
266
|
+
case 'drop':
|
|
267
|
+
return colors.yellow(action);
|
|
268
|
+
case 'conflict':
|
|
269
|
+
return colors.red(action);
|
|
270
|
+
default:
|
|
271
|
+
return colors.dim(action);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Where the server is with a search index, when it is not simply serving it */
|
|
276
|
+
function searchBuildDetail(build) {
|
|
277
|
+
if (build === undefined) return '';
|
|
278
|
+
switch (searchBuildState(build)) {
|
|
279
|
+
case 'serving':
|
|
280
|
+
return '';
|
|
281
|
+
case 'failed':
|
|
282
|
+
return `FAILED${build.message ? `: ${build.message}` : ''}`;
|
|
283
|
+
case 'updating':
|
|
284
|
+
return 'updating';
|
|
285
|
+
case 'stale':
|
|
286
|
+
return 'STALE — not replicating';
|
|
287
|
+
default:
|
|
288
|
+
return build.status;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Whether a search index row is one worth showing even when nothing changes: not serving yet */
|
|
293
|
+
function searchNotServing(action) {
|
|
294
|
+
return action.target === 'searchIndex' && searchBuildDetail(action.build) !== '';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The detail column: what differs, or why the row is what it is — and a search index's build */
|
|
298
|
+
function convergeDetail(action) {
|
|
299
|
+
if (action.reason === 'name' && action.liveName !== undefined) {
|
|
300
|
+
return `renamed from "${action.liveName}"`;
|
|
301
|
+
}
|
|
302
|
+
if (action.target !== 'searchIndex') return action.reason ?? '';
|
|
303
|
+
const parts = [];
|
|
304
|
+
const type = action.to?.type ?? action.from?.type;
|
|
305
|
+
if (type === 'vectorSearch' && (action.action === 'create' || action.action === 'modify')) {
|
|
306
|
+
parts.push('vectorSearch');
|
|
307
|
+
}
|
|
308
|
+
if (action.reason) parts.push(action.reason);
|
|
309
|
+
const build = searchBuildDetail(action.build);
|
|
310
|
+
if (build) parts.push(build);
|
|
311
|
+
return parts.join(' · ');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Render a converge plan (or result) as a table plus a one-line summary.
|
|
316
|
+
* Rows that need nothing are folded into the summary unless `all` — a large
|
|
317
|
+
* schema would otherwise bury its two changes under fifty "unchanged" lines.
|
|
318
|
+
*/
|
|
319
|
+
function renderConvergeTable(result, { all = false } = {}) {
|
|
320
|
+
const colors = palette();
|
|
321
|
+
const cells = [];
|
|
322
|
+
const counts = {
|
|
323
|
+
change: 0,
|
|
324
|
+
destructive: 0,
|
|
325
|
+
conflict: 0,
|
|
326
|
+
keep: 0,
|
|
327
|
+
searchKeep: 0,
|
|
328
|
+
skip: 0,
|
|
329
|
+
unchanged: 0,
|
|
330
|
+
touched: 0,
|
|
331
|
+
};
|
|
332
|
+
for (const collection of result.collections) {
|
|
333
|
+
let changes = 0;
|
|
334
|
+
for (const action of collection.actions) {
|
|
335
|
+
if (action.action === 'unchanged') counts.unchanged += 1;
|
|
336
|
+
else if (action.action === 'keep' && action.target === 'searchIndex') counts.searchKeep += 1;
|
|
337
|
+
else if (action.action === 'keep') counts.keep += 1;
|
|
338
|
+
else if (action.action === 'conflict') counts.conflict += 1;
|
|
339
|
+
else if (action.action === 'skip') counts.skip += 1;
|
|
340
|
+
else changes += 1;
|
|
341
|
+
if (isDestructive(action)) counts.destructive += 1;
|
|
342
|
+
if (action.action === 'unchanged' && !all && !searchNotServing(action)) continue;
|
|
343
|
+
const named = action.target === 'index' || action.target === 'searchIndex';
|
|
344
|
+
cells.push([
|
|
345
|
+
truncate(sanitize(collection.name), MAX_NAME_WIDTH),
|
|
346
|
+
TARGET_LABELS[action.target] ?? action.target,
|
|
347
|
+
named ? truncate(sanitize(action.name), MAX_NAME_WIDTH) : '',
|
|
348
|
+
convergeActionCell(colors, action.action),
|
|
349
|
+
truncate(sanitize(convergeDetail(action)), MAX_DETAIL_WIDTH),
|
|
350
|
+
]);
|
|
351
|
+
}
|
|
352
|
+
counts.change += changes;
|
|
353
|
+
if (changes > 0) counts.touched += 1;
|
|
354
|
+
}
|
|
355
|
+
const collections = result.collections.length;
|
|
356
|
+
let summary;
|
|
357
|
+
if (counts.change === 0 && counts.conflict === 0) {
|
|
358
|
+
summary = `✔ ${collections} collection(s) match their declarations`;
|
|
359
|
+
} else {
|
|
360
|
+
const verb = result.dryRun ? 'Would make' : 'Made';
|
|
361
|
+
summary =
|
|
362
|
+
`${verb} ${counts.change} change(s) in ${counts.touched} of ${collections} ` +
|
|
363
|
+
'collection(s)';
|
|
364
|
+
}
|
|
365
|
+
const parts = [summary];
|
|
366
|
+
if (counts.destructive > 0) parts.push(`${counts.destructive} drop/rebuild`);
|
|
367
|
+
if (counts.conflict > 0) parts.push(colors.red(`${counts.conflict} conflict(s)`));
|
|
368
|
+
if (counts.keep > 0) parts.push(`${counts.keep} undeclared index(es) kept`);
|
|
369
|
+
if (counts.searchKeep > 0) parts.push(`${counts.searchKeep} undeclared search index(es) kept`);
|
|
370
|
+
if (counts.skip > 0) {
|
|
371
|
+
parts.push(colors.yellow(`${counts.skip} search index(es) skipped — Search unavailable`));
|
|
372
|
+
}
|
|
373
|
+
let building = 0;
|
|
374
|
+
let stale = 0;
|
|
375
|
+
let failed = 0;
|
|
376
|
+
for (const index of result.search?.notReady ?? []) {
|
|
377
|
+
const state = searchBuildState(index);
|
|
378
|
+
if (state === 'failed') failed += 1;
|
|
379
|
+
else if (state === 'stale') stale += 1;
|
|
380
|
+
else building += 1;
|
|
381
|
+
}
|
|
382
|
+
if (building > 0) parts.push(`${building} search index(es) building`);
|
|
383
|
+
if (stale > 0) parts.push(colors.yellow(`${stale} search index(es) stale`));
|
|
384
|
+
if (failed > 0) parts.push(colors.red(`${failed} search index(es) failed`));
|
|
385
|
+
if (counts.unchanged > 0 && !all) parts.push(`${counts.unchanged} unchanged`);
|
|
386
|
+
const line = parts.join(' · ');
|
|
387
|
+
if (cells.length === 0) return line;
|
|
388
|
+
return `${renderTable(['Collection', 'Target', 'Index', 'Action', 'Detail'], cells)}\n${line}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Render the converge history: one line per converge that changed something
|
|
393
|
+
* or failed, newest first — the audit view (`migronaut converge --history`).
|
|
394
|
+
*/
|
|
395
|
+
function renderConvergeHistory(entries) {
|
|
396
|
+
if (entries.length === 0) return 'No converge has changed anything yet';
|
|
397
|
+
const colors = palette();
|
|
398
|
+
const cells = entries.map((entry) => [
|
|
399
|
+
entry.startedAt instanceof Date ? entry.startedAt.toISOString() : String(entry.startedAt),
|
|
400
|
+
entry.trigger,
|
|
401
|
+
entry.success ? colors.green('ok') : colors.red('failed'),
|
|
402
|
+
String(entry.changed),
|
|
403
|
+
truncate(sanitize(entry.requestedBy ?? entry.executedBy ?? ''), MAX_NAME_WIDTH),
|
|
404
|
+
truncate(
|
|
405
|
+
sanitize(entry.reason ?? (entry.success ? '' : (entry.error ?? ''))),
|
|
406
|
+
MAX_DETAIL_WIDTH,
|
|
407
|
+
),
|
|
408
|
+
]);
|
|
409
|
+
return renderTable(['When', 'Trigger', 'Result', 'Changes', 'Who', 'Why'], cells);
|
|
410
|
+
}
|
|
411
|
+
|
|
250
412
|
module.exports = {
|
|
251
413
|
charWidth,
|
|
252
414
|
sanitize,
|
|
@@ -256,4 +418,6 @@ module.exports = {
|
|
|
256
418
|
renderStatusTable,
|
|
257
419
|
renderImportTable,
|
|
258
420
|
renderRowsOrEmpty,
|
|
421
|
+
renderConvergeTable,
|
|
422
|
+
renderConvergeHistory,
|
|
259
423
|
};
|