@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
package/src/core/runner.js
CHANGED
|
@@ -27,9 +27,9 @@ function isTransactionsUnsupported(error) {
|
|
|
27
27
|
* timeout buys is the *run* stopping instead of hanging forever — which also
|
|
28
28
|
* lets the lock's TTL expire, so a wedged migration no longer blocks every
|
|
29
29
|
* other instance indefinitely. Migrations that need real cancellation should
|
|
30
|
-
* watch `ctx.signal
|
|
30
|
+
* watch `ctx.signal`, which `onTimeout` aborts when the timer fires.
|
|
31
31
|
*/
|
|
32
|
-
async function withTimeout(promise, timeoutMs, name, direction) {
|
|
32
|
+
async function withTimeout(promise, timeoutMs, name, direction, onTimeout) {
|
|
33
33
|
if (!timeoutMs) return promise;
|
|
34
34
|
let timer;
|
|
35
35
|
try {
|
|
@@ -43,16 +43,19 @@ async function withTimeout(promise, timeoutMs, name, direction) {
|
|
|
43
43
|
// unhandledRejection long after the run already reported the
|
|
44
44
|
// timeout. Swallow it: the timeout is the reported failure.
|
|
45
45
|
Promise.resolve(promise).catch(() => {});
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
},
|
|
54
|
-
),
|
|
46
|
+
const timeoutError = new MigrationTimeoutError(
|
|
47
|
+
`Migration ${direction} timed out after ${timeoutMs}ms: ${name}`,
|
|
48
|
+
{
|
|
49
|
+
name,
|
|
50
|
+
direction,
|
|
51
|
+
timeoutMs,
|
|
52
|
+
},
|
|
55
53
|
);
|
|
54
|
+
// Told, not just abandoned: the caller aborts the context's signal
|
|
55
|
+
// with this error, so a body that watches ctx.signal can stop
|
|
56
|
+
// writing instead of racing whoever acquires the lock next.
|
|
57
|
+
onTimeout?.(timeoutError);
|
|
58
|
+
reject(timeoutError);
|
|
56
59
|
}, timeoutMs);
|
|
57
60
|
timer.unref?.();
|
|
58
61
|
}),
|
|
@@ -77,7 +80,10 @@ async function withTimeout(promise, timeoutMs, name, direction) {
|
|
|
77
80
|
*
|
|
78
81
|
* On any error the `onError` hook is invoked before a
|
|
79
82
|
* MigrationExecutionFailedError is thrown — the error is never swallowed, and a
|
|
80
|
-
* throwing hook cannot mask the original failure.
|
|
83
|
+
* throwing hook cannot mask the original failure. The one exception: when the
|
|
84
|
+
* body succeeded and only the (non-transactional) changelog write failed, the
|
|
85
|
+
* error carries `context.phase = 'changelog-write'` and `onError` is not fired —
|
|
86
|
+
* the migration itself did not fail.
|
|
81
87
|
*/
|
|
82
88
|
async function runMigration(params) {
|
|
83
89
|
const { name, migration, direction, context, useTransaction, hooks, onSuccess, logger } = params;
|
|
@@ -87,30 +93,71 @@ async function runMigration(params) {
|
|
|
87
93
|
|
|
88
94
|
const start = Date.now();
|
|
89
95
|
let session;
|
|
90
|
-
|
|
96
|
+
// JavaScript cannot cancel a running body, but it can tell it to stop: this
|
|
97
|
+
// controller feeds the context's signal, so the documented "watch ctx.signal"
|
|
98
|
+
// advice covers the migration's own timeout too — not only lock loss and
|
|
99
|
+
// stop(). Without it a timed-out body keeps writing after the lock is
|
|
100
|
+
// released, racing whoever acquires it next.
|
|
101
|
+
const timedOut = new AbortController();
|
|
102
|
+
const signal = context.signal
|
|
103
|
+
? AbortSignal.any([context.signal, timedOut.signal])
|
|
104
|
+
: timedOut.signal;
|
|
105
|
+
let runtimeContext = { ...context, signal };
|
|
106
|
+
const onTimeout = (timeoutError) => timedOut.abort(timeoutError);
|
|
91
107
|
let duration = 0;
|
|
108
|
+
// 'body' while the migration's own code runs; 'changelog' once it committed
|
|
109
|
+
// and only the record write remains. The two failures need different
|
|
110
|
+
// reporting: a changelog failure after a committed body must not read as
|
|
111
|
+
// "the migration failed" — that invites a re-run of already-applied writes.
|
|
112
|
+
let phase = 'body';
|
|
92
113
|
|
|
93
114
|
try {
|
|
94
115
|
if (useTransaction) {
|
|
95
116
|
session = context.client.startSession();
|
|
96
|
-
runtimeContext = { ...
|
|
117
|
+
runtimeContext = { ...runtimeContext, session };
|
|
97
118
|
// withTransaction may run the body more than once when the driver retries
|
|
98
|
-
// a transient failure, so duration is re-measured on each attempt.
|
|
119
|
+
// a transient failure, so duration is re-measured on each attempt. The
|
|
120
|
+
// changelog write stays inside the transaction, so a failure there
|
|
121
|
+
// aborts the body's writes too — 'body' phase is accurate throughout.
|
|
99
122
|
await session.withTransaction(async () => {
|
|
100
123
|
const attemptStart = Date.now();
|
|
101
|
-
await withTimeout(fn(runtimeContext), timeoutMs, name, direction);
|
|
124
|
+
await withTimeout(fn(runtimeContext), timeoutMs, name, direction, onTimeout);
|
|
102
125
|
duration = Date.now() - attemptStart;
|
|
103
126
|
await onSuccess?.(duration, session);
|
|
104
127
|
});
|
|
105
128
|
} else {
|
|
106
|
-
await withTimeout(fn(runtimeContext), timeoutMs, name, direction);
|
|
129
|
+
await withTimeout(fn(runtimeContext), timeoutMs, name, direction, onTimeout);
|
|
107
130
|
duration = Date.now() - start;
|
|
131
|
+
phase = 'changelog';
|
|
108
132
|
await onSuccess?.(duration, undefined);
|
|
109
133
|
}
|
|
110
134
|
|
|
111
135
|
return { duration };
|
|
112
136
|
} catch (error) {
|
|
113
137
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
138
|
+
// Failures deserve timing data as much as successes — a slow-then-failing
|
|
139
|
+
// migration is exactly what an operator alerts on.
|
|
140
|
+
const elapsed = Date.now() - start;
|
|
141
|
+
|
|
142
|
+
// Without a transaction the body's writes are already committed when the
|
|
143
|
+
// changelog write fails — say exactly that, instead of the generic
|
|
144
|
+
// "migration failed" that would invite re-running committed writes. The
|
|
145
|
+
// onError hook is for migration failures, so it does not fire here.
|
|
146
|
+
if (phase === 'changelog') {
|
|
147
|
+
throw new MigrationExecutionFailedError(
|
|
148
|
+
`Migration ${direction} succeeded but recording it in the changelog failed: ${name} — ` +
|
|
149
|
+
'its own writes are committed; verify the changelog before re-running',
|
|
150
|
+
{
|
|
151
|
+
name,
|
|
152
|
+
direction,
|
|
153
|
+
phase: 'changelog-write',
|
|
154
|
+
bodySucceeded: true,
|
|
155
|
+
durationMs: elapsed,
|
|
156
|
+
cause: err.message,
|
|
157
|
+
},
|
|
158
|
+
{ cause: err },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
114
161
|
|
|
115
162
|
if (hooks?.onError) {
|
|
116
163
|
// A throwing onError hook must not replace the real cause.
|
|
@@ -122,14 +169,17 @@ async function runMigration(params) {
|
|
|
122
169
|
}
|
|
123
170
|
}
|
|
124
171
|
|
|
125
|
-
if (err instanceof MigrationTimeoutError)
|
|
172
|
+
if (err instanceof MigrationTimeoutError) {
|
|
173
|
+
err.context = { durationMs: elapsed, ...err.context };
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
126
176
|
// A standalone deployment refusing the transaction is a topology problem,
|
|
127
177
|
// not a bug in the migration — say so instead of blaming the file.
|
|
128
178
|
if (useTransaction && isTransactionsUnsupported(err)) {
|
|
129
179
|
throw new TransactionsUnsupportedError(
|
|
130
180
|
`Cannot run ${name} in a transaction — this deployment is standalone. ` +
|
|
131
181
|
'Set useTransaction: false, or run against a replica set / mongos.',
|
|
132
|
-
{ name, direction, cause: err.message },
|
|
182
|
+
{ name, direction, durationMs: elapsed, cause: err.message },
|
|
133
183
|
{ cause: err },
|
|
134
184
|
);
|
|
135
185
|
}
|
|
@@ -137,7 +187,7 @@ async function runMigration(params) {
|
|
|
137
187
|
`Migration ${direction} failed: ${name}`,
|
|
138
188
|
// The message is duplicated into context because that is what survives
|
|
139
189
|
// JSON serialization; `cause` keeps the real Error (and its stack).
|
|
140
|
-
{ name, direction, cause: err.message },
|
|
190
|
+
{ name, direction, durationMs: elapsed, cause: err.message },
|
|
141
191
|
{ cause: err },
|
|
142
192
|
);
|
|
143
193
|
} finally {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const { MigrationBlockedError, MigrationFileNotFoundError } = require('../errors/index.js');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The migration sequence: the files on disk, in name order, measured against
|
|
6
|
+
* the names the changelog holds as applied — which are pending, which arrived
|
|
7
|
+
* late, which stand in the way of an ordered step, and in what order applied
|
|
8
|
+
* records are reverted. Pure apart from reading the directory; what to do
|
|
9
|
+
* with each answer (refuse, warn, wait) is the kit's decision.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Declaration files sit next to TypeScript migrations and are never one */
|
|
13
|
+
const DECLARATION_SUFFIXES = ['.d.ts', '.d.mts', '.d.cts'];
|
|
14
|
+
|
|
15
|
+
/** The migration files in `dir`, sorted ascending — empty when the directory does not exist */
|
|
16
|
+
async function listMigrationFiles(dir, extensions) {
|
|
17
|
+
let entries;
|
|
18
|
+
try {
|
|
19
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error.code === 'ENOENT') return [];
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
const matches = [];
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
// A directory named `foo.js`, a dotfile, or a `types.d.ts` sitting next
|
|
27
|
+
// to the migrations is not a migration — including it would hard-fail
|
|
28
|
+
// the whole run with MigrationInvalidExportError.
|
|
29
|
+
if (!entry.isFile()) continue;
|
|
30
|
+
const file = entry.name;
|
|
31
|
+
if (file.startsWith('.')) continue;
|
|
32
|
+
if (DECLARATION_SUFFIXES.some((suffix) => file.endsWith(suffix))) continue;
|
|
33
|
+
for (const ext of extensions) {
|
|
34
|
+
if (file.endsWith(ext)) {
|
|
35
|
+
matches.push(file);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return matches.sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The files of `sequence` with no applied name — all of them, or only those
|
|
45
|
+
* sorting before `before`. Pending means "no applied record": a `'failed'`
|
|
46
|
+
* trace counts, so a migration that failed stops the line exactly like one
|
|
47
|
+
* that never ran.
|
|
48
|
+
*/
|
|
49
|
+
function pendingIn(sequence, appliedNames, before) {
|
|
50
|
+
const pending = [];
|
|
51
|
+
for (const file of sequence) {
|
|
52
|
+
if (before !== undefined && file >= before) break;
|
|
53
|
+
if (!appliedNames.has(file)) pending.push(file);
|
|
54
|
+
}
|
|
55
|
+
return pending;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Keep only the pending migrations up to and including `to`.
|
|
60
|
+
*
|
|
61
|
+
* `to` must name a migration that exists; it may already be applied (then
|
|
62
|
+
* nothing before it is pending either, and the result is empty), which is
|
|
63
|
+
* what makes `up --to X` idempotent — running it twice is a no-op rather
|
|
64
|
+
* than an error.
|
|
65
|
+
*/
|
|
66
|
+
function truncateAtTarget(pending, allFiles, to) {
|
|
67
|
+
if (!allFiles.includes(to)) {
|
|
68
|
+
throw new MigrationFileNotFoundError('Migration file not found', { to });
|
|
69
|
+
}
|
|
70
|
+
const kept = [];
|
|
71
|
+
for (const file of pending) {
|
|
72
|
+
if (file > to) break;
|
|
73
|
+
kept.push(file);
|
|
74
|
+
}
|
|
75
|
+
return kept;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The name that sorts last — '' for none */
|
|
79
|
+
function newestOf(names) {
|
|
80
|
+
let newest = '';
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
if (name > newest) newest = name;
|
|
83
|
+
}
|
|
84
|
+
return newest;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Out-of-order arrivals among `targets`: pending files that sort before the
|
|
89
|
+
* newest applied name — migrations merged late from a parallel branch, which
|
|
90
|
+
* will run after migrations authored later. `null` when there are none.
|
|
91
|
+
*/
|
|
92
|
+
function lateArrivals(targets, appliedNames) {
|
|
93
|
+
if (targets.length === 0 || appliedNames.size === 0) return null;
|
|
94
|
+
const newestApplied = newestOf(appliedNames);
|
|
95
|
+
const late = [];
|
|
96
|
+
for (const target of targets) {
|
|
97
|
+
if (!appliedNames.has(target) && target < newestApplied) late.push(target);
|
|
98
|
+
}
|
|
99
|
+
return late.length > 0 ? { late, newestApplied } : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The names of the records to revert, newest first unless already in revert order */
|
|
103
|
+
function revertOrder(records, preserveOrder) {
|
|
104
|
+
const names = [];
|
|
105
|
+
for (const record of records) names.push(record.name);
|
|
106
|
+
if (!preserveOrder) {
|
|
107
|
+
names.sort();
|
|
108
|
+
names.reverse();
|
|
109
|
+
}
|
|
110
|
+
return names;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The refusal of an ordered step: "`subject` is blocked: N `what`: a, b".
|
|
115
|
+
* `context` carries `blockedBy` (and `failed`, the blockers with a failed
|
|
116
|
+
* trace — a stopped line rather than one still on its way).
|
|
117
|
+
*/
|
|
118
|
+
function blockedError(subject, what, context) {
|
|
119
|
+
const { blockedBy } = context;
|
|
120
|
+
return new MigrationBlockedError(
|
|
121
|
+
`${subject} is blocked: ${blockedBy.length} ${what}: ${blockedBy.join(', ')}`,
|
|
122
|
+
context,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
blockedError,
|
|
128
|
+
lateArrivals,
|
|
129
|
+
listMigrationFiles,
|
|
130
|
+
newestOf,
|
|
131
|
+
pendingIn,
|
|
132
|
+
revertOrder,
|
|
133
|
+
truncateAtTarget,
|
|
134
|
+
};
|
package/src/errors/index.js
CHANGED
|
@@ -183,7 +183,7 @@ class ImportTargetNotEmptyError extends MigronautError {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
/** Thrown when attempting to roll back a
|
|
186
|
+
/** Thrown when attempting to roll back a forward-only (imported or baselined) migration */
|
|
187
187
|
class IrreversibleMigrationError extends MigronautError {
|
|
188
188
|
constructor(message, context, options) {
|
|
189
189
|
super('MIGRATION_IRREVERSIBLE', message, context, options);
|
|
@@ -191,6 +191,71 @@ class IrreversibleMigrationError extends MigronautError {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Thrown by a bulk `up` under `onOutOfOrder: 'error'` when a pending migration
|
|
196
|
+
* sorts before the newest applied one — a file merged late from a parallel
|
|
197
|
+
* branch, which would otherwise run after migrations authored later and leave
|
|
198
|
+
* environments with different effective apply orders.
|
|
199
|
+
*/
|
|
200
|
+
class OutOfOrderMigrationError extends MigronautError {
|
|
201
|
+
constructor(message, context, options) {
|
|
202
|
+
super('MIGRATION_OUT_OF_ORDER', message, context, options);
|
|
203
|
+
this.name = 'OutOfOrderMigrationError';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Thrown by an `ordered` single-file run that would apply or revert out of
|
|
209
|
+
* sequence: an earlier migration is still pending (`up`), or one applied later
|
|
210
|
+
* is still applied (`down`). `context.blockedBy` names what must go first.
|
|
211
|
+
*/
|
|
212
|
+
class MigrationBlockedError extends MigronautError {
|
|
213
|
+
constructor(message, context, options) {
|
|
214
|
+
super('MIGRATION_BLOCKED', message, context, options);
|
|
215
|
+
this.name = 'MigrationBlockedError';
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Thrown by the queue adapter when a job's payload fails the contract check.
|
|
221
|
+
* Job data comes back from Redis, so it is untrusted input — never a config
|
|
222
|
+
* mistake of the process that reads it.
|
|
223
|
+
*/
|
|
224
|
+
class QueueJobInvalidError extends MigronautError {
|
|
225
|
+
constructor(message, context, options) {
|
|
226
|
+
super('QUEUE_JOB_INVALID', message, context, options);
|
|
227
|
+
this.name = 'QueueJobInvalidError';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Thrown by a queue group's `wait()` when one of its jobs failed or the wait
|
|
233
|
+
* timed out. The worker's typed error does not cross the queue — only its
|
|
234
|
+
* message does — so `context.failedReason` carries it and `context.results`
|
|
235
|
+
* lists the jobs that finished before it.
|
|
236
|
+
*/
|
|
237
|
+
class QueueJobFailedError extends MigronautError {
|
|
238
|
+
constructor(message, context, options) {
|
|
239
|
+
super('QUEUE_JOB_FAILED', message, context, options);
|
|
240
|
+
this.name = 'QueueJobFailedError';
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Thrown by `converge` when the database cannot be brought to the declared
|
|
246
|
+
* state: the plan has a conflict (refused before any write — `context.phase`
|
|
247
|
+
* is `'plan'`), or a step failed (`'apply'`). `context.converge` is the
|
|
248
|
+
* converge result so far — which steps were applied, which failed, which were
|
|
249
|
+
* never reached — and `context.hint`, when present, says what usually fixes
|
|
250
|
+
* the server error behind it.
|
|
251
|
+
*/
|
|
252
|
+
class ConvergeFailedError extends MigronautError {
|
|
253
|
+
constructor(message, context, options) {
|
|
254
|
+
super('CONVERGE_FAILED', message, context, options);
|
|
255
|
+
this.name = 'ConvergeFailedError';
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
194
259
|
module.exports = {
|
|
195
260
|
MigronautError,
|
|
196
261
|
LockAlreadyHeldError,
|
|
@@ -212,4 +277,9 @@ module.exports = {
|
|
|
212
277
|
NotAppliedError,
|
|
213
278
|
ImportTargetNotEmptyError,
|
|
214
279
|
IrreversibleMigrationError,
|
|
280
|
+
OutOfOrderMigrationError,
|
|
281
|
+
MigrationBlockedError,
|
|
282
|
+
QueueJobInvalidError,
|
|
283
|
+
QueueJobFailedError,
|
|
284
|
+
ConvergeFailedError,
|
|
215
285
|
};
|
package/src/index.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
const { EXIT_CODES } = require('./cli/exit-codes.js');
|
|
2
2
|
const { MigratorKit } = require('./core/migrator.js');
|
|
3
3
|
const { pendingMigrations, runMigrations } = require('./core/run.js');
|
|
4
|
+
const { createLogger } = require('./utils/logger.js');
|
|
4
5
|
const {
|
|
5
6
|
ChecksumMismatchError,
|
|
6
7
|
ConfigFileExistsError,
|
|
7
8
|
ConfigInvalidError,
|
|
8
9
|
ConnectionFailedError,
|
|
10
|
+
ConvergeFailedError,
|
|
9
11
|
HookFailedError,
|
|
10
12
|
ImportTargetNotEmptyError,
|
|
11
13
|
IrreversibleMigrationError,
|
|
12
14
|
LockAlreadyHeldError,
|
|
13
15
|
LockLostError,
|
|
14
16
|
LockReleaseFailedError,
|
|
17
|
+
MigrationBlockedError,
|
|
15
18
|
MigrationExecutionFailedError,
|
|
16
19
|
MigrationFileExistsError,
|
|
17
20
|
MigrationFileNotFoundError,
|
|
@@ -21,6 +24,9 @@ const {
|
|
|
21
24
|
TransactionsUnsupportedError,
|
|
22
25
|
MigronautError,
|
|
23
26
|
NotAppliedError,
|
|
27
|
+
OutOfOrderMigrationError,
|
|
28
|
+
QueueJobFailedError,
|
|
29
|
+
QueueJobInvalidError,
|
|
24
30
|
RunAbortedError,
|
|
25
31
|
} = require('./errors/index.js');
|
|
26
32
|
|
|
@@ -32,6 +38,11 @@ module.exports = {
|
|
|
32
38
|
pendingMigrations,
|
|
33
39
|
runMigrations,
|
|
34
40
|
|
|
41
|
+
// The default console logger, for programmatic callers who want migronaut's
|
|
42
|
+
// own output at a chosen level (e.g. createLogger(process.stdout, 'debug'))
|
|
43
|
+
// without hand-writing a four-method logger
|
|
44
|
+
createLogger,
|
|
45
|
+
|
|
35
46
|
// The CLI's exit-code map, for wrappers that mirror its semantics
|
|
36
47
|
EXIT_CODES,
|
|
37
48
|
|
|
@@ -40,12 +51,14 @@ module.exports = {
|
|
|
40
51
|
ConfigFileExistsError,
|
|
41
52
|
ConfigInvalidError,
|
|
42
53
|
ConnectionFailedError,
|
|
54
|
+
ConvergeFailedError,
|
|
43
55
|
HookFailedError,
|
|
44
56
|
ImportTargetNotEmptyError,
|
|
45
57
|
IrreversibleMigrationError,
|
|
46
58
|
LockAlreadyHeldError,
|
|
47
59
|
LockLostError,
|
|
48
60
|
LockReleaseFailedError,
|
|
61
|
+
MigrationBlockedError,
|
|
49
62
|
MigrationExecutionFailedError,
|
|
50
63
|
MigrationFileExistsError,
|
|
51
64
|
MigrationFileNotFoundError,
|
|
@@ -55,5 +68,8 @@ module.exports = {
|
|
|
55
68
|
TransactionsUnsupportedError,
|
|
56
69
|
MigronautError,
|
|
57
70
|
NotAppliedError,
|
|
71
|
+
OutOfOrderMigrationError,
|
|
72
|
+
QueueJobFailedError,
|
|
73
|
+
QueueJobInvalidError,
|
|
58
74
|
RunAbortedError,
|
|
59
75
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who asked for a run, and why: `requestedBy` and `reason`, stamped on what
|
|
3
|
+
* the run writes to the changelog (and on a converge's history entry). Kept
|
|
4
|
+
* apart from `executedBy` — the OS user that ran it, which on a queue worker
|
|
5
|
+
* is the container's, not the person behind the request.
|
|
6
|
+
*
|
|
7
|
+
* The one definition of the two fields' limits, shared by the kit's options
|
|
8
|
+
* and the queue's job contract, so a producer can never send what its worker
|
|
9
|
+
* refuses.
|
|
10
|
+
*/
|
|
11
|
+
const ACTOR_LIMITS = Object.freeze({ requestedBy: 128, reason: 512 });
|
|
12
|
+
|
|
13
|
+
/** The problem with an actor field, or null when it is absent or valid */
|
|
14
|
+
function actorIssue(key, value) {
|
|
15
|
+
if (value === undefined) return null;
|
|
16
|
+
const max = ACTOR_LIMITS[key];
|
|
17
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > max) {
|
|
18
|
+
return `${key} must be a non-empty string of at most ${max} characters`;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Just the actor fields of `source` that are set */
|
|
24
|
+
function pickActor(source) {
|
|
25
|
+
const actor = {};
|
|
26
|
+
for (const key of Object.keys(ACTOR_LIMITS)) {
|
|
27
|
+
if (source?.[key] !== undefined) actor[key] = source[key];
|
|
28
|
+
}
|
|
29
|
+
return actor;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The actor fields of `options` as changelog fields: `requestedBy` / `reason`
|
|
34
|
+
* for what a run applies, `<prefix>RequestedBy` / `<prefix>Reason` for what
|
|
35
|
+
* it reverts (`prefix: 'revert'`), so a revert never overwrites who applied.
|
|
36
|
+
*/
|
|
37
|
+
function actorFields(options, prefix) {
|
|
38
|
+
const fields = {};
|
|
39
|
+
if (options?.requestedBy !== undefined) {
|
|
40
|
+
fields[prefix ? `${prefix}RequestedBy` : 'requestedBy'] = options.requestedBy;
|
|
41
|
+
}
|
|
42
|
+
if (options?.reason !== undefined) {
|
|
43
|
+
fields[prefix ? `${prefix}Reason` : 'reason'] = options.reason;
|
|
44
|
+
}
|
|
45
|
+
return fields;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { ACTOR_LIMITS, actorFields, actorIssue, pickActor };
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value comparison for declared state against what the server returns.
|
|
3
|
+
*
|
|
4
|
+
* The server stores a validator or a partial filter exactly as it was sent,
|
|
5
|
+
* but "as sent" and "as declared" are not the same JavaScript value: object
|
|
6
|
+
* keys may come back in another order, an `Int32` or a `Long` may stand where
|
|
7
|
+
* the declaration had a plain number, and an `undefined` property is either
|
|
8
|
+
* dropped or stored as `null` depending on the client's `ignoreUndefined`.
|
|
9
|
+
* `canonical` maps both sides onto one JSON-safe shape so that a plain string
|
|
10
|
+
* comparison decides equality.
|
|
11
|
+
*
|
|
12
|
+
* Arrays keep their order — `required: ['a', 'b']` and `['b', 'a']` are
|
|
13
|
+
* different documents to the server, and treating them as equal would hide a
|
|
14
|
+
* real change. A false "changed" costs one idempotent command; a false "same"
|
|
15
|
+
* would leave the database out of step with the declaration forever.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const isPlainObject = (value) => {
|
|
19
|
+
if (value === null || typeof value !== 'object') return false;
|
|
20
|
+
const proto = Object.getPrototypeOf(value);
|
|
21
|
+
return proto === Object.prototype || proto === null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A BSON value class from the driver (`Int32`, `Long`, `ObjectId`, …) */
|
|
25
|
+
const bsonType = (value) =>
|
|
26
|
+
value !== null && typeof value === 'object' && typeof value._bsontype === 'string'
|
|
27
|
+
? value._bsontype
|
|
28
|
+
: undefined;
|
|
29
|
+
|
|
30
|
+
function canonicalNumber(number) {
|
|
31
|
+
if (Number.isNaN(number)) return { $number: 'NaN' };
|
|
32
|
+
if (!Number.isFinite(number)) return { $number: number > 0 ? 'Infinity' : '-Infinity' };
|
|
33
|
+
// -0 and 0 are the same BSON value for every purpose a validator has.
|
|
34
|
+
return number === 0 ? 0 : number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function canonicalBson(type, value) {
|
|
38
|
+
if (type === 'Int32' || type === 'Double') return canonicalNumber(Number(value.valueOf()));
|
|
39
|
+
if (type === 'Long') {
|
|
40
|
+
const number = value.toNumber();
|
|
41
|
+
return Number.isSafeInteger(number) ? number : { $long: value.toString() };
|
|
42
|
+
}
|
|
43
|
+
if (type === 'ObjectId' || type === 'ObjectID') return { $oid: value.toHexString() };
|
|
44
|
+
if (type === 'Decimal128') return { $decimal: value.toString() };
|
|
45
|
+
if (type === 'BSONRegExp') return { $regex: value.pattern, $options: sortFlags(value.options) };
|
|
46
|
+
// Binary, Timestamp, MinKey, … — rare in a validator. Their JSON form is
|
|
47
|
+
// stable and type-tagged, which is all equality needs.
|
|
48
|
+
const json = typeof value.toJSON === 'function' ? value.toJSON() : String(value);
|
|
49
|
+
return { $bson: type, value: canonical(json) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sortFlags(flags) {
|
|
53
|
+
return [...String(flags)].sort().join('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A JavaScript RegExp's flags as the server stores them. The driver writes
|
|
58
|
+
* `i` and `m` as they are, `g` as the server's `s` (dotAll) — and drops every
|
|
59
|
+
* other flag. Comparing in this form is what lets a declared `/x/i` and the
|
|
60
|
+
* `/x/i` read back compare equal, and a `BSONRegExp('x', 's')` match too.
|
|
61
|
+
*/
|
|
62
|
+
const DRIVER_REGEXP_FLAGS = { i: 'i', m: 'm', g: 's' };
|
|
63
|
+
function storedFlags(flags) {
|
|
64
|
+
let out = '';
|
|
65
|
+
for (const flag of String(flags)) out += DRIVER_REGEXP_FLAGS[flag] ?? '';
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** JavaScript RegExp flags that do not survive the trip to the server as written */
|
|
70
|
+
const UNSTORABLE_FLAGS = /[^im]/g;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Why `value` cannot be stored as declared — a RegExp whose flags the driver
|
|
74
|
+
* changes (`g` becomes dotAll) or drops (`s`, `u`, `y`, `d`, `v`) — or null.
|
|
75
|
+
* A `BSONRegExp` states server options directly and is always fine.
|
|
76
|
+
*/
|
|
77
|
+
function regExpIssue(value, seen = new Set()) {
|
|
78
|
+
if (value instanceof RegExp) {
|
|
79
|
+
const bad = value.flags.match(UNSTORABLE_FLAGS);
|
|
80
|
+
return bad
|
|
81
|
+
? `regular expression /${value.source}/${value.flags}: flag(s) ${bad.join('')} cannot be ` +
|
|
82
|
+
'stored as written (the driver keeps only i and m, and turns g into dotAll) — use ' +
|
|
83
|
+
"BSONRegExp from 'bson' for server options"
|
|
84
|
+
: null;
|
|
85
|
+
}
|
|
86
|
+
if (value === null || typeof value !== 'object' || seen.has(value)) return null;
|
|
87
|
+
seen.add(value);
|
|
88
|
+
const items =
|
|
89
|
+
value instanceof Map
|
|
90
|
+
? [...value.values()]
|
|
91
|
+
: Array.isArray(value)
|
|
92
|
+
? value
|
|
93
|
+
: isPlainObject(value)
|
|
94
|
+
? Object.values(value)
|
|
95
|
+
: [];
|
|
96
|
+
for (const item of items) {
|
|
97
|
+
const issue = regExpIssue(item, seen);
|
|
98
|
+
if (issue) return issue;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Assign without invoking setters: a key named `__proto__` (JSON.parse makes
|
|
105
|
+
* one an own property) must stay a key, not replace the object's prototype —
|
|
106
|
+
* otherwise it vanishes from what is sent and what is compared.
|
|
107
|
+
*/
|
|
108
|
+
function assign(target, key, value) {
|
|
109
|
+
Object.defineProperty(target, key, {
|
|
110
|
+
value,
|
|
111
|
+
enumerable: true,
|
|
112
|
+
writable: true,
|
|
113
|
+
configurable: true,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A JSON-safe, key-sorted stand-in for `value` (see the module comment) */
|
|
118
|
+
function canonical(value) {
|
|
119
|
+
if (value === undefined || value === null) return null;
|
|
120
|
+
const type = typeof value;
|
|
121
|
+
if (type === 'string' || type === 'boolean') return value;
|
|
122
|
+
if (type === 'number') return canonicalNumber(value);
|
|
123
|
+
if (type === 'bigint') {
|
|
124
|
+
const number = Number(value);
|
|
125
|
+
return Number.isSafeInteger(number) ? number : { $long: value.toString() };
|
|
126
|
+
}
|
|
127
|
+
if (type !== 'object') return { $opaque: type };
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
const out = new Array(value.length);
|
|
130
|
+
for (let i = 0; i < value.length; i++) out[i] = canonical(value[i]);
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
if (value instanceof Date) {
|
|
134
|
+
const time = value.getTime();
|
|
135
|
+
return { $date: Number.isNaN(time) ? 'invalid' : value.toISOString() };
|
|
136
|
+
}
|
|
137
|
+
if (value instanceof RegExp) {
|
|
138
|
+
return { $regex: value.source, $options: sortFlags(storedFlags(value.flags)) };
|
|
139
|
+
}
|
|
140
|
+
const bson = bsonType(value);
|
|
141
|
+
if (bson !== undefined) return canonicalBson(bson, value);
|
|
142
|
+
const entries = value instanceof Map ? [...value.entries()] : Object.entries(value);
|
|
143
|
+
if (!(value instanceof Map) && !isPlainObject(value)) return { $opaque: 'object' };
|
|
144
|
+
const out = {};
|
|
145
|
+
const keys = [];
|
|
146
|
+
for (const [key, item] of entries) {
|
|
147
|
+
// Dropped, not nulled: that is what the declaration means, and what a
|
|
148
|
+
// client with `ignoreUndefined` stores. toWire() makes sure it is also
|
|
149
|
+
// what migronaut itself sends.
|
|
150
|
+
if (item !== undefined) keys.push([String(key), item]);
|
|
151
|
+
}
|
|
152
|
+
keys.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
153
|
+
for (const [key, item] of keys) assign(out, key, canonical(item));
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Deep equality under {@link canonical} */
|
|
158
|
+
function deepEqual(a, b) {
|
|
159
|
+
return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A copy of a declared value fit to send: plain objects lose their
|
|
164
|
+
* `undefined` properties, everything else (arrays, Dates, RegExps, BSON
|
|
165
|
+
* values) is kept as is. Without it a declared `undefined` would be stored as
|
|
166
|
+
* `null` by a client with the default `ignoreUndefined: false`, and the next
|
|
167
|
+
* comparison would report a change that can never converge.
|
|
168
|
+
*/
|
|
169
|
+
function toWire(value) {
|
|
170
|
+
if (Array.isArray(value)) return value.map((item) => toWire(item));
|
|
171
|
+
if (!isPlainObject(value)) return value;
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const [key, item] of Object.entries(value)) {
|
|
174
|
+
if (item !== undefined) assign(out, key, toWire(item));
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { canonical, deepEqual, isPlainObject, regExpIssue, toWire };
|