@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +409 -1
  2. package/README.md +248 -24
  3. package/bin/migronaut.js +11 -3
  4. package/bullmq.d.ts +845 -0
  5. package/bullmq.js +1 -0
  6. package/index.d.ts +757 -29
  7. package/migronaut.schema.json +191 -1
  8. package/package.json +27 -6
  9. package/src/bullmq/index.js +55 -0
  10. package/src/bullmq/jobs.js +454 -0
  11. package/src/bullmq/processor.js +608 -0
  12. package/src/bullmq/producer.js +424 -0
  13. package/src/bullmq/service.js +653 -0
  14. package/src/bullmq/wait.js +124 -0
  15. package/src/cli/args.js +12 -2
  16. package/src/cli/commands/baseline.js +45 -0
  17. package/src/cli/commands/converge.js +160 -0
  18. package/src/cli/commands/down.js +2 -0
  19. package/src/cli/commands/lock.js +2 -1
  20. package/src/cli/commands/redo.js +8 -1
  21. package/src/cli/commands/unlock.js +12 -2
  22. package/src/cli/commands/up.js +14 -1
  23. package/src/cli/exit-codes.js +10 -2
  24. package/src/cli/index.js +4 -0
  25. package/src/cli/shared.js +29 -7
  26. package/src/cli/table.js +105 -0
  27. package/src/core/audit.js +17 -3
  28. package/src/core/baseline.js +80 -0
  29. package/src/core/changelog.js +140 -24
  30. package/src/core/collections.js +372 -0
  31. package/src/core/config.js +125 -27
  32. package/src/core/converge-log.js +47 -0
  33. package/src/core/converge-plan.js +483 -0
  34. package/src/core/converge.js +867 -0
  35. package/src/core/import-runner.js +34 -6
  36. package/src/core/import.js +14 -7
  37. package/src/core/index-spec.js +496 -0
  38. package/src/core/lock-wait.js +260 -0
  39. package/src/core/lock.js +71 -20
  40. package/src/core/migrator.js +805 -304
  41. package/src/core/options.js +251 -0
  42. package/src/core/run-recorder.js +157 -0
  43. package/src/core/run.js +71 -71
  44. package/src/core/runner.js +70 -20
  45. package/src/core/sequence.js +134 -0
  46. package/src/errors/index.js +71 -1
  47. package/src/index.js +16 -0
  48. package/src/utils/actor.js +48 -0
  49. package/src/utils/canonical.js +179 -0
  50. package/src/utils/collection-name.js +21 -0
  51. package/src/utils/error.js +18 -1
  52. package/src/utils/id.js +77 -0
  53. package/src/utils/loader.js +39 -21
  54. package/src/utils/logger.js +30 -12
  55. package/src/utils/migration-name.js +32 -0
  56. package/src/utils/redact.js +57 -4
  57. package/src/utils/sanitize.js +8 -3
  58. package/src/utils/telemetry.js +393 -0
  59. package/src/utils/template.js +60 -12
package/src/cli/table.js CHANGED
@@ -247,6 +247,109 @@ function renderImportTable(rows) {
247
247
  return renderTable(head, cells);
248
248
  }
249
249
 
