@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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const { errorText } = require('../utils/error.js');
|
|
2
|
+
const { toLockInfo } = require('./lock.js');
|
|
3
|
+
|
|
4
|
+
/** Changelog indexes ensureIndexes() creates — audit warns when any is absent */
|
|
5
|
+
const EXPECTED_INDEXES = [
|
|
6
|
+
'name_unique',
|
|
7
|
+
'status_batch',
|
|
8
|
+
'batch',
|
|
9
|
+
'status_name',
|
|
10
|
+
'status_appliedAt_name',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read-only health check of the setup: configuration, connectivity,
|
|
15
|
+
* transaction support, indexes, lock state and checksum drift.
|
|
16
|
+
*
|
|
17
|
+
* Fixes nothing — it reports, so an operator can see in one command why a
|
|
18
|
+
* migration would fail before running one. Every check is independent: a
|
|
19
|
+
* failure in one is recorded and the rest still run.
|
|
20
|
+
*
|
|
21
|
+
* Pure orchestration over capabilities the MigratorKit injects (`deps`), so it
|
|
22
|
+
* needs none of the kit's private state.
|
|
23
|
+
*/
|
|
24
|
+
async function runAudit(deps) {
|
|
25
|
+
const checks = [];
|
|
26
|
+
const record = (name, status, detail) => checks.push({ name, status, detail });
|
|
27
|
+
|
|
28
|
+
// 1. Configuration. Everything downstream depends on it, so a failure here
|
|
29
|
+
// is fatal to the audit itself.
|
|
30
|
+
let config;
|
|
31
|
+
try {
|
|
32
|
+
config = await deps.ensureConfig();
|
|
33
|
+
record('config', 'pass', `Loaded (database "${config.dbName}")`);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
record('config', 'fail', errorText(error));
|
|
36
|
+
return auditReport(checks);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 2. Connectivity.
|
|
40
|
+
try {
|
|
41
|
+
await deps.connect();
|
|
42
|
+
record('connection', 'pass', config.client ? 'Using the injected client' : 'Connected');
|
|
43
|
+
} catch (error) {
|
|
44
|
+
record('connection', 'fail', errorText(error));
|
|
45
|
+
return auditReport(checks);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const db = deps.getDb();
|
|
49
|
+
|
|
50
|
+
// 3. Transactions need a replica set or a sharded cluster; on a standalone
|
|
51
|
+
// server `useTransaction: true` fails at run time with a driver error
|
|
52
|
+
// that says nothing about the configuration.
|
|
53
|
+
try {
|
|
54
|
+
const info = await db.admin().command({ hello: 1 });
|
|
55
|
+
const supportsTransactions = Boolean(info.setName) || info.msg === 'isdbgrid';
|
|
56
|
+
if (supportsTransactions) {
|
|
57
|
+
record('transactions', 'pass', info.setName ? `Replica set "${info.setName}"` : 'Sharded');
|
|
58
|
+
} else if (config.useTransaction) {
|
|
59
|
+
record('transactions', 'fail', 'useTransaction is on, but this is a standalone server');
|
|
60
|
+
} else {
|
|
61
|
+
record('transactions', 'warn', 'Standalone server — useTransaction is unavailable');
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
record('transactions', 'warn', `Could not determine topology: ${errorText(error)}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 4. Indexes. Missing ones do not break correctness, only performance.
|
|
68
|
+
try {
|
|
69
|
+
const present = new Set();
|
|
70
|
+
for (const index of await db.collection(config.migrationsCollection).indexes()) {
|
|
71
|
+
present.add(index.name);
|
|
72
|
+
}
|
|
73
|
+
const missing = [];
|
|
74
|
+
for (const name of EXPECTED_INDEXES) {
|
|
75
|
+
if (!present.has(name)) missing.push(name);
|
|
76
|
+
}
|
|
77
|
+
if (missing.length === 0) record('indexes', 'pass', 'All changelog indexes present');
|
|
78
|
+
else record('indexes', 'warn', `Missing: ${missing.join(', ')}`);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
record('indexes', 'warn', `Could not read indexes: ${errorText(error)}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 5. Lock. A held lock is not itself a problem — a stale one is.
|
|
84
|
+
try {
|
|
85
|
+
const holder = toLockInfo(await deps.inspectLock());
|
|
86
|
+
if (!holder) {
|
|
87
|
+
record('lock', 'pass', 'No lock held');
|
|
88
|
+
} else {
|
|
89
|
+
const ageSeconds = Math.round((Date.now() - holder.lockedAt.getTime()) / 1000);
|
|
90
|
+
const stale = ageSeconds > config.lockTTLSeconds;
|
|
91
|
+
record(
|
|
92
|
+
'lock',
|
|
93
|
+
stale ? 'warn' : 'pass',
|
|
94
|
+
`Held by pid ${holder.pid} on ${holder.host} for ${ageSeconds}s` +
|
|
95
|
+
(stale ? ' — past its TTL, likely from a crashed run (migronaut unlock)' : ''),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
record('lock', 'warn', `Could not read the lock: ${errorText(error)}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 6. Checksum drift and missing files, from the same rows `status` renders.
|
|
103
|
+
try {
|
|
104
|
+
const rows = await deps.status();
|
|
105
|
+
// One pass over the rows collects both signals.
|
|
106
|
+
const drifted = [];
|
|
107
|
+
let pending = 0;
|
|
108
|
+
for (const row of rows) {
|
|
109
|
+
if (row.checksumOk === false) drifted.push(row.file);
|
|
110
|
+
if (row.status === 'pending') pending += 1;
|
|
111
|
+
}
|
|
112
|
+
if (drifted.length > 0) {
|
|
113
|
+
record('checksums', 'fail', `Edited after being applied: ${drifted.join(', ')}`);
|
|
114
|
+
} else {
|
|
115
|
+
record('checksums', 'pass', 'No drift among applied migrations');
|
|
116
|
+
}
|
|
117
|
+
record('pending', pending === 0 ? 'pass' : 'warn', `${pending} pending migration(s)`);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
record('checksums', 'warn', `Could not read status: ${errorText(error)}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 7. Runtime. TypeScript migrations need a runtime that can strip types.
|
|
123
|
+
// Feature detection instead of version parsing: it also catches a run
|
|
124
|
+
// under `--no-experimental-strip-types` on an otherwise capable Node.
|
|
125
|
+
const nodeVersion = process.versions.node;
|
|
126
|
+
const wantsTs = (config.fileExtensions ?? []).includes('.ts');
|
|
127
|
+
const stripsTypes = Boolean(process.features.typescript);
|
|
128
|
+
if (wantsTs && !stripsTypes) {
|
|
129
|
+
record(
|
|
130
|
+
'runtime',
|
|
131
|
+
'warn',
|
|
132
|
+
`Node ${nodeVersion} cannot import .ts here — type stripping is unavailable or disabled; re-enable it or use a loader such as tsx`,
|
|
133
|
+
);
|
|
134
|
+
} else {
|
|
135
|
+
record('runtime', 'pass', `Node ${nodeVersion}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return auditReport(checks);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Roll individual checks up into the report shape (one pass, both counters) */
|
|
142
|
+
function auditReport(checks) {
|
|
143
|
+
let failed = 0;
|
|
144
|
+
let warnings = 0;
|
|
145
|
+
for (const check of checks) {
|
|
146
|
+
if (check.status === 'fail') failed += 1;
|
|
147
|
+
else if (check.status === 'warn') warnings += 1;
|
|
148
|
+
}
|
|
149
|
+
return { ok: failed === 0, failed, warnings, checks };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { runAudit };
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads and writes migration records in the changelog collection
|
|
3
|
+
* (`_migronaut_migrations` by default).
|
|
4
|
+
*
|
|
5
|
+
* Records are an append-mostly audit trail: reverting a migration updates its
|
|
6
|
+
* status to `'reverted'` and stamps `revertedAt` — it never deletes the record.
|
|
7
|
+
*/
|
|
8
|
+
class Changelog {
|
|
9
|
+
#collectionName;
|
|
10
|
+
|
|
11
|
+
constructor(collectionName) {
|
|
12
|
+
this.#collectionName = collectionName;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
#coll(db) {
|
|
16
|
+
return db.collection(this.#collectionName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create the indexes the read paths actually use. Safe to call repeatedly.
|
|
21
|
+
*
|
|
22
|
+
* Beyond the unique `name` index: `{status, batch}` serves the highest-batch
|
|
23
|
+
* lookups (which would otherwise be a blocking in-memory sort, capped at
|
|
24
|
+
* 32 MB), `{batch}` serves rollback by batch, `{status, name}` makes the
|
|
25
|
+
* hottest query of all — the applied set, sorted by name, read on every
|
|
26
|
+
* `up`/`status`/health check — a covered, sort-free index scan, and
|
|
27
|
+
* `{status, appliedAt, name}` does the same for the newest-first ordering
|
|
28
|
+
* that `redo` and `down --steps` sort by.
|
|
29
|
+
*/
|
|
30
|
+
async ensureIndexes(db) {
|
|
31
|
+
await this.#coll(db).createIndexes([
|
|
32
|
+
{ key: { name: 1 }, name: 'name_unique', unique: true },
|
|
33
|
+
{ key: { status: 1, batch: -1 }, name: 'status_batch' },
|
|
34
|
+
{ key: { batch: 1 }, name: 'batch' },
|
|
35
|
+
{ key: { status: 1, name: 1 }, name: 'status_name' },
|
|
36
|
+
{ key: { status: 1, appliedAt: -1, name: -1 }, name: 'status_appliedAt_name' },
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read every raw document from a foreign collection (e.g. a migrate-mongo
|
|
42
|
+
* `changelog`). Returns untyped docs since the source schema differs from
|
|
43
|
+
* ours — the caller is responsible for validating and mapping them. Projected
|
|
44
|
+
* down to the fields the import mapping consumes, so adopting a large
|
|
45
|
+
* changelog does not pull every legacy payload into memory.
|
|
46
|
+
*/
|
|
47
|
+
async getForeignDocs(db, collectionName) {
|
|
48
|
+
return db
|
|
49
|
+
.collection(collectionName)
|
|
50
|
+
.find()
|
|
51
|
+
.project({ fileName: 1, fileHash: 1, appliedAt: 1, migrationBlock: 1, _id: 0 })
|
|
52
|
+
.toArray();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every record's `{name, batch}` only — what `import` needs to number new
|
|
57
|
+
* batches and detect a non-empty target without loading full documents.
|
|
58
|
+
*/
|
|
59
|
+
async getNamesAndBatches(db) {
|
|
60
|
+
return this.#coll(db).find().project({ name: 1, batch: 1, _id: 0 }).toArray();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The most recently applied record (by `appliedAt`, name-desc tiebreak), or
|
|
65
|
+
* null. A server-side top-1 sort+limit — never the whole history in memory.
|
|
66
|
+
*/
|
|
67
|
+
async getNewestApplied(db) {
|
|
68
|
+
const docs = await this.#coll(db)
|
|
69
|
+
.find({ status: 'applied' })
|
|
70
|
+
.sort({ appliedAt: -1, name: -1 })
|
|
71
|
+
.limit(1)
|
|
72
|
+
.toArray();
|
|
73
|
+
return docs[0] ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Return every changelog record, sorted by name ascending */
|
|
77
|
+
async getAll(db) {
|
|
78
|
+
return this.#coll(db).find().sort({ name: 1 }).toArray();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Return the names of all currently-applied migrations */
|
|
82
|
+
async getAppliedNames(db) {
|
|
83
|
+
const docs = await this.#coll(db)
|
|
84
|
+
.find({ status: 'applied' })
|
|
85
|
+
.sort({ name: 1 })
|
|
86
|
+
.project({ name: 1, _id: 0 })
|
|
87
|
+
.toArray();
|
|
88
|
+
const names = [];
|
|
89
|
+
for (const doc of docs) names.push(doc.name);
|
|
90
|
+
return names;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Return a single record by migration name, or null */
|
|
94
|
+
async getByName(db, name) {
|
|
95
|
+
return this.#coll(db).findOne({ name });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Return every currently-applied record, sorted by name ascending */
|
|
99
|
+
async getApplied(db) {
|
|
100
|
+
return this.#coll(db).find({ status: 'applied' }).sort({ name: 1 }).toArray();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The last N applied records, newest first (by `appliedAt`, name-desc
|
|
105
|
+
* tiebreak) — the server-side counterpart of `down --steps`. A covered
|
|
106
|
+
* sort+limit on the `status_appliedAt_name` index: never the whole applied
|
|
107
|
+
* history transferred and re-sorted client-side just to slice N.
|
|
108
|
+
*/
|
|
109
|
+
async getLastAppliedN(db, n) {
|
|
110
|
+
return this.#coll(db)
|
|
111
|
+
.find({ status: 'applied' })
|
|
112
|
+
.sort({ appliedAt: -1, name: -1 })
|
|
113
|
+
.limit(n)
|
|
114
|
+
.toArray();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Applied records named strictly after `name`, ascending — what `down --to`
|
|
119
|
+
* reverts. The predicate is pushed down onto the `status_name` index instead
|
|
120
|
+
* of filtering the full applied history in JS.
|
|
121
|
+
*/
|
|
122
|
+
async getAppliedAfter(db, name) {
|
|
123
|
+
return this.#coll(db)
|
|
124
|
+
.find({ status: 'applied', name: { $gt: name } })
|
|
125
|
+
.sort({ name: 1 })
|
|
126
|
+
.toArray();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Return the highest batch number among currently-applied migrations, or null */
|
|
130
|
+
async getLastBatch(db) {
|
|
131
|
+
const docs = await this.#coll(db)
|
|
132
|
+
.find({ status: 'applied' })
|
|
133
|
+
.sort({ batch: -1 })
|
|
134
|
+
.limit(1)
|
|
135
|
+
.project({ batch: 1, _id: 0 })
|
|
136
|
+
.toArray();
|
|
137
|
+
return docs[0]?.batch ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Highest batch number ever used, including reverted records — the basis for
|
|
142
|
+
* the next batch, so numbers stay monotonic across rollbacks. An indexed
|
|
143
|
+
* sort+limit, not a full scan.
|
|
144
|
+
*/
|
|
145
|
+
async getMaxBatch(db) {
|
|
146
|
+
const docs = await this.#coll(db)
|
|
147
|
+
.find({})
|
|
148
|
+
.sort({ batch: -1 })
|
|
149
|
+
.limit(1)
|
|
150
|
+
.project({ batch: 1, _id: 0 })
|
|
151
|
+
.toArray();
|
|
152
|
+
return docs[0]?.batch ?? 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Return all records belonging to a given batch */
|
|
156
|
+
async getByBatch(db, batch) {
|
|
157
|
+
return this.#coll(db).find({ batch }).sort({ name: 1 }).toArray();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Record a migration as applied. Upserts on `name` so re-applying a
|
|
162
|
+
* previously-reverted migration (e.g. via `redo`) cannot violate the unique
|
|
163
|
+
* index. Uses `$set` rather than a whole-document replace so audit fields
|
|
164
|
+
* survive a re-apply: `firstAppliedAt` is stamped once, and the stale
|
|
165
|
+
* `revertedAt` from an earlier rollback is cleared.
|
|
166
|
+
*
|
|
167
|
+
* Pass `session` to make this write part of the migration's transaction, so
|
|
168
|
+
* the migration and its changelog record commit together.
|
|
169
|
+
*/
|
|
170
|
+
async markApplied(db, record, session) {
|
|
171
|
+
// `name` comes from the filter on insert, so it must not also appear in an
|
|
172
|
+
// update operator (MongoDB rejects the conflicting path).
|
|
173
|
+
const { name, ...fields } = record;
|
|
174
|
+
await this.#coll(db).updateOne(
|
|
175
|
+
{ name },
|
|
176
|
+
{
|
|
177
|
+
$set: fields,
|
|
178
|
+
$setOnInsert: { firstAppliedAt: record.appliedAt },
|
|
179
|
+
$unset: { revertedAt: '' },
|
|
180
|
+
},
|
|
181
|
+
{ upsert: true, ...(session ? { session } : {}) },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Bulk counterpart of {@link markApplied}: one unordered bulkWrite instead of
|
|
187
|
+
* one round trip per record. Identical upsert-on-name semantics; the
|
|
188
|
+
* operations are independent (unique `name` keys), so unordered is safe and
|
|
189
|
+
* lets the server parallelize. Used by `import`, whose record count is set by
|
|
190
|
+
* whatever legacy changelog is being adopted.
|
|
191
|
+
*/
|
|
192
|
+
async markAppliedBulk(db, records) {
|
|
193
|
+
if (records.length === 0) return;
|
|
194
|
+
const ops = new Array(records.length);
|
|
195
|
+
for (let i = 0; i < records.length; i++) {
|
|
196
|
+
const { name, ...fields } = records[i];
|
|
197
|
+
ops[i] = {
|
|
198
|
+
updateOne: {
|
|
199
|
+
filter: { name },
|
|
200
|
+
update: {
|
|
201
|
+
$set: fields,
|
|
202
|
+
$setOnInsert: { firstAppliedAt: records[i].appliedAt },
|
|
203
|
+
$unset: { revertedAt: '' },
|
|
204
|
+
},
|
|
205
|
+
upsert: true,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
await this.#coll(db).bulkWrite(ops, { ordered: false });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Mark a migration as reverted. Sets `status='reverted'` and `revertedAt=now`.
|
|
214
|
+
* Never deletes the record — preserves the full audit history.
|
|
215
|
+
*
|
|
216
|
+
* Pass `session` to commit this together with the migration's `down()`.
|
|
217
|
+
*
|
|
218
|
+
* Returns the driver's update result: `matchedCount === 0` means the record
|
|
219
|
+
* was no longer `'applied'` (a concurrent peer got there first) — the caller
|
|
220
|
+
* decides what to do with that, since this module stays logger-free.
|
|
221
|
+
*/
|
|
222
|
+
async markReverted(db, name, session) {
|
|
223
|
+
return this.#coll(db).updateOne(
|
|
224
|
+
{ name, status: 'applied' },
|
|
225
|
+
{ $set: { status: 'reverted', revertedAt: new Date() } },
|
|
226
|
+
session ? { session } : {},
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { Changelog };
|