@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,653 @@
1
+ const { MigratorKit } = require('../core/migrator.js');
2
+ const { ConfigInvalidError, MigronautError } = require('../errors/index.js');
3
+ const { errorText } = require('../utils/error.js');
4
+ const { isBareFilename } = require('../utils/migration-name.js');
5
+ const { redactDeep, redactOutbound } = require('../utils/redact.js');
6
+ const {
7
+ DEFAULT_CONVERGE_SCHEDULER_ID,
8
+ DEFAULT_QUEUE_NAME,
9
+ DEFAULT_SCHEDULER_ID,
10
+ JOB_NAMES,
11
+ buildConvergeJobTemplate,
12
+ permissionsNeeded,
13
+ resolveAllow,
14
+ buildSyncJobTemplate,
15
+ isPlainObject,
16
+ } = require('./jobs.js');
17
+ const { createMigrationProcessor, resolveProcessorOptions } = require('./processor.js');
18
+ const { assertJobOptions, enqueueConverge, enqueueDown, enqueueUp } = require('./producer.js');
19
+
20
+ /**
21
+ * Twice BullMQ's default job lock: its renewal (every half) then survives a
22
+ * migration that keeps the event loop busy for a while, instead of the job
23
+ * being declared stalled and handed to another worker mid-run.
24
+ */
25
+ const DEFAULT_LOCK_DURATION_MS = 60_000;
26
+ /** One stall is a crashed worker; a second on the same job is a pattern — fail it */
27
+ const DEFAULT_MAX_STALLED_COUNT = 1;
28
+ /** The shortest interval `schedule({ every })` accepts */
29
+ const MIN_SCHEDULE_EVERY_MS = 1000;
30
+
31
+ const isClass = (value) => typeof value === 'function';
32
+
33
+ /** A short string field of a job read back from Redis, or undefined — for log fields only */
34
+ function shortString(value) {
35
+ return typeof value === 'string' && value.length > 0 && value.length <= 255 ? value : undefined;
36
+ }
37
+
38
+ /** What a failed job's log line can say about it, from the job and from the error */
39
+ function failedJobFields(job, error) {
40
+ const data = job?.data ?? {};
41
+ const fields = {
42
+ ...(job?.id !== undefined ? { jobId: String(job.id) } : {}),
43
+ groupId: shortString(data.groupId),
44
+ migration: shortString(data.migration),
45
+ direction: shortString(data.direction),
46
+ runId: shortString(error?.context?.runId),
47
+ ...(error instanceof MigronautError ? { code: error.code } : {}),
48
+ };
49
+ for (const key of Object.keys(fields)) if (fields[key] === undefined) delete fields[key];
50
+ return fields;
51
+ }
52
+
53
+ function assertName(value, name) {
54
+ // BullMQ builds its Redis keys by joining with `:` and rejects it in names.
55
+ if (typeof value !== 'string' || value.length === 0 || value.includes(':')) {
56
+ throw new ConfigInvalidError(`${name} must be a non-empty string without ':'`, {
57
+ [name]: value,
58
+ });
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Migrations as a queue: one database's migrations, enqueued as one BullMQ job
64
+ * each and applied by a single-concurrency worker.
65
+ *
66
+ * BullMQ itself is never imported — the classes (or ready instances) come in
67
+ * through `options.bullmq`, the same way a Mongoose instance or a pino logger
68
+ * does. Whatever this object constructed, it closes; whatever was handed to it
69
+ * (a Queue instance, the Redis connection, a kit, its MongoClient) stays the
70
+ * caller's to close.
71
+ */
72
+ class MigrationQueue {
73
+ #kit;
74
+ #ownsKit;
75
+ #queue;
76
+ #ownsQueue;
77
+ #WorkerClass;
78
+ #worker;
79
+ #workerStarting;
80
+ #queueEventsSource;
81
+ #queueEvents;
82
+ #ownsQueueEvents = false;
83
+ #processor;
84
+ #connection;
85
+ #queueName;
86
+ #prefix;
87
+ #jobOptions;
88
+ #workerOptions;
89
+ #telemetry;
90
+ #globalConcurrency;
91
+ #allow;
92
+ #closing;
93
+
94
+ constructor(options) {
95
+ if (!isPlainObject(options)) {
96
+ throw new ConfigInvalidError('createMigrationQueue options must be an object');
97
+ }
98
+ const {
99
+ config,
100
+ kit,
101
+ kitOptions,
102
+ bullmq,
103
+ connection,
104
+ queueName,
105
+ prefix,
106
+ jobOptions,
107
+ workerOptions = {},
108
+ globalConcurrency = true,
109
+ lockWait,
110
+ allow,
111
+ } = options;
112
+
113
+ if (!isPlainObject(bullmq)) {
114
+ throw new ConfigInvalidError(
115
+ 'bullmq is required — pass { Queue, Worker, QueueEvents } from your own bullmq install',
116
+ );
117
+ }
118
+ const { Queue, Worker, QueueEvents, telemetry } = bullmq;
119
+ // BullMQ's own telemetry object (`new BullMQOtel(…)`), handed to the Queue
120
+ // and the Worker untouched — it is what carries a trace from the process
121
+ // that enqueues to the one that applies. Nothing here looks inside it.
122
+ if (telemetry !== undefined && (typeof telemetry !== 'object' || telemetry === null)) {
123
+ throw new ConfigInvalidError(
124
+ 'bullmq.telemetry must be a BullMQ telemetry object — e.g. new BullMQOtel(…)',
125
+ { telemetry: typeof telemetry },
126
+ );
127
+ }
128
+ const queueIsInstance = isPlainObject(Queue) && typeof Queue.addBulk === 'function';
129
+ if (!isClass(Queue) && !queueIsInstance) {
130
+ throw new ConfigInvalidError('bullmq.Queue must be the Queue class or a Queue instance');
131
+ }
132
+ if (Worker !== undefined && !isClass(Worker)) {
133
+ throw new ConfigInvalidError('bullmq.Worker must be the Worker class');
134
+ }
135
+ const eventsIsInstance = isPlainObject(QueueEvents) && typeof QueueEvents.on === 'function';
136
+ if (QueueEvents !== undefined && !isClass(QueueEvents) && !eventsIsInstance) {
137
+ throw new ConfigInvalidError(
138
+ 'bullmq.QueueEvents must be the QueueEvents class or a QueueEvents instance',
139
+ );
140
+ }
141
+ // Anything this object has to construct needs somewhere to connect to.
142
+ const constructs = isClass(Queue) || Worker !== undefined || isClass(QueueEvents);
143
+ if (constructs && connection == null) {
144
+ throw new ConfigInvalidError(
145
+ 'connection is required — the BullMQ connection options or your Redis client',
146
+ );
147
+ }
148
+
149
+ const resolvedName =
150
+ queueName ?? (queueIsInstance ? Queue.name : undefined) ?? DEFAULT_QUEUE_NAME;
151
+ assertName(resolvedName, 'queueName');
152
+ // An injected Queue already says where its keys live. A Worker or
153
+ // QueueEvents built here on another name or prefix would listen to an
154
+ // empty queue: jobs that never run, a wait() that never returns.
155
+ const resolvedPrefix = prefix ?? (queueIsInstance ? Queue.opts?.prefix : undefined);
156
+ if (resolvedPrefix !== undefined) assertName(resolvedPrefix, 'prefix');
157
+ if (queueIsInstance) {
158
+ MigrationQueue.#assertSameQueue('Queue', Queue, resolvedName, resolvedPrefix);
159
+ }
160
+ if (eventsIsInstance) {
161
+ MigrationQueue.#assertSameQueue('QueueEvents', QueueEvents, resolvedName, resolvedPrefix);
162
+ }
163
+ assertJobOptions(jobOptions);
164
+ if (!isPlainObject(workerOptions)) {
165
+ throw new ConfigInvalidError('workerOptions must be an object');
166
+ }
167
+ MigrationQueue.#assertConcurrency(workerOptions.concurrency);
168
+ if (typeof globalConcurrency !== 'boolean') {
169
+ throw new ConfigInvalidError('globalConcurrency must be a boolean', { globalConcurrency });
170
+ }
171
+
172
+ this.#connection = connection;
173
+ this.#queueName = resolvedName;
174
+ this.#prefix = resolvedPrefix;
175
+ this.#jobOptions = jobOptions;
176
+ this.#workerOptions = workerOptions;
177
+ this.#telemetry = telemetry;
178
+ this.#globalConcurrency = globalConcurrency;
179
+ this.#WorkerClass = Worker;
180
+ this.#queueEventsSource = QueueEvents;
181
+ if (eventsIsInstance) this.#queueEvents = QueueEvents;
182
+
183
+ // Everything is validated before the first thing is constructed: a Queue
184
+ // opens a Redis connection, and a constructor that throws after that would
185
+ // leave it open with nobody holding a reference to close it.
186
+ resolveProcessorOptions({
187
+ ...(kit !== undefined ? { kit } : {}),
188
+ ...(config !== undefined ? { config } : {}),
189
+ ...(lockWait !== undefined ? { lockWait } : {}),
190
+ ...(allow !== undefined ? { allow } : {}),
191
+ });
192
+ this.#allow = resolveAllow(allow);
193
+
194
+ this.#ownsKit = kit === undefined;
195
+ this.#kit = kit ?? new MigratorKit(config ?? {}, kitOptions);
196
+ this.#ownsQueue = !queueIsInstance;
197
+ this.#queue = queueIsInstance
198
+ ? Queue
199
+ : new Queue(resolvedName, {
200
+ connection,
201
+ ...(resolvedPrefix !== undefined ? { prefix: resolvedPrefix } : {}),
202
+ ...(telemetry !== undefined ? { telemetry } : {}),
203
+ });
204
+ this.#listen(this.#queue, 'error', (error) =>
205
+ this.#kit.logger.error(`✖ Migration queue error: ${errorText(error)}`, {
206
+ queue: resolvedName,
207
+ error: errorText(error),
208
+ }),
209
+ );
210
+ this.#processor = createMigrationProcessor({
211
+ kit: this.#kit,
212
+ // `sync` jobs (and the converge jobs they add) enqueue into the queue
213
+ // they arrived on.
214
+ queue: this.#queue,
215
+ ...(lockWait !== undefined ? { lockWait } : {}),
216
+ ...(jobOptions !== undefined ? { jobOptions } : {}),
217
+ ...(allow !== undefined ? { allow } : {}),
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Refuse, at the enqueue call, a request this object's own policy would
223
+ * refuse on the worker — a job that can only fail is better not added. The
224
+ * same `allow` belongs on every process that enqueues and every worker.
225
+ */
226
+ #assertPermitted(request) {
227
+ for (const permission of permissionsNeeded(request)) {
228
+ if (!this.#allow[permission]) {
229
+ throw new ConfigInvalidError(
230
+ `${permission === 'unordered' ? 'ordered: false' : permission} is not allowed by this ` +
231
+ `queue (allow.${permission})`,
232
+ { permission },
233
+ );
234
+ }
235
+ }
236
+ }
237
+
238
+ /** An injected instance must be on the queue this object is configured for */
239
+ static #assertSameQueue(label, instance, name, prefix) {
240
+ if (typeof instance.name === 'string' && instance.name !== name) {
241
+ throw new ConfigInvalidError(
242
+ `bullmq.${label} is on queue "${instance.name}", not "${name}"`,
243
+ {
244
+ queueName: name,
245
+ },
246
+ );
247
+ }
248
+ const instancePrefix = instance.opts?.prefix;
249
+ if (instancePrefix !== undefined && prefix !== undefined && instancePrefix !== prefix) {
250
+ throw new ConfigInvalidError(
251
+ `bullmq.${label} uses prefix "${instancePrefix}", not "${prefix}"`,
252
+ { prefix },
253
+ );
254
+ }
255
+ }
256
+
257
+ static #assertConcurrency(concurrency) {
258
+ if (concurrency !== undefined && concurrency !== 1) {
259
+ throw new ConfigInvalidError(
260
+ 'Worker concurrency must be 1 — migrations run one at a time, in order',
261
+ { concurrency },
262
+ );
263
+ }
264
+ }
265
+
266
+ /** Subscribe if the object is an emitter — an unlistened `error` event throws */
267
+ #listen(emitter, event, listener) {
268
+ if (typeof emitter?.on === 'function') emitter.on(event, listener);
269
+ }
270
+
271
+ #assertOpen() {
272
+ if (this.#closing) {
273
+ throw new ConfigInvalidError('This migration queue is closed');
274
+ }
275
+ }
276
+
277
+ get kit() {
278
+ return this.#kit;
279
+ }
280
+
281
+ get queue() {
282
+ return this.#queue;
283
+ }
284
+
285
+ /** The worker started by {@link startWorker}, if any */
286
+ get worker() {
287
+ return this.#worker;
288
+ }
289
+
290
+ /** The QueueEvents in use — an injected instance, or the one built on first `wait()` */
291
+ get queueEvents() {
292
+ return this.#queueEvents;
293
+ }
294
+
295
+ get queueName() {
296
+ return this.#queueName;
297
+ }
298
+
299
+ /** The function a Worker runs — for attaching to a Worker you construct yourself */
300
+ get processor() {
301
+ return this.#processor;
302
+ }
303
+
304
+ #ensureQueueEvents() {
305
+ if (this.#queueEvents) return this.#queueEvents;
306
+ // A connection opened after close() would have nobody to close it.
307
+ this.#assertOpen();
308
+ const QueueEvents = this.#queueEventsSource;
309
+ if (!isClass(QueueEvents)) return undefined;
310
+ this.#queueEvents = new QueueEvents(this.#queueName, {
311
+ connection: this.#connection,
312
+ ...(this.#prefix !== undefined ? { prefix: this.#prefix } : {}),
313
+ });
314
+ this.#ownsQueueEvents = true;
315
+ this.#listen(this.#queueEvents, 'error', (error) =>
316
+ this.#kit.logger.error(`✖ Migration queue events error: ${errorText(error)}`, {
317
+ queue: this.#queueName,
318
+ error: errorText(error),
319
+ }),
320
+ );
321
+ return this.#queueEvents;
322
+ }
323
+
324
+ #internals() {
325
+ return { getQueueEvents: () => this.#ensureQueueEvents() };
326
+ }
327
+
328
+ /**
329
+ * Enqueue pending migrations — all of them, up to `options.to`, or the one
330
+ * `filename` — as one job each, under a single shared batch.
331
+ */
332
+ async enqueueUp(filename, options = {}) {
333
+ this.#assertOpen();
334
+ this.#assertPermitted({
335
+ kind: 'migration',
336
+ direction: JOB_NAMES.UP,
337
+ force: options?.force === true,
338
+ ordered: options?.ordered,
339
+ });
340
+ return enqueueUp(
341
+ this.#queue,
342
+ this.#kit,
343
+ { ...options, filename, jobOptions: this.#jobOptions },
344
+ this.#internals(),
345
+ );
346
+ }
347
+
348
+ /**
349
+ * Enqueue a rollback — the last batch, `options.batch`, the last
350
+ * `options.steps`, everything after `options.to`, or the one `filename` —
351
+ * newest applied first.
352
+ */
353
+ async enqueueDown(filename, options = {}) {
354
+ this.#assertOpen();
355
+ this.#assertPermitted({
356
+ kind: 'migration',
357
+ direction: JOB_NAMES.DOWN,
358
+ ordered: options?.ordered,
359
+ });
360
+ return enqueueDown(
361
+ this.#queue,
362
+ this.#kit,
363
+ { ...options, filename, jobOptions: this.#jobOptions },
364
+ this.#internals(),
365
+ );
366
+ }
367
+
368
+ /**
369
+ * Enqueue a converge job: the declared collections brought to their
370
+ * declared state by the worker, under the MongoDB lock. By default it
371
+ * refuses while a migration is still pending (`ordered: false` lifts that).
372
+ */
373
+ async enqueueConverge(options = {}) {
374
+ this.#assertOpen();
375
+ this.#assertPermitted({ kind: 'converge', ordered: options?.ordered });
376
+ return enqueueConverge(
377
+ this.#queue,
378
+ this.#kit,
379
+ { ...options, jobOptions: this.#jobOptions },
380
+ this.#internals(),
381
+ );
382
+ }
383
+
384
+ /** Full migration status — read straight from MongoDB, not from the queue */
385
+ async status() {
386
+ this.#assertOpen();
387
+ return this.#kit.status();
388
+ }
389
+
390
+ /** Migrations not applied yet */
391
+ async pending() {
392
+ this.#assertOpen();
393
+ return this.#kit.list('pending');
394
+ }
395
+
396
+ async audit() {
397
+ this.#assertOpen();
398
+ return this.#kit.audit();
399
+ }
400
+
401
+ /** The current holder of the MongoDB migration lock, or null */
402
+ async lockInfo() {
403
+ this.#assertOpen();
404
+ return this.#kit.lockInfo();
405
+ }
406
+
407
+ /**
408
+ * Start the worker that applies the jobs. Needs `bullmq.Worker`. Connects to
409
+ * MongoDB first, so an unreachable database fails here. Concurrency is
410
+ * always 1, and where BullMQ supports it the queue's *global* concurrency
411
+ * is set to 1 too, so several pods running this take turns instead of each
412
+ * picking a job and queuing on the MongoDB lock. Calling it again returns
413
+ * the same worker.
414
+ */
415
+ async startWorker(overrides = {}) {
416
+ this.#assertOpen();
417
+ if (!isPlainObject(overrides)) {
418
+ throw new ConfigInvalidError('startWorker options must be an object');
419
+ }
420
+ MigrationQueue.#assertConcurrency(overrides.concurrency);
421
+ if (!this.#WorkerClass) {
422
+ throw new ConfigInvalidError(
423
+ 'startWorker() needs the Worker class — pass bullmq: { Queue, Worker }',
424
+ );
425
+ }
426
+ // A failed start is not cached: a database or Redis that was briefly
427
+ // unreachable at boot must not leave this object unable to ever start.
428
+ this.#workerStarting ??= this.#startWorker(overrides).catch((error) => {
429
+ this.#workerStarting = undefined;
430
+ throw error;
431
+ });
432
+ return this.#workerStarting;
433
+ }
434
+
435
+ async #startWorker(overrides) {
436
+ // Connect first: a worker that cannot reach MongoDB should fail at boot,
437
+ // not on its first job — and until the config is resolved the kit's logger
438
+ // is only provisional, so the listeners below would ignore `logger: null`.
439
+ await this.#kit.connect();
440
+ const queue = this.#queue;
441
+ if (this.#globalConcurrency && typeof queue.setGlobalConcurrency === 'function') {
442
+ await queue.setGlobalConcurrency(1);
443
+ }
444
+ // close() may have begun while this was connecting: a Worker built now
445
+ // would fetch a job after shutdown, with nobody left to close it.
446
+ this.#assertOpen();
447
+ const Worker = this.#WorkerClass;
448
+ const worker = new Worker(this.#queueName, this.#processor, {
449
+ connection: this.#connection,
450
+ ...(this.#prefix !== undefined ? { prefix: this.#prefix } : {}),
451
+ lockDuration: DEFAULT_LOCK_DURATION_MS,
452
+ maxStalledCount: DEFAULT_MAX_STALLED_COUNT,
453
+ // Before the worker options, so a `telemetry` given there (or to this
454
+ // call) still wins for the worker alone.
455
+ ...(this.#telemetry !== undefined ? { telemetry: this.#telemetry } : {}),
456
+ ...this.#workerOptions,
457
+ ...overrides,
458
+ concurrency: 1,
459
+ });
460
+ this.#worker = worker;
461
+ const fields = { queue: this.#queueName };
462
+ this.#listen(worker, 'error', (error) =>
463
+ this.#kit.logger.error(`✖ Migration worker error: ${errorText(error)}`, {
464
+ ...fields,
465
+ error: errorText(error),
466
+ }),
467
+ );
468
+ this.#listen(worker, 'failed', (job, error) =>
469
+ this.#kit.logger.warn(
470
+ `✖ Migration job failed${job?.id !== undefined ? ` (${job.id})` : ''}: ${errorText(error)}`,
471
+ { ...fields, ...failedJobFields(job, error), error: errorText(error) },
472
+ ),
473
+ );
474
+ this.#listen(worker, 'stalled', (jobId) =>
475
+ this.#kit.logger.warn(`⚠ Migration job stalled (${jobId}) — it will be re-run`, {
476
+ ...fields,
477
+ jobId: String(jobId),
478
+ }),
479
+ );
480
+ await worker.waitUntilReady?.();
481
+ return worker;
482
+ }
483
+
484
+ /** Stop workers from picking up new jobs. The job in flight finishes */
485
+ async pause() {
486
+ this.#assertOpen();
487
+ await this.#queue.pause();
488
+ }
489
+
490
+ async resume() {
491
+ this.#assertOpen();
492
+ await this.#queue.resume();
493
+ }
494
+
495
+ /**
496
+ * A job as plain, redacted data — safe to hand to an HTTP response — or
497
+ * null. For the live BullMQ Job, use `queue.getJob(id)`.
498
+ */
499
+ async getJob(id) {
500
+ this.#assertOpen();
501
+ if (typeof id !== 'string' || id.length === 0) {
502
+ throw new ConfigInvalidError('Job id must be a non-empty string', { id });
503
+ }
504
+ let job = await this.#queue.getJob(id);
505
+ if (!job) return null;
506
+ const state = typeof job.getState === 'function' ? await job.getState() : 'unknown';
507
+ // The job and its state are two reads: one that finished in between would
508
+ // read as finished with no outcome (no returnvalue or failedReason, the
509
+ // attempt not counted). A finished job no longer changes, so read it again.
510
+ if (state === 'completed' || state === 'failed') job = (await this.#queue.getJob(id)) ?? job;
511
+ return redactDeep({
512
+ id: String(job.id),
513
+ name: job.name,
514
+ data: job.data,
515
+ state,
516
+ progress: job.progress,
517
+ ...(job.returnvalue != null ? { returnvalue: job.returnvalue } : {}),
518
+ ...(job.failedReason ? { failedReason: redactOutbound(job.failedReason) } : {}),
519
+ attemptsMade: job.attemptsMade ?? 0,
520
+ ...(job.timestamp !== undefined ? { timestamp: job.timestamp } : {}),
521
+ ...(job.processedOn !== undefined ? { processedOn: job.processedOn } : {}),
522
+ ...(job.finishedOn !== undefined ? { finishedOn: job.finishedOn } : {}),
523
+ });
524
+ }
525
+
526
+ /**
527
+ * Keep the database migrated on a schedule: every tick enqueues a `sync`
528
+ * job, which plans whatever is pending and enqueues it — or, with
529
+ * `job: 'converge'`, a converge job, on a cadence of its own (index builds
530
+ * often belong at night, not on every sync). Idempotent — safe to call from
531
+ * every instance at boot.
532
+ */
533
+ async schedule(options = {}) {
534
+ this.#assertOpen();
535
+ if (!isPlainObject(options)) {
536
+ throw new ConfigInvalidError('schedule options must be an object');
537
+ }
538
+ const { job = JOB_NAMES.SYNC, every, pattern, tz, to } = options;
539
+ if (job !== JOB_NAMES.SYNC && job !== JOB_NAMES.CONVERGE) {
540
+ throw new ConfigInvalidError("schedule job must be 'sync' or 'converge'", { job });
541
+ }
542
+ const converge = job === JOB_NAMES.CONVERGE;
543
+ const { id = converge ? DEFAULT_CONVERGE_SCHEDULER_ID : DEFAULT_SCHEDULER_ID } = options;
544
+ assertName(id, 'id');
545
+ if (converge && to !== undefined) {
546
+ throw new ConfigInvalidError('to only applies to a sync schedule', { to });
547
+ }
548
+ if ((every === undefined) === (pattern === undefined)) {
549
+ throw new ConfigInvalidError(
550
+ 'schedule needs exactly one of `every` (ms) or `pattern` (cron)',
551
+ );
552
+ }
553
+ // A tick is a job, a Redis round trip and a changelog read: a schedule
554
+ // faster than once a second is a typo, not a cadence.
555
+ if (every !== undefined && (!Number.isFinite(every) || every < MIN_SCHEDULE_EVERY_MS)) {
556
+ throw new ConfigInvalidError(`every must be at least ${MIN_SCHEDULE_EVERY_MS} milliseconds`, {
557
+ every,
558
+ });
559
+ }
560
+ if (pattern !== undefined && (typeof pattern !== 'string' || pattern.length === 0)) {
561
+ throw new ConfigInvalidError('pattern must be a cron expression', { pattern });
562
+ }
563
+ if (tz !== undefined && (typeof tz !== 'string' || tz.length === 0)) {
564
+ throw new ConfigInvalidError('tz must be a time zone name', { tz });
565
+ }
566
+ if (to !== undefined && !isBareFilename(to)) {
567
+ throw new ConfigInvalidError('to must be a migration filename', { to });
568
+ }
569
+ if (typeof this.#queue.upsertJobScheduler !== 'function') {
570
+ throw new ConfigInvalidError(
571
+ 'schedule() needs job schedulers (queue.upsertJobScheduler) — BullMQ 5.16 or newer',
572
+ );
573
+ }
574
+ await this.#queue.upsertJobScheduler(
575
+ id,
576
+ { ...(every !== undefined ? { every } : { pattern }), ...(tz !== undefined ? { tz } : {}) },
577
+ converge
578
+ ? buildConvergeJobTemplate({ jobOptions: this.#jobOptions })
579
+ : buildSyncJobTemplate({ to, jobOptions: this.#jobOptions }),
580
+ );
581
+ }
582
+
583
+ /**
584
+ * Remove a schedule — the sync one by default; pass
585
+ * `DEFAULT_CONVERGE_SCHEDULER_ID` (or your own id) for another. Resolves
586
+ * whether one existed.
587
+ */
588
+ async unschedule(id = DEFAULT_SCHEDULER_ID) {
589
+ this.#assertOpen();
590
+ assertName(id, 'id');
591
+ if (typeof this.#queue.removeJobScheduler !== 'function') {
592
+ throw new ConfigInvalidError(
593
+ 'unschedule() needs job schedulers (queue.removeJobScheduler) — BullMQ 5.16 or newer',
594
+ );
595
+ }
596
+ return Boolean(await this.#queue.removeJobScheduler(id));
597
+ }
598
+
599
+ /**
600
+ * Shut down in dependency order: stop taking the lock, let the worker finish
601
+ * its job (`force` skips that wait), then close what this object created and
602
+ * disconnect a kit it created. Idempotent; every step is attempted, and the
603
+ * first failure is rethrown once they all have been.
604
+ */
605
+ async close(options = {}) {
606
+ this.#closing ??= this.#close(options?.force === true);
607
+ return this.#closing;
608
+ }
609
+
610
+ async #close(force) {
611
+ const failures = [];
612
+ const attempt = async (step) => {
613
+ try {
614
+ await step();
615
+ } catch (error) {
616
+ failures.push(error);
617
+ }
618
+ };
619
+ // The worker stops fetching first: a job the shutdown below puts back in
620
+ // the queue must go to the next worker, not straight back to this one.
621
+ const worker = this.#worker;
622
+ const workerClosed = worker ? attempt(() => worker.close(force)) : undefined;
623
+ this.#processor.shutdown('Migration queue closing');
624
+ await workerClosed;
625
+ // A worker still starting is closed too, not orphaned — and a start that
626
+ // failed is that call's failure, not this one's.
627
+ await this.#workerStarting?.catch(() => undefined);
628
+ if (this.#worker && this.#worker !== worker) {
629
+ await attempt(() => this.#worker.close(force));
630
+ }
631
+ if (this.#queueEvents && this.#ownsQueueEvents) await attempt(() => this.#queueEvents.close());
632
+ if (this.#ownsQueue) await attempt(() => this.#queue.close());
633
+ if (force) {
634
+ // The migration in flight is not waited for — but it keeps its
635
+ // connection until it ends: a kit this object created is disconnected
636
+ // only once the processor has settled.
637
+ this.#processor
638
+ .close()
639
+ .then(() => (this.#ownsKit ? this.#kit.disconnect() : undefined))
640
+ .catch(() => undefined);
641
+ } else {
642
+ await attempt(() => this.#processor.close());
643
+ if (this.#ownsKit) await attempt(() => this.#kit.disconnect());
644
+ }
645
+ if (failures.length > 0) throw failures[0];
646
+ }
647
+ }
648
+
649
+ function createMigrationQueue(options) {
650
+ return new MigrationQueue(options);
651
+ }
652
+
653
+ module.exports = { MigrationQueue, createMigrationQueue };