250
+ /** Longest index or collection name rendered before it is ellipsized */
251
+ const MAX_NAME_WIDTH = 48;
252
+
253
+ /** Longest converge detail rendered before it is ellipsized */
254
+ const MAX_DETAIL_WIDTH = 72;
255
+
256
+ /** Render a converge action cell: what changes stands out, what does not recedes */
257
+ function convergeActionCell(colors, action) {
258
+ switch (action) {
259
+ case 'create':
260
+ return colors.green(action);
261
+ case 'modify':
262
+ return colors.cyan(action);
263
+ case 'recreate':
264
+ case 'drop':
265
+ return colors.yellow(action);
266
+ case 'conflict':
267
+ return colors.red(action);
268
+ default:
269
+ return colors.dim(action);
270
+ }
271
+ }
272
+
273
+ /** The detail column: what differs, or why the row is what it is */
274
+ function convergeDetail(action) {
275
+ if (action.reason === 'name' && action.liveName !== undefined) {
276
+ return `renamed from "${action.liveName}"`;
277
+ }
278
+ return action.reason ?? '';
279
+ }
280
+
281
+ /**
282
+ * Render a converge plan (or result) as a table plus a one-line summary.
283
+ * Rows that need nothing are folded into the summary unless `all` — a large
284
+ * schema would otherwise bury its two changes under fifty "unchanged" lines.
285
+ */
286
+ function renderConvergeTable(result, { all = false } = {}) {
287
+ const colors = palette();
288
+ const cells = [];
289
+ const counts = { change: 0, destructive: 0, conflict: 0, keep: 0, unchanged: 0, touched: 0 };
290
+ for (const collection of result.collections) {
291
+ let changes = 0;
292
+ for (const action of collection.actions) {
293
+ if (action.action === 'unchanged') counts.unchanged += 1;
294
+ else if (action.action === 'keep') counts.keep += 1;
295
+ else if (action.action === 'conflict') counts.conflict += 1;
296
+ else changes += 1;
297
+ if (action.target === 'index' && (action.action === 'drop' || action.action === 'recreate')) {
298
+ counts.destructive += 1;
299
+ }
300
+ if (action.action === 'unchanged' && !all) continue;
301
+ cells.push([
302
+ truncate(sanitize(collection.name), MAX_NAME_WIDTH),
303
+ action.target,
304
+ action.target === 'index' ? truncate(sanitize(action.name), MAX_NAME_WIDTH) : '',
305
+ convergeActionCell(colors, action.action),
306
+ truncate(sanitize(convergeDetail(action)), MAX_DETAIL_WIDTH),
307
+ ]);
308
+ }
309
+ counts.change += changes;
310
+ if (changes > 0) counts.touched += 1;
311
+ }
312
+ const collections = result.collections.length;
313
+ let summary;
314
+ if (counts.change === 0 && counts.conflict === 0) {
315
+ summary = `✔ ${collections} collection(s) match their declarations`;
316
+ } else {
317
+ const verb = result.dryRun ? 'Would make' : 'Made';
318
+ summary =
319
+ `${verb} ${counts.change} change(s) in ${counts.touched} of ${collections} ` +
320
+ 'collection(s)';
321
+ }
322
+ const parts = [summary];
323
+ if (counts.destructive > 0) parts.push(`${counts.destructive} drop/rebuild`);
324
+ if (counts.conflict > 0) parts.push(colors.red(`${counts.conflict} conflict(s)`));
325
+ if (counts.keep > 0) parts.push(`${counts.keep} undeclared index(es) kept`);
326
+ if (counts.unchanged > 0 && !all) parts.push(`${counts.unchanged} unchanged`);
327
+ const line = parts.join(' · ');
328
+ if (cells.length === 0) return line;
329
+ return `${renderTable(['Collection', 'Target', 'Index', 'Action', 'Detail'], cells)}\n${line}`;
330
+ }
331
+
332
+ /**
333
+ * Render the converge history: one line per converge that changed something
334
+ * or failed, newest first — the audit view (`migronaut converge --history`).
335
+ */
336
+ function renderConvergeHistory(entries) {
337
+ if (entries.length === 0) return 'No converge has changed anything yet';
338
+ const colors = palette();
339
+ const cells = entries.map((entry) => [
340
+ entry.startedAt instanceof Date ? entry.startedAt.toISOString() : String(entry.startedAt),
341
+ entry.trigger,
342
+ entry.success ? colors.green('ok') : colors.red('failed'),
343
+ String(entry.changed),
344
+ truncate(sanitize(entry.requestedBy ?? entry.executedBy ?? ''), MAX_NAME_WIDTH),
345
+ truncate(
346
+ sanitize(entry.reason ?? (entry.success ? '' : (entry.error ?? ''))),
347
+ MAX_DETAIL_WIDTH,
348
+ ),
349
+ ]);
350
+ return renderTable(['When', 'Trigger', 'Result', 'Changes', 'Who', 'Why'], cells);
351
+ }
352
+
250
353
  module.exports = {
251
354
  charWidth,
252
355
  sanitize,
@@ -256,4 +359,6 @@ module.exports = {
256
359
  renderStatusTable,
257
360
  renderImportTable,
258
361
  renderRowsOrEmpty,
362
+ renderConvergeTable,
363
+ renderConvergeHistory,
259
364
  };
package/src/core/audit.js CHANGED
@@ -99,15 +99,20 @@ async function runAudit(deps) {
99
99
  record('lock', 'warn', `Could not read the lock: ${errorText(error)}`);
100
100
  }
101
101
 
102
- // 6. Checksum drift and missing files, from the same rows `status` renders.
102
+ // 6. Checksum drift, missing files, pending count and ordering, from the
103
+ // same rows `status` renders.
103
104
  try {
104
105
  const rows = await deps.status();
105
- // One pass over the rows collects both signals.
106
+ // One pass over the rows collects every signal.
106
107
  const drifted = [];
108
+ const outOfOrder = [];
107
109
  let pending = 0;
108
110
  for (const row of rows) {
109
111
  if (row.checksumOk === false) drifted.push(row.file);
110
- if (row.status === 'pending') pending += 1;
112
+ // A recorded failed attempt still counts as pending work — the file
113
+ // will be retried by the next `up`.
114
+ if (row.status === 'pending' || row.status === 'failed') pending += 1;
115
+ if (row.outOfOrder) outOfOrder.push(row.file);
111
116
  }
112
117
  if (drifted.length > 0) {
113
118
  record('checksums', 'fail', `Edited after being applied: ${drifted.join(', ')}`);
@@ -115,6 +120,15 @@ async function runAudit(deps) {
115
120
  record('checksums', 'pass', 'No drift among applied migrations');
116
121
  }
117
122
  record('pending', pending === 0 ? 'pass' : 'warn', `${pending} pending migration(s)`);
123
+ if (outOfOrder.length > 0) {
124
+ record(
125
+ 'ordering',
126
+ 'warn',
127
+ `Pending but older than the newest applied migration: ${outOfOrder.join(', ')}`,
128
+ );
129
+ } else {
130
+ record('ordering', 'pass', 'No out-of-order pending migrations');
131
+ }
118
132
  } catch (error) {
119
133
  record('checksums', 'warn', `Could not read status: ${errorText(error)}`);
120
134
  }
@@ -0,0 +1,80 @@
1
+ const { computeChecksum } = require('../utils/checksum.js');
2
+ const { mapLimit } = require('../utils/concurrency.js');
3
+
4
+ /** Simultaneous file hashes — same EMFILE bound as every other multi-file path */
5
+ const FS_CONCURRENCY = 16;
6
+
7
+ /**
8
+ * Adopt an existing database with no prior migration tool: mark migration
9
+ * files on disk as applied — checksums taken from disk, one shared batch,
10
+ * `origin: 'baseline'` — without executing anything. The database is assumed
11
+ * to already be in the state those files describe (they were applied by hand,
12
+ * by a home-grown script, or reconstructed after the fact).
13
+ *
14
+ * Forward-only: baselined records were never executed by migronaut, so
15
+ * `down`/`redo` refuse them (the same `origin` preflight import uses).
16
+ * Idempotent: already-applied names are skipped, so a partial baseline can
17
+ * simply be re-run.
18
+ *
19
+ * Pure orchestration over capabilities the MigratorKit injects (`deps`):
20
+ * `{db, changelog, logger, fields, filepath, listMigrationFiles, nextBatch,
21
+ * truncateAtTarget, environment, executedBy, runId, assertNotAborted}`.
22
+ */
23
+ async function runBaseline(deps, options, signal) {
24
+ const { db, changelog, logger } = deps;
25
+
26
+ const files = await deps.listMigrationFiles();
27
+ const applied = new Set(await changelog.getAppliedNames(db));
28
+ let targets = [];
29
+ for (const file of files) {
30
+ if (!applied.has(file)) targets.push(file);
31
+ }
32
+ if (options.to !== undefined) {
33
+ targets = deps.truncateAtTarget(targets, files, options.to);
34
+ }
35
+ const skipped = files.length - targets.length;
36
+
37
+ if (targets.length === 0) {
38
+ logger.info('Nothing to baseline', deps.fields({ skipped }));
39
+ return { baselined: [], skipped, batch: null };
40
+ }
41
+
42
+ // Checksums come from the files as they are NOW — that is the contract: the
43
+ // baseline asserts "the database already matches these exact files", and
44
+ // later drift checks police edits against this snapshot.
45
+ const checksums = await mapLimit(targets, FS_CONCURRENCY, (name) =>
46
+ computeChecksum(deps.filepath(name)),
47
+ );
48
+
49
+ const batch = await deps.nextBatch();
50
+ const records = new Array(targets.length);
51
+ for (let i = 0; i < targets.length; i++) {
52
+ records[i] = {
53
+ name: targets[i],
54
+ batch,
55
+ status: 'applied',
56
+ // No appliedAt: the changelog stamps it in server time.
57
+ duration: 0,
58
+ checksum: checksums[i],
59
+ environment: deps.environment(),
60
+ executedBy: deps.executedBy(),
61
+ origin: 'baseline',
62
+ ...(deps.runId() ? { runId: deps.runId() } : {}),
63
+ };
64
+ }
65
+
66
+ // One write, checked against the abort signal first: a baseline is all
67
+ // bookkeeping, so there is no safe partial point worth resuming from — and
68
+ // markAppliedBulk's upsert-by-name makes a re-run after any failure
69
+ // idempotent anyway.
70
+ deps.assertNotAborted(signal);
71
+ await changelog.markAppliedBulk(db, records);
72
+
73
+ logger.info(
74
+ `✔ Baselined ${records.length} migration(s) as applied (batch ${batch})`,
75
+ deps.fields({ baselined: records.length, skipped, batch }),
76
+ );
77
+ return { baselined: targets, skipped, batch };
78
+ }
79
+
80
+ module.exports = { runBaseline };
@@ -1,3 +1,5 @@
1
+ const { actorFields } = require('../utils/actor.js');
2
+
1
3
  /**
2
4
  * Reads and writes migration records in the changelog collection
3
5
  * (`_migronaut_migrations` by default).
@@ -90,14 +92,40 @@ class Changelog {
90
92
  return names;
91
93
  }
92
94
 
95
+ /**
96
+ * Which of `names` carry a `'failed'` trace — what tells a migration that
97
+ * failed (the line is stopped) from one that simply has not run yet (it may
98
+ * be in flight elsewhere). Served by the `status_name` index.
99
+ */
100
+ async getFailedNames(db, names) {
101
+ if (names.length === 0) return [];
102
+ const docs = await this.#coll(db)
103
+ .find({ status: 'failed', name: { $in: names } })
104
+ .sort({ name: 1 })
105
+ .project({ name: 1, _id: 0 })
106
+ .toArray();
107
+ const failed = [];
108
+ for (const doc of docs) failed.push(doc.name);
109
+ return failed;
110
+ }
111
+
93
112
  /** Return a single record by migration name, or null */
94
113
  async getByName(db, name) {
95
114
  return this.#coll(db).findOne({ name });
96
115
  }
97
116
 
98
- /** Return every currently-applied record, sorted by name ascending */
117
+ /**
118
+ * Every currently-applied record's `{name, checksum}`, sorted by name
119
+ * ascending — exactly what the strict bulk drift check consumes (the name
120
+ * doubles as the applied-set key). Projected like the module's other reads;
121
+ * widen the projection if a new caller needs more.
122
+ */
99
123
  async getApplied(db) {
100
- return this.#coll(db).find({ status: 'applied' }).sort({ name: 1 }).toArray();
124
+ return this.#coll(db)
125
+ .find({ status: 'applied' })
126
+ .sort({ name: 1 })
127
+ .project({ _id: 0, name: 1, checksum: 1 })
128
+ .toArray();
101
129
  }
102
130
 
103
131
  /**
@@ -126,6 +154,29 @@ class Changelog {
126
154
  .toArray();
127
155
  }
128
156
 
157
+ /**
158
+ * Applied records that were applied *after* `record`, newest first — the
159
+ * revert order `down --steps` uses (`appliedAt`, name-desc tiebreak). An
160
+ * `ordered` rollback refuses while any exist: undoing effects is only safe in
161
+ * reverse of the order they were made. A record with no `appliedAt` (a
162
+ * hand-edited or legacy document) treats every other applied record as
163
+ * newer — the conservative answer.
164
+ */
165
+ async getAppliedNewerThan(db, { appliedAt, name }) {
166
+ const filter =
167
+ appliedAt instanceof Date
168
+ ? {
169
+ status: 'applied',
170
+ $or: [{ appliedAt: { $gt: appliedAt } }, { appliedAt, name: { $gt: name } }],
171
+ }
172
+ : { status: 'applied', name: { $ne: name } };
173
+ return this.#coll(db)
174
+ .find(filter)
175
+ .sort({ appliedAt: -1, name: -1 })
176
+ .project({ _id: 0, name: 1, appliedAt: 1, batch: 1 })
177
+ .toArray();
178
+ }
179
+
129
180
  /** Return the highest batch number among currently-applied migrations, or null */
130
181
  async getLastBatch(db) {
131
182
  const docs = await this.#coll(db)
@@ -157,29 +208,66 @@ class Changelog {
157
208
  return this.#coll(db).find({ batch }).sort({ name: 1 }).toArray();
158
209
  }
159
210
 
211
+ /**
212
+ * The update document shared by markApplied and markAppliedBulk.
213
+ *
214
+ * `appliedAt` is stamped in **server time** (`$currentDate`) when the record
215
+ * does not carry one — the same clock discipline the lock's `$$NOW` uses:
216
+ * `redo` and `down --steps` sort by `appliedAt`, and a client-stamped value
217
+ * lets a skewed host mis-order the revert selection. An explicit `appliedAt`
218
+ * (import adopting a legacy changelog's historical timestamps) is written
219
+ * verbatim. `firstAppliedAt` is audit-only metadata, never sorted on, so its
220
+ * client-clock `$setOnInsert` fallback is acceptable ($setOnInsert cannot
221
+ * express server time).
222
+ */
223
+ static #appliedUpdate(record) {
224
+ // `name` comes from the filter on insert, so it must not also appear in an
225
+ // update operator (MongoDB rejects the conflicting path).
226
+ const { name, appliedAt, ...fields } = record;
227
+ const update = {
228
+ $set: fields,
229
+ // A re-apply clears the stale revert marker (and who asked for the
230
+ // revert, and why) — and the failure trace a markFailed() from an earlier
231
+ // crashed attempt may have left.
232
+ $unset: {
233
+ revertedAt: '',
234
+ revertRequestedBy: '',
235
+ revertReason: '',
236
+ failedAt: '',
237
+ error: '',
238
+ },
239
+ };
240
+ // Who asked for this apply, and why — or nobody said: then the previous
241
+ // apply's answer must not linger as if it were this one's.
242
+ for (const key of ['requestedBy', 'reason']) {
243
+ if (fields[key] === undefined) update.$unset[key] = '';
244
+ }
245
+ if (appliedAt !== undefined) {
246
+ update.$set.appliedAt = appliedAt;
247
+ update.$setOnInsert = { firstAppliedAt: appliedAt };
248
+ } else {
249
+ update.$currentDate = { appliedAt: true };
250
+ update.$setOnInsert = { firstAppliedAt: new Date() };
251
+ }
252
+ return update;
253
+ }
254
+
160
255
  /**
161
256
  * Record a migration as applied. Upserts on `name` so re-applying a
162
257
  * previously-reverted migration (e.g. via `redo`) cannot violate the unique
163
258
  * index. Uses `$set` rather than a whole-document replace so audit fields
164
259
  * survive a re-apply: `firstAppliedAt` is stamped once, and the stale
165
- * `revertedAt` from an earlier rollback is cleared.
260
+ * `revertedAt` from an earlier rollback is cleared. `appliedAt` is stamped
261
+ * server-side unless the record carries one — see {@link #appliedUpdate}.
166
262
  *
167
263
  * Pass `session` to make this write part of the migration's transaction, so
168
264
  * the migration and its changelog record commit together.
169
265
  */
170
266
  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
- );
267
+ await this.#coll(db).updateOne({ name: record.name }, Changelog.#appliedUpdate(record), {
268
+ upsert: true,
269
+ ...(session ? { session } : {}),
270
+ });
183
271
  }
