@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/LICENSE +22 -0
  3. package/README.md +773 -0
  4. package/bin/migronaut.js +36 -0
  5. package/index.d.ts +903 -0
  6. package/index.js +1 -0
  7. package/migronaut.schema.json +135 -0
  8. package/package.json +105 -0
  9. package/src/cli/args.js +314 -0
  10. package/src/cli/commands/audit.js +40 -0
  11. package/src/cli/commands/create.js +29 -0
  12. package/src/cli/commands/down.js +30 -0
  13. package/src/cli/commands/dry-run.js +36 -0
  14. package/src/cli/commands/import.js +33 -0
  15. package/src/cli/commands/init.js +64 -0
  16. package/src/cli/commands/list.js +19 -0
  17. package/src/cli/commands/lock.js +35 -0
  18. package/src/cli/commands/redo.js +16 -0
  19. package/src/cli/commands/status.js +52 -0
  20. package/src/cli/commands/unlock.js +43 -0
  21. package/src/cli/commands/up.js +53 -0
  22. package/src/cli/exit-codes.js +38 -0
  23. package/src/cli/index.js +91 -0
  24. package/src/cli/shared.js +343 -0
  25. package/src/cli/spinner.js +59 -0
  26. package/src/cli/table.js +259 -0
  27. package/src/core/audit.js +152 -0
  28. package/src/core/changelog.js +231 -0
  29. package/src/core/config.js +479 -0
  30. package/src/core/context.js +16 -0
  31. package/src/core/import-runner.js +164 -0
  32. package/src/core/import.js +60 -0
  33. package/src/core/lock.js +348 -0
  34. package/src/core/migrator.js +1475 -0
  35. package/src/core/run.js +144 -0
  36. package/src/core/runner.js +150 -0
  37. package/src/errors/index.js +215 -0
  38. package/src/index.js +59 -0
  39. package/src/utils/checksum.js +23 -0
  40. package/src/utils/colors.js +68 -0
  41. package/src/utils/concurrency.js +32 -0
  42. package/src/utils/date.js +28 -0
  43. package/src/utils/env.js +51 -0
  44. package/src/utils/error.js +11 -0
  45. package/src/utils/loader.js +131 -0
  46. package/src/utils/logger.js +109 -0
  47. package/src/utils/redact.js +39 -0
  48. package/src/utils/sanitize.js +43 -0
  49. package/src/utils/template.js +615 -0
  50. package/src/utils/user.js +25 -0
@@ -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 };