@alexify/migronaut 1.0.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 +128 -0
- package/LICENSE +22 -0
- package/README.md +773 -0
- package/bin/migronaut.js +36 -0
- package/index.d.ts +903 -0
- package/index.js +1 -0
- package/migronaut.schema.json +135 -0
- package/package.json +105 -0
- package/src/cli/args.js +314 -0
- package/src/cli/commands/audit.js +40 -0
- package/src/cli/commands/create.js +29 -0
- package/src/cli/commands/down.js +30 -0
- package/src/cli/commands/dry-run.js +36 -0
- package/src/cli/commands/import.js +33 -0
- package/src/cli/commands/init.js +64 -0
- package/src/cli/commands/list.js +19 -0
- package/src/cli/commands/lock.js +35 -0
- package/src/cli/commands/redo.js +16 -0
- package/src/cli/commands/status.js +52 -0
- package/src/cli/commands/unlock.js +43 -0
- package/src/cli/commands/up.js +53 -0
- package/src/cli/exit-codes.js +38 -0
- package/src/cli/index.js +91 -0
- package/src/cli/shared.js +343 -0
- package/src/cli/spinner.js +59 -0
- package/src/cli/table.js +259 -0
- package/src/core/audit.js +152 -0
- package/src/core/changelog.js +231 -0
- package/src/core/config.js +479 -0
- package/src/core/context.js +16 -0
- package/src/core/import-runner.js +164 -0
- package/src/core/import.js +60 -0
- package/src/core/lock.js +348 -0
- package/src/core/migrator.js +1475 -0
- package/src/core/run.js +144 -0
- package/src/core/runner.js +150 -0
- package/src/errors/index.js +215 -0
- package/src/index.js +59 -0
- package/src/utils/checksum.js +23 -0
- package/src/utils/colors.js +68 -0
- package/src/utils/concurrency.js +32 -0
- package/src/utils/date.js +28 -0
- package/src/utils/env.js +51 -0
- package/src/utils/error.js +11 -0
- package/src/utils/loader.js +131 -0
- package/src/utils/logger.js +109 -0
- package/src/utils/redact.js +39 -0
- package/src/utils/sanitize.js +43 -0
- package/src/utils/template.js +615 -0
- package/src/utils/user.js +25 -0
package/src/core/run.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const { setTimeout: delay } = require('node:timers/promises');
|
|
2
|
+
const { ConfigInvalidError, LockAlreadyHeldError } = require('../errors/index.js');
|
|
3
|
+
const { MigratorKit } = require('./migrator.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Longer than the default 60s lock TTL on purpose: with a shorter budget, a peer
|
|
7
|
+
* migration that outlives it makes every waiting instance fail to boot, even
|
|
8
|
+
* though the peer is healthy and still holding a valid lock.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_LOCK_WAIT_TIMEOUT_MS = 90_000;
|
|
11
|
+
const DEFAULT_LOCK_POLL_INTERVAL_MS = 500;
|
|
12
|
+
/** ±25% jitter so N instances booting together stop polling in lockstep */
|
|
13
|
+
const POLL_JITTER_RATIO = 0.25;
|
|
14
|
+
|
|
15
|
+
function jitteredDelay(baseMs) {
|
|
16
|
+
const spread = baseMs * POLL_JITTER_RATIO;
|
|
17
|
+
return Math.max(1, Math.round(baseMs - spread + Math.random() * spread * 2));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run all pending migrations and return a summary — the blessed one-call entry
|
|
22
|
+
* point for application startup, deploy hooks, serverless cold starts, and test
|
|
23
|
+
* setup.
|
|
24
|
+
*
|
|
25
|
+
* Unlike driving MigratorKit by hand, this opens its own connection,
|
|
26
|
+
* runs pending `up` migrations, and **always disconnects in a `finally`** so a
|
|
27
|
+
* failure never leaks a MongoDB connection. Migration errors propagate
|
|
28
|
+
* unchanged (as MigronautError subclasses with a typed `code`) so a broken
|
|
29
|
+
* migration aborts your boot sequence instead of starting the app against a
|
|
30
|
+
* half-migrated database.
|
|
31
|
+
*
|
|
32
|
+
* For multi-instance deploys, set `onLockHeld: 'wait'` so instances that lose
|
|
33
|
+
* the race to acquire the lock block until the migrating peer finishes, then
|
|
34
|
+
* confirm there is nothing left to apply.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```js
|
|
38
|
+
* const { runMigrations } = require('@alexify/migronaut');
|
|
39
|
+
*
|
|
40
|
+
* const { applied, upToDate } = await runMigrations(
|
|
41
|
+
* { uri: process.env.MIGRONAUT_URI, dbName: 'my_app' },
|
|
42
|
+
* { onLockHeld: 'wait' },
|
|
43
|
+
* );
|
|
44
|
+
* if (!upToDate) console.log(`Applied ${applied.length} migration(s)`);
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
async function runMigrations(config = {}, options = {}) {
|
|
48
|
+
const {
|
|
49
|
+
noLock,
|
|
50
|
+
onLockHeld = 'throw',
|
|
51
|
+
lockWaitTimeoutMs = DEFAULT_LOCK_WAIT_TIMEOUT_MS,
|
|
52
|
+
lockPollIntervalMs = DEFAULT_LOCK_POLL_INTERVAL_MS,
|
|
53
|
+
...kitOptions
|
|
54
|
+
} = options;
|
|
55
|
+
|
|
56
|
+
// Validated before anything connects. A NaN here (or any non-positive value)
|
|
57
|
+
// disables every deadline comparison below — `NaN > deadline` is always
|
|
58
|
+
// false — turning the wait loop into an unbounded retry storm against the
|
|
59
|
+
// lock collection that never returns and never surfaces the real error.
|
|
60
|
+
if (!Number.isFinite(lockWaitTimeoutMs) || lockWaitTimeoutMs <= 0) {
|
|
61
|
+
throw new ConfigInvalidError('lockWaitTimeoutMs must be a positive finite number', {
|
|
62
|
+
lockWaitTimeoutMs,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (!Number.isFinite(lockPollIntervalMs) || lockPollIntervalMs <= 0) {
|
|
66
|
+
throw new ConfigInvalidError('lockPollIntervalMs must be a positive finite number', {
|
|
67
|
+
lockPollIntervalMs,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const kit = new MigratorKit(config, kitOptions);
|
|
72
|
+
let waited = false;
|
|
73
|
+
let waitedMs = 0;
|
|
74
|
+
let attempts = 0;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await kit.connect();
|
|
78
|
+
// Resolved AFTER connect, from the kit's own merged config: a `logger:
|
|
79
|
+
// null` in the config file must silence this module's lines too, not only
|
|
80
|
+
// the kit's own.
|
|
81
|
+
const logger = kit.logger;
|
|
82
|
+
// The clock starts at the first contention, not before the first attempt —
|
|
83
|
+
// otherwise a slow initial attempt eats the whole waiting budget.
|
|
84
|
+
let deadline;
|
|
85
|
+
|
|
86
|
+
for (;;) {
|
|
87
|
+
try {
|
|
88
|
+
attempts += 1;
|
|
89
|
+
const applied = await kit.up(undefined, noLock ? { noLock: true } : {});
|
|
90
|
+
return { applied, upToDate: applied.length === 0, waited, waitedMs, attempts };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (onLockHeld !== 'wait' || !(error instanceof LockAlreadyHeldError)) {
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
deadline ??= Date.now() + lockWaitTimeoutMs;
|
|
96
|
+
const nextDelay = jitteredDelay(lockPollIntervalMs);
|
|
97
|
+
if (Date.now() + nextDelay > deadline) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
if (!waited) {
|
|
101
|
+
logger.info('Migration lock held by another process — waiting for it to release…');
|
|
102
|
+
}
|
|
103
|
+
waited = true;
|
|
104
|
+
logger.debug('Migration lock still held — retrying', {
|
|
105
|
+
attempts,
|
|
106
|
+
waitedMs,
|
|
107
|
+
nextDelayMs: nextDelay,
|
|
108
|
+
});
|
|
109
|
+
waitedMs += nextDelay;
|
|
110
|
+
await delay(nextDelay);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
await kit.disconnect().catch(() => undefined);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Return the migrations that have not yet been applied — a connection-managed
|
|
120
|
+
* readiness probe. Opens its own connection and always disconnects in a
|
|
121
|
+
* `finally`. Use it to fail a deploy/health check when the database is behind
|
|
122
|
+
* (`(await pendingMigrations(config)).length === 0`) without running anything.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```js
|
|
126
|
+
* const { pendingMigrations } = require('@alexify/migronaut');
|
|
127
|
+
*
|
|
128
|
+
* const pending = await pendingMigrations({ uri, dbName: 'my_app' });
|
|
129
|
+
* if (pending.length > 0) {
|
|
130
|
+
* throw new Error(`Database is behind by ${pending.length} migration(s)`);
|
|
131
|
+
* }
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
async function pendingMigrations(config = {}, options = {}) {
|
|
135
|
+
const kit = new MigratorKit(config, options);
|
|
136
|
+
try {
|
|
137
|
+
await kit.connect();
|
|
138
|
+
return await kit.list('pending');
|
|
139
|
+
} finally {
|
|
140
|
+
await kit.disconnect().catch(() => undefined);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { runMigrations, pendingMigrations };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
const {
|
|
2
|
+
MigrationExecutionFailedError,
|
|
3
|
+
MigrationTimeoutError,
|
|
4
|
+
TransactionsUnsupportedError,
|
|
5
|
+
} = require('../errors/index.js');
|
|
6
|
+
const { errorText } = require('../utils/error.js');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* True when the driver refused to start a transaction because the deployment
|
|
10
|
+
* cannot run one (standalone server; code 20 = IllegalOperation). Matched on
|
|
11
|
+
* the message too, since older servers phrase it without the code.
|
|
12
|
+
*/
|
|
13
|
+
function isTransactionsUnsupported(error) {
|
|
14
|
+
if (typeof error !== 'object' || error === null) return false;
|
|
15
|
+
return (
|
|
16
|
+
error.code === 20 ||
|
|
17
|
+
(typeof error.message === 'string' &&
|
|
18
|
+
error.message.includes('Transaction numbers are only allowed'))
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Race a migration against its timeout.
|
|
24
|
+
*
|
|
25
|
+
* Best-effort by design: JavaScript cannot cancel a running function, so the
|
|
26
|
+
* migration keeps executing in the background after this rejects. What the
|
|
27
|
+
* timeout buys is the *run* stopping instead of hanging forever — which also
|
|
28
|
+
* lets the lock's TTL expire, so a wedged migration no longer blocks every
|
|
29
|
+
* other instance indefinitely. Migrations that need real cancellation should
|
|
30
|
+
* watch `ctx.signal`.
|
|
31
|
+
*/
|
|
32
|
+
async function withTimeout(promise, timeoutMs, name, direction) {
|
|
33
|
+
if (!timeoutMs) return promise;
|
|
34
|
+
let timer;
|
|
35
|
+
try {
|
|
36
|
+
return await Promise.race([
|
|
37
|
+
promise,
|
|
38
|
+
new Promise((_resolve, reject) => {
|
|
39
|
+
timer = setTimeout(() => {
|
|
40
|
+
// The body keeps running after this rejects (see docblock), and its
|
|
41
|
+
// eventual rejection — e.g. MongoExpiredSessionError once the caller
|
|
42
|
+
// ends the session — would otherwise surface as an
|
|
43
|
+
// unhandledRejection long after the run already reported the
|
|
44
|
+
// timeout. Swallow it: the timeout is the reported failure.
|
|
45
|
+
Promise.resolve(promise).catch(() => {});
|
|
46
|
+
reject(
|
|
47
|
+
new MigrationTimeoutError(
|
|
48
|
+
`Migration ${direction} timed out after ${timeoutMs}ms: ${name}`,
|
|
49
|
+
{
|
|
50
|
+
name,
|
|
51
|
+
direction,
|
|
52
|
+
timeoutMs,
|
|
53
|
+
},
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}, timeoutMs);
|
|
57
|
+
timer.unref?.();
|
|
58
|
+
}),
|
|
59
|
+
]);
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Execute a single migration's `up` or `down` safely.
|
|
67
|
+
*
|
|
68
|
+
* When `useTransaction` is true the call runs inside `session.withTransaction()`,
|
|
69
|
+
* which commits on success, aborts on failure, and — unlike a hand-rolled
|
|
70
|
+
* start/commit pair — retries `TransientTransactionError` and
|
|
71
|
+
* `UnknownTransactionCommitResult` per the driver's documented commit protocol.
|
|
72
|
+
*
|
|
73
|
+
* `onSuccess(duration, session)` runs inside the same transaction, right after
|
|
74
|
+
* the migration body. That is what makes the changelog write atomic with the
|
|
75
|
+
* migration itself: a crash between the two can no longer leave a migration
|
|
76
|
+
* applied but unrecorded (which would silently re-run it on the next `up`).
|
|
77
|
+
*
|
|
78
|
+
* On any error the `onError` hook is invoked before a
|
|
79
|
+
* MigrationExecutionFailedError is thrown — the error is never swallowed, and a
|
|
80
|
+
* throwing hook cannot mask the original failure.
|
|
81
|
+
*/
|
|
82
|
+
async function runMigration(params) {
|
|
83
|
+
const { name, migration, direction, context, useTransaction, hooks, onSuccess, logger } = params;
|
|
84
|
+
const fn = direction === 'up' ? migration.up : migration.down;
|
|
85
|
+
// A per-file `export const timeoutMs` overrides the global setting.
|
|
86
|
+
const timeoutMs = migration.timeoutMs ?? params.timeoutMs;
|
|
87
|
+
|
|
88
|
+
const start = Date.now();
|
|
89
|
+
let session;
|
|
90
|
+
let runtimeContext = context;
|
|
91
|
+
let duration = 0;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
if (useTransaction) {
|
|
95
|
+
session = context.client.startSession();
|
|
96
|
+
runtimeContext = { ...context, session };
|
|
97
|
+
// withTransaction may run the body more than once when the driver retries
|
|
98
|
+
// a transient failure, so duration is re-measured on each attempt.
|
|
99
|
+
await session.withTransaction(async () => {
|
|
100
|
+
const attemptStart = Date.now();
|
|
101
|
+
await withTimeout(fn(runtimeContext), timeoutMs, name, direction);
|
|
102
|
+
duration = Date.now() - attemptStart;
|
|
103
|
+
await onSuccess?.(duration, session);
|
|
104
|
+
});
|
|
105
|
+
} else {
|
|
106
|
+
await withTimeout(fn(runtimeContext), timeoutMs, name, direction);
|
|
107
|
+
duration = Date.now() - start;
|
|
108
|
+
await onSuccess?.(duration, undefined);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { duration };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
114
|
+
|
|
115
|
+
if (hooks?.onError) {
|
|
116
|
+
// A throwing onError hook must not replace the real cause.
|
|
117
|
+
try {
|
|
118
|
+
await hooks.onError(name, err, runtimeContext);
|
|
119
|
+
} catch (hookError) {
|
|
120
|
+
const message = errorText(hookError);
|
|
121
|
+
logger?.warn(`⚠ onError hook failed for ${name}: ${message}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (err instanceof MigrationTimeoutError) throw err;
|
|
126
|
+
// A standalone deployment refusing the transaction is a topology problem,
|
|
127
|
+
// not a bug in the migration — say so instead of blaming the file.
|
|
128
|
+
if (useTransaction && isTransactionsUnsupported(err)) {
|
|
129
|
+
throw new TransactionsUnsupportedError(
|
|
130
|
+
`Cannot run ${name} in a transaction — this deployment is standalone. ` +
|
|
131
|
+
'Set useTransaction: false, or run against a replica set / mongos.',
|
|
132
|
+
{ name, direction, cause: err.message },
|
|
133
|
+
{ cause: err },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
throw new MigrationExecutionFailedError(
|
|
137
|
+
`Migration ${direction} failed: ${name}`,
|
|
138
|
+
// The message is duplicated into context because that is what survives
|
|
139
|
+
// JSON serialization; `cause` keeps the real Error (and its stack).
|
|
140
|
+
{ name, direction, cause: err.message },
|
|
141
|
+
{ cause: err },
|
|
142
|
+
);
|
|
143
|
+
} finally {
|
|
144
|
+
if (session) {
|
|
145
|
+
await session.endSession();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { runMigration };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base error for all migronaut failures. Carries a typed `code`, an optional
|
|
3
|
+
* `context`, and — when the failure wraps another error — that error as
|
|
4
|
+
* `cause`.
|
|
5
|
+
*
|
|
6
|
+
* The cause is a real Error, not a string, so `err.cause.stack` still points
|
|
7
|
+
* into the user's own migration. Wrap sites additionally keep the *message* in
|
|
8
|
+
* `context.cause`, because that is what survives JSON serialization for
|
|
9
|
+
* `--json` consumers.
|
|
10
|
+
*/
|
|
11
|
+
class MigronautError extends Error {
|
|
12
|
+
constructor(code, message, context, options) {
|
|
13
|
+
super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
|
|
14
|
+
this.name = 'MigronautError';
|
|
15
|
+
this.code = code;
|
|
16
|
+
if (context !== undefined) {
|
|
17
|
+
this.context = context;
|
|
18
|
+
}
|
|
19
|
+
Error.captureStackTrace(this, this.constructor);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Thrown when a lock is already held by another process within its TTL */
|
|
24
|
+
class LockAlreadyHeldError extends MigronautError {
|
|
25
|
+
constructor(message, context, options) {
|
|
26
|
+
super('LOCK_ALREADY_HELD', message, context, options);
|
|
27
|
+
this.name = 'LockAlreadyHeldError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Thrown when releasing a lock fails */
|
|
32
|
+
class LockReleaseFailedError extends MigronautError {
|
|
33
|
+
constructor(message, context, options) {
|
|
34
|
+
super('LOCK_RELEASE_FAILED', message, context, options);
|
|
35
|
+
this.name = 'LockReleaseFailedError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Thrown when the lock is lost while migrations are still running — another
|
|
41
|
+
* process reclaimed it, or the heartbeat could not reach the database. The run
|
|
42
|
+
* stops rather than risk two processes migrating the same database at once.
|
|
43
|
+
*/
|
|
44
|
+
class LockLostError extends MigronautError {
|
|
45
|
+
constructor(message, context, options) {
|
|
46
|
+
super('LOCK_LOST', message, context, options);
|
|
47
|
+
this.name = 'LockLostError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Thrown when a run is stopped before finishing — via `MigratorKit.stop()` or a
|
|
53
|
+
* SIGINT/SIGTERM. Migrations already applied are listed in `context.results`.
|
|
54
|
+
*/
|
|
55
|
+
class RunAbortedError extends MigronautError {
|
|
56
|
+
constructor(message, context, options) {
|
|
57
|
+
super('RUN_ABORTED', message, context, options);
|
|
58
|
+
this.name = 'RunAbortedError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Thrown when a user-supplied lifecycle hook throws */
|
|
63
|
+
class HookFailedError extends MigronautError {
|
|
64
|
+
constructor(message, context, options) {
|
|
65
|
+
super('HOOK_FAILED', message, context, options);
|
|
66
|
+
this.name = 'HookFailedError';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Thrown when `migronaut create` would overwrite an existing migration file */
|
|
71
|
+
class MigrationFileExistsError extends MigronautError {
|
|
72
|
+
constructor(message, context, options) {
|
|
73
|
+
super('MIGRATION_FILE_EXISTS', message, context, options);
|
|
74
|
+
this.name = 'MigrationFileExistsError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Thrown when a file's checksum differs from the one recorded at apply time */
|
|
79
|
+
class ChecksumMismatchError extends MigronautError {
|
|
80
|
+
constructor(message, context, options) {
|
|
81
|
+
super('CHECKSUM_MISMATCH', message, context, options);
|
|
82
|
+
this.name = 'ChecksumMismatchError';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Thrown when a referenced migration file does not exist on disk */
|
|
87
|
+
class MigrationFileNotFoundError extends MigronautError {
|
|
88
|
+
constructor(message, context, options) {
|
|
89
|
+
super('MIGRATION_FILE_NOT_FOUND', message, context, options);
|
|
90
|
+
this.name = 'MigrationFileNotFoundError';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Thrown when a migration name is not a plain filename — e.g. it contains a
|
|
96
|
+
* path separator or `..`, which would let a target escape the migrations
|
|
97
|
+
* directory (path traversal) when joined into a filesystem path.
|
|
98
|
+
*/
|
|
99
|
+
class MigrationInvalidNameError extends MigronautError {
|
|
100
|
+
constructor(message, context, options) {
|
|
101
|
+
super('MIGRATION_INVALID_NAME', message, context, options);
|
|
102
|
+
this.name = 'MigrationInvalidNameError';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Thrown when a migration file does not export valid up()/down() functions */
|
|
107
|
+
class MigrationInvalidExportError extends MigronautError {
|
|
108
|
+
constructor(message, context, options) {
|
|
109
|
+
super('MIGRATION_INVALID_EXPORT', message, context, options);
|
|
110
|
+
this.name = 'MigrationInvalidExportError';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Thrown when a migration's up() or down() throws during execution */
|
|
115
|
+
class MigrationExecutionFailedError extends MigronautError {
|
|
116
|
+
constructor(message, context, options) {
|
|
117
|
+
super('MIGRATION_EXECUTION_FAILED', message, context, options);
|
|
118
|
+
this.name = 'MigrationExecutionFailedError';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Thrown when a migration exceeds its `timeoutMs`. Best-effort: the migration's
|
|
124
|
+
* own work cannot be cancelled, but the run stops instead of hanging, letting
|
|
125
|
+
* the lock's TTL expire so other instances are not blocked forever.
|
|
126
|
+
*/
|
|
127
|
+
class MigrationTimeoutError extends MigronautError {
|
|
128
|
+
constructor(message, context, options) {
|
|
129
|
+
super('MIGRATION_TIMEOUT', message, context, options);
|
|
130
|
+
this.name = 'MigrationTimeoutError';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Thrown when `useTransaction` is on but the deployment cannot run
|
|
136
|
+
* transactions (a standalone server — they need a replica set or mongos).
|
|
137
|
+
* A dedicated code, because the driver's own error blames the migration.
|
|
138
|
+
*/
|
|
139
|
+
class TransactionsUnsupportedError extends MigronautError {
|
|
140
|
+
constructor(message, context, options) {
|
|
141
|
+
super('TRANSACTIONS_UNSUPPORTED', message, context, options);
|
|
142
|
+
this.name = 'TransactionsUnsupportedError';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Thrown when the merged configuration fails validation */
|
|
147
|
+
class ConfigInvalidError extends MigronautError {
|
|
148
|
+
constructor(message, context, options) {
|
|
149
|
+
super('CONFIG_INVALID', message, context, options);
|
|
150
|
+
this.name = 'ConfigInvalidError';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Thrown when creating a config file that already exists without `--force` */
|
|
155
|
+
class ConfigFileExistsError extends MigronautError {
|
|
156
|
+
constructor(message, context, options) {
|
|
157
|
+
super('CONFIG_FILE_EXISTS', message, context, options);
|
|
158
|
+
this.name = 'ConfigFileExistsError';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Thrown when connecting to MongoDB fails */
|
|
163
|
+
class ConnectionFailedError extends MigronautError {
|
|
164
|
+
constructor(message, context, options) {
|
|
165
|
+
super('CONNECTION_FAILED', message, context, options);
|
|
166
|
+
this.name = 'ConnectionFailedError';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Thrown when attempting to revert a migration that was never applied */
|
|
171
|
+
class NotAppliedError extends MigronautError {
|
|
172
|
+
constructor(message, context, options) {
|
|
173
|
+
super('NOT_APPLIED', message, context, options);
|
|
174
|
+
this.name = 'NotAppliedError';
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Thrown when `migronaut import` targets a non-empty changelog without `--force` */
|
|
179
|
+
class ImportTargetNotEmptyError extends MigronautError {
|
|
180
|
+
constructor(message, context, options) {
|
|
181
|
+
super('IMPORT_TARGET_NOT_EMPTY', message, context, options);
|
|
182
|
+
this.name = 'ImportTargetNotEmptyError';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Thrown when attempting to roll back a migrate-mongo-imported (forward-only) migration */
|
|
187
|
+
class IrreversibleMigrationError extends MigronautError {
|
|
188
|
+
constructor(message, context, options) {
|
|
189
|
+
super('MIGRATION_IRREVERSIBLE', message, context, options);
|
|
190
|
+
this.name = 'IrreversibleMigrationError';
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
MigronautError,
|
|
196
|
+
LockAlreadyHeldError,
|
|
197
|
+
LockReleaseFailedError,
|
|
198
|
+
LockLostError,
|
|
199
|
+
RunAbortedError,
|
|
200
|
+
HookFailedError,
|
|
201
|
+
ChecksumMismatchError,
|
|
202
|
+
MigrationFileNotFoundError,
|
|
203
|
+
MigrationFileExistsError,
|
|
204
|
+
MigrationInvalidNameError,
|
|
205
|
+
MigrationInvalidExportError,
|
|
206
|
+
MigrationExecutionFailedError,
|
|
207
|
+
MigrationTimeoutError,
|
|
208
|
+
TransactionsUnsupportedError,
|
|
209
|
+
ConfigInvalidError,
|
|
210
|
+
ConfigFileExistsError,
|
|
211
|
+
ConnectionFailedError,
|
|
212
|
+
NotAppliedError,
|
|
213
|
+
ImportTargetNotEmptyError,
|
|
214
|
+
IrreversibleMigrationError,
|
|
215
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { EXIT_CODES } = require('./cli/exit-codes.js');
|
|
2
|
+
const { MigratorKit } = require('./core/migrator.js');
|
|
3
|
+
const { pendingMigrations, runMigrations } = require('./core/run.js');
|
|
4
|
+
const {
|
|
5
|
+
ChecksumMismatchError,
|
|
6
|
+
ConfigFileExistsError,
|
|
7
|
+
ConfigInvalidError,
|
|
8
|
+
ConnectionFailedError,
|
|
9
|
+
HookFailedError,
|
|
10
|
+
ImportTargetNotEmptyError,
|
|
11
|
+
IrreversibleMigrationError,
|
|
12
|
+
LockAlreadyHeldError,
|
|
13
|
+
LockLostError,
|
|
14
|
+
LockReleaseFailedError,
|
|
15
|
+
MigrationExecutionFailedError,
|
|
16
|
+
MigrationFileExistsError,
|
|
17
|
+
MigrationFileNotFoundError,
|
|
18
|
+
MigrationInvalidExportError,
|
|
19
|
+
MigrationInvalidNameError,
|
|
20
|
+
MigrationTimeoutError,
|
|
21
|
+
TransactionsUnsupportedError,
|
|
22
|
+
MigronautError,
|
|
23
|
+
NotAppliedError,
|
|
24
|
+
RunAbortedError,
|
|
25
|
+
} = require('./errors/index.js');
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
// Main class
|
|
29
|
+
MigratorKit,
|
|
30
|
+
|
|
31
|
+
// Programmatic entry points (app startup / serverless / test setup)
|
|
32
|
+
pendingMigrations,
|
|
33
|
+
runMigrations,
|
|
34
|
+
|
|
35
|
+
// The CLI's exit-code map, for wrappers that mirror its semantics
|
|
36
|
+
EXIT_CODES,
|
|
37
|
+
|
|
38
|
+
// Error classes
|
|
39
|
+
ChecksumMismatchError,
|
|
40
|
+
ConfigFileExistsError,
|
|
41
|
+
ConfigInvalidError,
|
|
42
|
+
ConnectionFailedError,
|
|
43
|
+
HookFailedError,
|
|
44
|
+
ImportTargetNotEmptyError,
|
|
45
|
+
IrreversibleMigrationError,
|
|
46
|
+
LockAlreadyHeldError,
|
|
47
|
+
LockLostError,
|
|
48
|
+
LockReleaseFailedError,
|
|
49
|
+
MigrationExecutionFailedError,
|
|
50
|
+
MigrationFileExistsError,
|
|
51
|
+
MigrationFileNotFoundError,
|
|
52
|
+
MigrationInvalidExportError,
|
|
53
|
+
MigrationInvalidNameError,
|
|
54
|
+
MigrationTimeoutError,
|
|
55
|
+
TransactionsUnsupportedError,
|
|
56
|
+
MigronautError,
|
|
57
|
+
NotAppliedError,
|
|
58
|
+
RunAbortedError,
|
|
59
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const crypto = require('node:crypto');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
|
|
4
|
+
const UTF8_BOM = 0xfeff;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SHA-256 hex digest of a migration file, normalized so the same logical file
|
|
8
|
+
* hashes the same everywhere.
|
|
9
|
+
*
|
|
10
|
+
* Line endings are normalized to LF and a leading UTF-8 BOM is stripped:
|
|
11
|
+
* without that, a Windows checkout (or a `.gitattributes` `text=auto`) produces
|
|
12
|
+
* a different digest for byte-identical content and every applied migration
|
|
13
|
+
* reports a spurious checksum mismatch.
|
|
14
|
+
*/
|
|
15
|
+
async function computeChecksum(filepath) {
|
|
16
|
+
const buffer = await fs.readFile(filepath);
|
|
17
|
+
let contents = buffer.toString('utf8');
|
|
18
|
+
if (contents.charCodeAt(0) === UTF8_BOM) contents = contents.slice(1);
|
|
19
|
+
contents = contents.replace(/\r\n/g, '\n');
|
|
20
|
+
return crypto.createHash('sha256').update(contents, 'utf8').digest('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { computeChecksum };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide whether ANSI colors should be emitted to `stream`.
|
|
3
|
+
*
|
|
4
|
+
* Precedence, most specific signal first:
|
|
5
|
+
* MIGRONAUT_FORCE_COLOR > MIGRONAUT_NO_COLOR > FORCE_COLOR > NO_COLOR >
|
|
6
|
+
* TERM=dumb > the stream being an interactive TTY.
|
|
7
|
+
*
|
|
8
|
+
* The MIGRONAUT_* pair exists so a project can pin migronaut's own output
|
|
9
|
+
* without disturbing every other tool in the same shell; the unprefixed pair
|
|
10
|
+
* stays honored underneath, because NO_COLOR/FORCE_COLOR are ecosystem-wide
|
|
11
|
+
* conventions (no-color.org) that a CLI is expected to obey. Same value
|
|
12
|
+
* semantics in both tiers: a *FORCE_COLOR that is set and non-empty decides
|
|
13
|
+
* ('0' off, anything else on); a *NO_COLOR that is set and non-empty forces off.
|
|
14
|
+
*
|
|
15
|
+
* There is deliberately no MIGRONAUT_TERM — TERM describes what the terminal
|
|
16
|
+
* can render, not what migronaut should do, and MIGRONAUT_NO_COLOR already
|
|
17
|
+
* covers the override case.
|
|
18
|
+
*/
|
|
19
|
+
function supportsColor(stream, env = process.env) {
|
|
20
|
+
const ownForce = env.MIGRONAUT_FORCE_COLOR;
|
|
21
|
+
if (ownForce !== undefined && ownForce !== '') return ownForce !== '0';
|
|
22
|
+
if (env.MIGRONAUT_NO_COLOR !== undefined && env.MIGRONAUT_NO_COLOR !== '') return false;
|
|
23
|
+
const force = env.FORCE_COLOR;
|
|
24
|
+
if (force !== undefined && force !== '') return force !== '0';
|
|
25
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return false;
|
|
26
|
+
if (env.TERM === 'dumb') return false;
|
|
27
|
+
return Boolean(stream && stream.isTTY);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const paint = (open, close) => (text) => `\x1b[${open}m${text}\x1b[${close}m`;
|
|
31
|
+
const identity = (text) => text;
|
|
32
|
+
|
|
33
|
+
const ESC = String.fromCharCode(27);
|
|
34
|
+
const ANSI_PATTERN = new RegExp(`${ESC}\\[[0-9;]*m`, 'g');
|
|
35
|
+
|
|
36
|
+
/** Remove ANSI SGR color codes from a string */
|
|
37
|
+
function stripAnsi(text) {
|
|
38
|
+
return text.replace(ANSI_PATTERN, '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create the color palette for writing to `stream`: green/yellow/red/cyan/dim
|
|
43
|
+
* wrapper functions plus an `enabled` flag. When colors are unsupported every
|
|
44
|
+
* wrapper is the identity function, so call sites never need to branch.
|
|
45
|
+
*/
|
|
46
|
+
function createColors(stream, env = process.env) {
|
|
47
|
+
const enabled = supportsColor(stream, env);
|
|
48
|
+
if (!enabled) {
|
|
49
|
+
return {
|
|
50
|
+
enabled,
|
|
51
|
+
green: identity,
|
|
52
|
+
yellow: identity,
|
|
53
|
+
red: identity,
|
|
54
|
+
cyan: identity,
|
|
55
|
+
dim: identity,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
enabled,
|
|
60
|
+
green: paint(32, 39),
|
|
61
|
+
yellow: paint(33, 39),
|
|
62
|
+
red: paint(31, 39),
|
|
63
|
+
cyan: paint(36, 39),
|
|
64
|
+
dim: paint(2, 22),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { supportsColor, createColors, stripAnsi };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map `items` through `fn` with at most `limit` calls in flight, preserving
|
|
3
|
+
* input order in the result.
|
|
4
|
+
*
|
|
5
|
+
* `Promise.all` over a whole list starts everything at once. For per-file
|
|
6
|
+
* reads that means thousands of simultaneous open descriptors (EMFILE) once a
|
|
7
|
+
* project has thousands of migrations; for writes it means an unbounded fan-out
|
|
8
|
+
* at one connection pool. Failures still reject on the first error, matching
|
|
9
|
+
* `Promise.all` — the point here is pacing, not error handling.
|
|
10
|
+
*/
|
|
11
|
+
async function mapLimit(items, limit, fn) {
|
|
12
|
+
const results = new Array(items.length);
|
|
13
|
+
if (items.length === 0) return results;
|
|
14
|
+
|
|
15
|
+
const width = Math.max(1, Math.min(limit, items.length));
|
|
16
|
+
let next = 0;
|
|
17
|
+
|
|
18
|
+
const worker = async () => {
|
|
19
|
+
for (;;) {
|
|
20
|
+
const index = next++;
|
|
21
|
+
if (index >= items.length) return;
|
|
22
|
+
results[index] = await fn(items[index], index);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const workers = [];
|
|
27
|
+
for (let i = 0; i < width; i++) workers.push(worker());
|
|
28
|
+
await Promise.all(workers);
|
|
29
|
+
return results;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { mapLimit };
|