184
272
 
185
273
  /**
@@ -193,15 +281,10 @@ class Changelog {
193
281
  if (records.length === 0) return;
194
282
  const ops = new Array(records.length);
195
283
  for (let i = 0; i < records.length; i++) {
196
- const { name, ...fields } = records[i];
197
284
  ops[i] = {
198
285
  updateOne: {
199
- filter: { name },
200
- update: {
201
- $set: fields,
202
- $setOnInsert: { firstAppliedAt: records[i].appliedAt },
203
- $unset: { revertedAt: '' },
204
- },
286
+ filter: { name: records[i].name },
287
+ update: Changelog.#appliedUpdate(records[i]),
205
288
  upsert: true,
206
289
  },
207
290
  };
@@ -219,13 +302,46 @@ class Changelog {
219
302
  * was no longer `'applied'` (a concurrent peer got there first) — the caller
220
303
  * decides what to do with that, since this module stays logger-free.
221
304
  */
222
- async markReverted(db, name, session) {
305
+ async markReverted(db, name, session, actor = {}) {
306
+ const update = {
307
+ $set: {
308
+ status: 'reverted',
309
+ ...actorFields(actor, 'revert'),
310
+ },
311
+ // Server time, like markApplied's appliedAt — one clock for the whole trail.
312
+ $currentDate: { revertedAt: true },
313
+ };
314
+ const unset = {};
315
+ if (actor.requestedBy === undefined) unset.revertRequestedBy = '';
316
+ if (actor.reason === undefined) unset.revertReason = '';
317
+ if (Object.keys(unset).length > 0) update.$unset = unset;
223
318
  return this.#coll(db).updateOne(
224
319
  { name, status: 'applied' },
225
- { $set: { status: 'reverted', revertedAt: new Date() } },
320
+ update,
226
321
  session ? { session } : {},
227
322
  );
228
323
  }
324
+
325
+ /**
326
+ * Best-effort trace of a failed `up` attempt, so a crash-and-restart leaves
327
+ * DB-side evidence of what was in flight ("did the crashed run start X?")
328
+ * instead of depending on process logs that may not have been captured.
329
+ *
330
+ * The filter excludes `'applied'` records: a forced re-run's failure must
331
+ * never demote a migration the changelog says is applied — the upsert then
332
+ * collides on the unique `name` index, and the caller swallows that. Every
333
+ * read path filters on `status: 'applied'`, so a `'failed'` record never
334
+ * changes what runs; the next successful apply overwrites it (and clears
335
+ * `failedAt`/`error`).
336
+ */
337
+ async markFailed(db, record) {
338
+ const { name, ...fields } = record;
339
+ await this.#coll(db).updateOne(
340
+ { name, status: { $ne: 'applied' } },
341
+ { $set: { ...fields, status: 'failed' }, $currentDate: { failedAt: true } },
342
+ { upsert: true },
343
+ );
344
+ }
229
345
  }
230
346
 
231
347
  module.exports = { Changelog };