@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
@@ -0,0 +1,424 @@
1
+ const { ConfigInvalidError, MigrationBlockedError } = require('../errors/index.js');
2
+ const { actorIssue, pickActor } = require('../utils/actor.js');
3
+ const { mapLimit } = require('../utils/concurrency.js');
4
+ const { assertId, randomId } = require('../utils/id.js');
5
+ const { assertMigrationName } = require('../utils/migration-name.js');
6
+ const {
7
+ FORBIDDEN_JOB_OPTIONS,
8
+ JOB_NAMES,
9
+ buildConvergeJob,
10
+ buildMigrationJob,
11
+ isPlainObject,
12
+ migrationJobOptions,
13
+ } = require('./jobs.js');
14
+ const { waitForGroup } = require('./wait.js');
15
+
16
+ /** Job lookups in flight while checking a group for deduplicated adds */
17
+ const LOOKUP_CONCURRENCY = 16;
18
+
19
+ /**
20
+ * `jobOptions` is a passthrough for retention and logging knobs
21
+ * (`removeOnComplete`, `removeOnFail`, `keepLogs`, …). Anything that would
22
+ * reorder, delay or re-run a job is refused by name rather than silently
23
+ * overridden — the caller should learn the queue is FIFO on purpose.
24
+ */
25
+ function assertJobOptions(jobOptions) {
26
+ if (jobOptions === undefined) return;
27
+ if (!isPlainObject(jobOptions)) {
28
+ throw new ConfigInvalidError('jobOptions must be an object', { jobOptions: typeof jobOptions });
29
+ }
30
+ for (const key of FORBIDDEN_JOB_OPTIONS) {
31
+ if (jobOptions[key] !== undefined) {
32
+ throw new ConfigInvalidError(
33
+ `jobOptions.${key} is not configurable — migration jobs run strictly first-in, ` +
34
+ 'first-out with a single attempt',
35
+ { key },
36
+ );
37
+ }
38
+ }
39
+ }
40
+
41
+ /** Validate the `requestedBy` / `reason` of an enqueue call, and return them */
42
+ function actorOf(options) {
43
+ for (const key of ['requestedBy', 'reason']) {
44
+ const issue = actorIssue(key, options[key]);
45
+ if (issue) throw new ConfigInvalidError(issue, { [key]: typeof options[key] });
46
+ }
47
+ return pickActor(options);
48
+ }
49
+
50
+ function assertBoolean(value, name) {
51
+ if (typeof value !== 'boolean') {
52
+ throw new ConfigInvalidError(`${name} must be a boolean`, { [name]: value });
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The id of one enqueue call, in the kit's configured format (`generateId`) so
58
+ * a deployment sees one id format across the changelog and the queue. A
59
+ * duck-typed kit without the method gets the default. Checked again here
60
+ * either way: a worker refuses a job whose group id is not a short string, and
61
+ * that has to be this call's error, not a job failing later in the queue.
62
+ */
63
+ async function newGroupId(kit) {
64
+ return assertId(typeof kit.generateId === 'function' ? await kit.generateId() : randomId());
65
+ }
66
+
67
+ /** Newest applied first — `appliedAt`, name-desc tiebreak: the order rollbacks must follow */
68
+ function newestFirst(a, b) {
69
+ const delta = (b.appliedAt?.getTime?.() ?? 0) - (a.appliedAt?.getTime?.() ?? 0);
70
+ if (delta !== 0) return delta;
71
+ if (a.file === b.file) return 0;
72
+ return a.file < b.file ? 1 : -1;
73
+ }
74
+
75
+ /**
76
+ * Whether an `up` group ends with a converge job. Explicit `converge` wins;
77
+ * otherwise it mirrors the kit's own after-up hook (`convergeAfterUp`), which
78
+ * never fires in a queue — every job there is a single-file run. Only a group
79
+ * that brings the database to the head converges, as in the kit.
80
+ */
81
+ async function resolveConverge(kit, { converge, filename, to }) {
82
+ if (converge !== undefined) {
83
+ assertBoolean(converge, 'converge');
84
+ if (converge && (filename !== undefined || to !== undefined)) {
85
+ throw new ConfigInvalidError(
86
+ 'converge needs a group that reaches the newest migration — not a filename or `to`',
87
+ { converge },
88
+ );
89
+ }
90
+ return converge;
91
+ }
92
+ if (filename !== undefined || to !== undefined) return false;
93
+ return typeof kit.convergesAfterUp === 'function' && (await kit.convergesAfterUp()) === true;
94
+ }
95
+
96
+ /**
97
+ * Plan an `up` group without enqueuing it: which files, in which order, under
98
+ * which batch. The selection is `kit.dryRun('up')` — the same one a real run
99
+ * makes, order policy included — so the plan can never name a file a run
100
+ * would not apply. One batch number is peeked for the whole group, which is
101
+ * what makes a later `down` revert it as a unit.
102
+ *
103
+ * A group that converges carries its converge job apart from the migration
104
+ * jobs, as `plan.converge`, so `plan.jobs` stays one-to-one with
105
+ * `plan.migrations`. With nothing pending the converge job is planned only if
106
+ * a dry run finds the database out of step.
107
+ */
108
+ async function planUpJobs(kit, options = {}) {
109
+ const { filename, to, force = false, ordered = true, jobOptions } = options;
110
+ if (filename !== undefined) assertMigrationName(filename);
111
+ if (to !== undefined) assertMigrationName(to);
112
+ assertBoolean(force, 'force');
113
+ assertBoolean(ordered, 'ordered');
114
+ const actor = actorOf(options);
115
+ if (force && filename === undefined) {
116
+ // A bulk plan only ever holds pending files — there is no applied target
117
+ // for `force` to re-run.
118
+ throw new ConfigInvalidError('force requires a filename', { force });
119
+ }
120
+ assertJobOptions(jobOptions);
121
+ const converge = await resolveConverge(kit, { converge: options.converge, filename, to });
122
+
123
+ const rows = await kit.dryRun('up', filename, to !== undefined ? { to } : {});
124
+ const migrations = [];
125
+ /** The version of each file the plan was made from — a worker refuses any other */
126
+ const checksums = new Map();
127
+ for (const row of rows) {
128
+ if (row.status !== 'applied' || force) {
129
+ migrations.push(row.file);
130
+ if (typeof row.checksum === 'string') checksums.set(row.file, row.checksum);
131
+ }
132
+ }
133
+ const groupId = await newGroupId(kit);
134
+ if (migrations.length === 0) {
135
+ const plan = { groupId, direction: JOB_NAMES.UP, batch: null, migrations, jobs: [] };
136
+ if (converge && !(await kit.converge({ dryRun: true })).inSync) {
137
+ plan.converge = buildConvergeJob({ groupId, ordered, jobOptions, ...actor });
138
+ }
139
+ return plan;
140
+ }
141
+
142
+ const batch = await kit.nextBatch();
143
+ const jobs = [];
144
+ for (const [index, migration] of migrations.entries()) {
145
+ jobs.push({
146
+ ...buildMigrationJob({
147
+ direction: JOB_NAMES.UP,
148
+ migration,
149
+ groupId,
150
+ index,
151
+ total: migrations.length,
152
+ batch,
153
+ force,
154
+ ordered,
155
+ checksum: checksums.get(migration),
156
+ ...actor,
157
+ }),
158
+ opts: migrationJobOptions(jobOptions, JOB_NAMES.UP, migration, { force }),
159
+ });
160
+ }
161
+ const plan = { groupId, direction: JOB_NAMES.UP, batch, migrations, jobs };
162
+ if (converge) {
163
+ plan.converge = buildConvergeJob({
164
+ groupId,
165
+ ordered,
166
+ after: migrations.at(-1),
167
+ jobOptions,
168
+ ...actor,
169
+ });
170
+ }
171
+ return plan;
172
+ }
173
+
174
+ /**
175
+ * An ordered rollback must be the top of the applied stack: every job reverts
176
+ * only once nothing applied after it remains, so a plan that skips a newer
177
+ * migration can never finish. Refusing here turns that into one synchronous
178
+ * error instead of a group that fails halfway through.
179
+ */
180
+ async function assertTopOfStack(kit, migrations) {
181
+ // Names and dates are all this needs — not a re-hash of every applied file.
182
+ const applied = await kit.list('applied', { checksums: false });
183
+ applied.sort(newestFirst);
184
+ const planned = new Set(migrations);
185
+ let deepest = -1;
186
+ for (const [index, row] of applied.entries()) {
187
+ if (planned.has(row.file)) deepest = index;
188
+ }
189
+ const blockedBy = [];
190
+ for (let index = 0; index < deepest; index++) {
191
+ if (!planned.has(applied[index].file)) blockedBy.push(applied[index].file);
192
+ }
193
+ if (blockedBy.length === 0) return;
194
+ throw new MigrationBlockedError(
195
+ `Rollback is blocked: ${blockedBy.length} later migration(s) still applied and not part of ` +
196
+ `it: ${blockedBy.join(', ')}`,
197
+ { direction: JOB_NAMES.DOWN, names: [...migrations], blockedBy },
198
+ );
199
+ }
200
+
201
+ /**
202
+ * Plan a `down` group: `kit.dryRun('down')` picks the records (so forward-only
203
+ * and not-applied refusals happen here, before anything is enqueued), then an
204
+ * ordered plan is put in revert order — newest applied first.
205
+ */
206
+ async function planDownJobs(kit, options = {}) {
207
+ const { filename, steps, batch, to, ordered = true, jobOptions } = options;
208
+ if (filename !== undefined) assertMigrationName(filename);
209
+ if (to !== undefined) assertMigrationName(to);
210
+ assertBoolean(ordered, 'ordered');
211
+ assertJobOptions(jobOptions);
212
+ const actor = actorOf(options);
213
+
214
+ const selection = {};
215
+ if (steps !== undefined) selection.steps = steps;
216
+ if (batch !== undefined) selection.batch = batch;
217
+ if (to !== undefined) selection.to = to;
218
+ const rows = await kit.dryRun('down', filename, selection);
219
+ if (ordered) rows.sort(newestFirst);
220
+
221
+ const migrations = [];
222
+ for (const row of rows) migrations.push(row.file);
223
+ const groupId = await newGroupId(kit);
224
+ if (migrations.length === 0) {
225
+ return { groupId, direction: JOB_NAMES.DOWN, batch: null, migrations, jobs: [] };
226
+ }
227
+ if (ordered) await assertTopOfStack(kit, migrations);
228
+
229
+ const jobs = [];
230
+ for (const [index, row] of rows.entries()) {
231
+ jobs.push({
232
+ ...buildMigrationJob({
233
+ direction: JOB_NAMES.DOWN,
234
+ migration: row.file,
235
+ groupId,
236
+ index,
237
+ total: rows.length,
238
+ batch: row.batch,
239
+ ordered,
240
+ ...actor,
241
+ }),
242
+ opts: migrationJobOptions(jobOptions, JOB_NAMES.DOWN, row.file),
243
+ });
244
+ }
245
+ return { groupId, direction: JOB_NAMES.DOWN, batch: null, migrations, jobs };
246
+ }
247
+
248
+ /**
249
+ * For each id, whether the job stored under it was queued by another enqueue
250
+ * call. A deduplicated add hands back the *existing* job's id, so the stored
251
+ * job's group differs from ours — and waiting on that id simply joins the job
252
+ * that will do the work. All false on a queue that cannot read jobs back.
253
+ */
254
+ async function foreignJobs(queue, ids, groupId) {
255
+ if (typeof queue.getJob !== 'function') return ids.map(() => false);
256
+ // A first deploy can enqueue hundreds of jobs: read them back a few at a time.
257
+ const stored = await mapLimit(ids, LOOKUP_CONCURRENCY, (id) => queue.getJob(id));
258
+ return stored.map((job) => Boolean(job && job.data?.groupId !== groupId));
259
+ }
260
+
261
+ /**
262
+ * The `wait()` of an enqueue handle. It waits through the caller's
263
+ * QueueEvents, else the one the facade lends (`getQueueEvents`) — never
264
+ * opened for a group that has nothing to wait for.
265
+ */
266
+ function makeWait(queue, group, { queueEvents, getQueueEvents } = {}) {
267
+ const waitsForSomething = group.jobs.length > 0 || group.converge !== undefined;
268
+ return (waitOptions = {}) =>
269
+ waitForGroup({
270
+ queue,
271
+ queueEvents:
272
+ waitOptions.queueEvents ??
273
+ queueEvents ??
274
+ (waitsForSomething ? getQueueEvents?.() : undefined),
275
+ ...group,
276
+ timeoutMs: waitOptions.timeoutMs,
277
+ });
278
+ }
279
+
280
+ function assertQueue(queue) {
281
+ if (!queue || typeof queue.addBulk !== 'function') {
282
+ throw new ConfigInvalidError('queue must be a BullMQ Queue (it has no addBulk method)');
283
+ }
284
+ }
285
+
286
+ /** Add a planned group to the queue (atomically) and return its handle */
287
+ async function enqueueGroup(queue, kit, plan, { queueEvents, getQueueEvents } = {}) {
288
+ assertQueue(queue);
289
+ const { groupId, direction, batch } = plan;
290
+ let jobs = [];
291
+ const deduplicated = [];
292
+ let converge = null;
293
+
294
+ const specs = plan.converge ? [...plan.jobs, plan.converge] : plan.jobs;
295
+ if (specs.length > 0) {
296
+ const added = await queue.addBulk(specs);
297
+ if (!Array.isArray(added) || added.length !== specs.length) {
298
+ throw new ConfigInvalidError('queue.addBulk did not return one job per migration', {
299
+ expected: specs.length,
300
+ });
301
+ }
302
+ jobs = plan.migrations.map((migration, index) => ({
303
+ id: String(added[index].id),
304
+ migration,
305
+ index,
306
+ }));
307
+ const foreign = await foreignJobs(
308
+ queue,
309
+ added.map((job) => String(job.id)),
310
+ groupId,
311
+ );
312
+ for (const job of jobs) {
313
+ if (foreign[job.index]) deduplicated.push(job.migration);
314
+ }
315
+ if (plan.converge) {
316
+ converge = { id: String(added[specs.length - 1].id), deduplicated: foreign.at(-1) };
317
+ }
318
+ const what =
319
+ jobs.length > 0
320
+ ? `${jobs.length} migration(s)${converge ? ' + converge' : ''}`
321
+ : 'a converge job';
322
+ kit.logger.info(
323
+ `⇢ Enqueued ${what} [${direction}${batch !== null ? `, batch ${batch}` : ''}]`,
324
+ {
325
+ groupId,
326
+ direction,
327
+ ...(batch !== null ? { batch } : {}),
328
+ count: jobs.length,
329
+ deduplicated: deduplicated.length,
330
+ ...(converge ? { converge: true } : {}),
331
+ },
332
+ );
333
+ }
334
+
335
+ return {
336
+ groupId,
337
+ direction,
338
+ batch,
339
+ // "No migration to run" — a converge-only group is still up to date.
340
+ upToDate: jobs.length === 0,
341
+ jobs,
342
+ deduplicated,
343
+ converge,
344
+ wait: makeWait(
345
+ queue,
346
+ { groupId, direction, batch, jobs, ...(converge ? { converge } : {}) },
347
+ { queueEvents, getQueueEvents },
348
+ ),
349
+ };
350
+ }
351
+
352
+ /**
353
+ * Enqueue a converge job on its own, on a queue you own: it brings the
354
+ * declared collections to their declared state, under the MongoDB lock.
355
+ * `ordered` (default true) makes it refuse while a migration is still
356
+ * pending — the declared state describes the newest schema.
357
+ */
358
+ async function enqueueConverge(queue, kit, options = {}, internals = {}) {
359
+ if (!isPlainObject(options)) {
360
+ throw new ConfigInvalidError('enqueueConverge options must be an object');
361
+ }
362
+ const { ordered, jobOptions, queueEvents } = options;
363
+ if (ordered !== undefined) assertBoolean(ordered, 'ordered');
364
+ assertJobOptions(jobOptions);
365
+ assertQueue(queue);
366
+ const actor = actorOf(options);
367
+ const groupId = await newGroupId(kit);
368
+ const [added] = await queue.addBulk([
369
+ buildConvergeJob({ groupId, ordered, jobOptions, ...actor }),
370
+ ]);
371
+ if (!added) throw new ConfigInvalidError('queue.addBulk did not return the converge job');
372
+ const jobId = String(added.id);
373
+ const [deduplicated] = await foreignJobs(queue, [jobId], groupId);
374
+ const wait = makeWait(
375
+ queue,
376
+ { groupId, direction: 'converge', batch: null, jobs: [], converge: { id: jobId } },
377
+ { queueEvents, getQueueEvents: internals.getQueueEvents },
378
+ );
379
+ kit.logger.info(`⇢ Enqueued a converge job${deduplicated ? ' (already queued)' : ''}`, {
380
+ groupId,
381
+ jobId,
382
+ deduplicated,
383
+ });
384
+ return {
385
+ groupId,
386
+ jobId,
387
+ deduplicated,
388
+ wait: async (waitOptions = {}) => {
389
+ const { converge } = await wait(waitOptions);
390
+ return converge;
391
+ },
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Enqueue pending migrations (all, up to `to`, or one `filename`) as one job
397
+ * each, on a queue you own. `internals` is how the facade lends its lazily
398
+ * built QueueEvents to `wait()`.
399
+ */
400
+ async function enqueueUp(queue, kit, options = {}, internals = {}) {
401
+ const { queueEvents, ...planOptions } = options;
402
+ return enqueueGroup(queue, kit, await planUpJobs(kit, planOptions), {
403
+ queueEvents,
404
+ ...internals,
405
+ });
406
+ }
407
+
408
+ /** Enqueue a rollback (last batch, a `batch`, `steps`, back `to`, or one `filename`) */
409
+ async function enqueueDown(queue, kit, options = {}, internals = {}) {
410
+ const { queueEvents, ...planOptions } = options;
411
+ return enqueueGroup(queue, kit, await planDownJobs(kit, planOptions), {
412
+ queueEvents,
413
+ ...internals,
414
+ });
415
+ }
416
+
417
+ module.exports = {
418
+ assertJobOptions,
419
+ enqueueConverge,
420
+ enqueueDown,
421
+ enqueueUp,
422
+ planDownJobs,
423
+ planUpJobs,
424
+ };