@alexify/migronaut 2.1.0 → 2.3.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 (71) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/README.md +68 -10
  3. package/bullmq.d.ts +465 -7
  4. package/index.d.ts +1272 -18
  5. package/migronaut.schema.json +150 -2
  6. package/package.json +8 -2
  7. package/src/bullmq/background-processor.js +469 -0
  8. package/src/bullmq/index.js +12 -0
  9. package/src/bullmq/jobs.js +254 -7
  10. package/src/bullmq/processor.js +153 -15
  11. package/src/bullmq/producer.js +202 -27
  12. package/src/bullmq/service.js +480 -45
  13. package/src/cli/commands/background.js +500 -0
  14. package/src/cli/commands/converge.js +38 -10
  15. package/src/cli/commands/create.js +6 -0
  16. package/src/cli/exit-codes.js +6 -0
  17. package/src/cli/index.js +2 -0
  18. package/src/cli/table.js +68 -9
  19. package/src/core/audit.js +98 -3
  20. package/src/core/background-audit.js +139 -0
  21. package/src/core/background-drift.js +126 -0
  22. package/src/core/background-dry-run.js +366 -0
  23. package/src/core/background-engine.js +818 -0
  24. package/src/core/background-kit.js +425 -0
  25. package/src/core/background-partition.js +298 -0
  26. package/src/core/background-runner.js +305 -0
  27. package/src/core/background-sandbox.js +701 -0
  28. package/src/core/background-shard.js +542 -0
  29. package/src/core/background-spec.js +597 -0
  30. package/src/core/background-store.js +951 -0
  31. package/src/core/background-throttle.js +269 -0
  32. package/src/core/background-watch-plan.js +164 -0
  33. package/src/core/background-watch-store.js +78 -0
  34. package/src/core/background-watch.js +605 -0
  35. package/src/core/background.js +1121 -0
  36. package/src/core/bson-peer.js +23 -0
  37. package/src/core/changelog.js +32 -0
  38. package/src/core/collections.js +125 -31
  39. package/src/core/config.js +133 -13
  40. package/src/core/converge-plan.js +343 -61
  41. package/src/core/converge-search-run.js +440 -0
  42. package/src/core/converge-search.js +404 -0
  43. package/src/core/converge.js +428 -183
  44. package/src/core/index-spec.js +27 -16
  45. package/src/core/lock.js +97 -32
  46. package/src/core/migrator.js +951 -26
  47. package/src/core/options.js +32 -1
  48. package/src/core/run.js +26 -12
  49. package/src/core/runner.js +1 -1
  50. package/src/core/search-index-spec.js +758 -0
  51. package/src/core/server-info.js +70 -0
  52. package/src/core/shard-info.js +76 -0
  53. package/src/core/versioning-spec.js +181 -0
  54. package/src/errors/index.js +97 -5
  55. package/src/index.js +16 -0
  56. package/src/utils/canonical.js +34 -1
  57. package/src/utils/error.js +11 -2
  58. package/src/utils/loader.js +77 -9
  59. package/src/utils/migration-name.js +33 -1
  60. package/src/utils/telemetry.js +125 -1
  61. package/src/utils/template.js +69 -1
  62. package/src/versioning/config.js +155 -0
  63. package/src/versioning/document.js +326 -0
  64. package/src/versioning/index.js +50 -0
  65. package/src/versioning/internal.js +279 -0
  66. package/src/versioning/mongoose.js +151 -0
  67. package/src/versioning/occ.js +318 -0
  68. package/src/versioning/registry.js +187 -0
  69. package/src/versioning/upcaster.js +213 -0
  70. package/versioning.d.ts +666 -0
  71. package/versioning.js +1 -0
@@ -0,0 +1,597 @@
1
+ const crypto = require('node:crypto');
2
+ const {
3
+ BackgroundConflictError,
4
+ ConfigInvalidError,
5
+ MigrationInvalidExportError,
6
+ } = require('../errors/index.js');
7
+ const { canonical, isPlainObject, toWire, unsendable } = require('../utils/canonical.js');
8
+ const { isCollectionName } = require('../utils/collection-name.js');
9
+ const { fieldNameIssue } = require('../versioning/config.js');
10
+ const { resolveFieldNames } = require('../versioning/document.js');
11
+ const { filterTouches } = require('../versioning/internal.js');
12
+
13
+ /**
14
+ * Background migrations, declared: what a migration file's
15
+ * `export const background = {…}` may say, validated strictly and resolved
16
+ * with every default — plus the pure tables the engine runs on (the state
17
+ * transitions, the BSON brackets of `_id`, the scope filters). No database,
18
+ * no clock: every rule here is unit-tested as data.
19
+ */
20
+
21
+ // ─── Settings ─────────────────────────────────────────────────────────────────
22
+
23
+ const DEFAULTS = Object.freeze({
24
+ batchSize: 500,
25
+ transactionBatchSize: 100,
26
+ pauseMs: 100,
27
+ sliceMs: 30_000,
28
+ writeConcern: Object.freeze({ w: 'majority' }),
29
+ maxDocumentErrors: 0,
30
+ maxPasses: 10,
31
+ maxConflictRetries: 3,
32
+ maxSliceFailures: 3,
33
+ maxReplicationLagMs: 10_000,
34
+ maxParallel: 1,
35
+ shardConcurrency: 1,
36
+ overPartition: 4,
37
+ maxPartitions: 256,
38
+ transactionTimeoutMs: 10_000,
39
+ transactionMaxRetries: 5,
40
+ targetLatencyMs: 500,
41
+ minBatchSize: 10,
42
+ maxPauseMs: 30_000,
43
+ });
44
+
45
+ /**
46
+ * The most documents a background migration may fail and still complete —
47
+ * as many as its state keeps the ids of (they are left out of later passes
48
+ * and of the final count, so a budget past them could never be told).
49
+ */
50
+ const MAX_BAD_IDS = 1000;
51
+
52
+ /** Integer settings: `[key, min, max]` */
53
+ const INTEGER_SETTINGS = [
54
+ ['batchSize', 1, 10_000],
55
+ ['pauseMs', 0, 3_600_000],
56
+ ['sliceMs', 1_000, 3_600_000],
57
+ ['maxDocumentErrors', 0, MAX_BAD_IDS],
58
+ ['maxPasses', 1, 1_000],
59
+ ['maxConflictRetries', 0, 100],
60
+ ['maxSliceFailures', 1, 100],
61
+ ['maxParallel', 1, 64],
62
+ ['shardConcurrency', 1, 64],
63
+ ];
64
+
65
+ /** The collection `create --background` leaves for the author to fill in */
66
+ const SCAFFOLD_PLACEHOLDER = 'TODO';
67
+
68
+ /** Operators that run JavaScript on the server */
69
+ const SERVER_JS = new Set(['$where', '$function', '$accumulator']);
70
+
71
+ /** Whether a filter runs JavaScript on the server, anywhere in it ($expr included) */
72
+ function usesServerJs(value) {
73
+ if (Array.isArray(value)) {
74
+ for (const item of value) if (usesServerJs(item)) return true;
75
+ return false;
76
+ }
77
+ if (!isPlainObject(value)) return false;
78
+ for (const [key, item] of Object.entries(value)) {
79
+ if (SERVER_JS.has(key) || usesServerJs(item)) return true;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /** The longest slice — for a caller that overrides the spec's (a runner, the CLI, a test) too */
85
+ const [, , MAX_SLICE_MS] = INTEGER_SETTINGS.find(([key]) => key === 'sliceMs');
86
+
87
+ /**
88
+ * A caller's `sliceMs`: a positive integer up to the spec's maximum. A slice
89
+ * always works one batch before it looks at its deadline, so a short one
90
+ * still makes progress; 0, a negative number or NaN would not.
91
+ *
92
+ * @throws {ConfigInvalidError} when it is not an integer in range
93
+ */
94
+ function assertSliceMs(sliceMs) {
95
+ if (!Number.isSafeInteger(sliceMs) || sliceMs < 1 || sliceMs > MAX_SLICE_MS) {
96
+ throw new ConfigInvalidError(`sliceMs must be an integer from 1 to ${MAX_SLICE_MS}`, {
97
+ sliceMs,
98
+ });
99
+ }
100
+ }
101
+
102
+ const PARTITION_SETTINGS = [
103
+ ['overPartition', 1, 64],
104
+ ['maxPartitions', 1, 4_096],
105
+ ['minPartitionDocs', 1, Number.MAX_SAFE_INTEGER],
106
+ ['sampleSize', 1, 100_000],
107
+ ];
108
+
109
+ const TRANSACTION_SETTINGS = [
110
+ ['timeoutMs', 1, 50_000],
111
+ ['maxRetries', 0, 20],
112
+ ];
113
+
114
+ const ADAPTIVE_SETTINGS = [
115
+ ['targetLatencyMs', 1, 600_000],
116
+ ['minBatchSize', 1, 10_000],
117
+ ['maxBatchSize', 1, 10_000],
118
+ ['maxPauseMs', 0, 3_600_000],
119
+ ];
120
+
121
+ const SETTING_KEYS = [
122
+ 'description',
123
+ ...INTEGER_SETTINGS.map(([key]) => key),
124
+ 'writeConcern',
125
+ 'maxReplicationLagMs',
126
+ 'throttle',
127
+ 'partitions',
128
+ 'transaction',
129
+ 'adaptive',
130
+ ];
131
+
132
+ const DECLARATIVE_KEYS = new Set([
133
+ ...SETTING_KEYS,
134
+ 'collection',
135
+ 'from',
136
+ 'to',
137
+ 'filter',
138
+ 'migrate',
139
+ 'migrateBatch',
140
+ 'revert',
141
+ 'revertBatch',
142
+ 'versionField',
143
+ 'revisionField',
144
+ 'occ',
145
+ ]);
146
+
147
+ const STEP_KEYS = new Set([...SETTING_KEYS, 'collection', 'step', 'revertStep']);
148
+
149
+ /** Keys a later release may give meaning to — refused now, not silently ignored */
150
+ const RESERVED_KEYS = new Map([
151
+ ['partitioner', 'is not supported yet — partitions follow _id (or the shard key) automatically'],
152
+ ]);
153
+
154
+ const FUNCTION_KEYS = [
155
+ 'migrate',
156
+ 'migrateBatch',
157
+ 'revert',
158
+ 'revertBatch',
159
+ 'step',
160
+ 'revertStep',
161
+ 'throttle',
162
+ ];
163
+
164
+ const OCC_MODES = new Set(['revision', 'version-only']);
165
+
166
+ /** A checkpoint a step returns is stored on the partition — kept small */
167
+ const MAX_CHECKPOINT_BYTES = 64 * 1024;
168
+
169
+ const inRange = (value, min, max) => Number.isSafeInteger(value) && value >= min && value <= max;
170
+
171
+ function rangeIssues(value, table, base, report) {
172
+ for (const [key, min, max] of table) {
173
+ if (value[key] !== undefined && !inRange(value[key], min, max)) {
174
+ report(`${base}${key}`, `must be an integer from ${min} to ${max}`);
175
+ }
176
+ }
177
+ }
178
+
179
+ function objectSettingIssues(value, key, table, report) {
180
+ if (value === undefined || typeof value === 'boolean') return;
181
+ if (!isPlainObject(value)) {
182
+ report(key, 'must be a boolean or an object');
183
+ return;
184
+ }
185
+ const known = new Set(table.map(([name]) => name));
186
+ for (const name of Object.keys(value)) {
187
+ if (!known.has(name)) report(`${key}.${name}`, `is not one of the ${key} settings`);
188
+ }
189
+ rangeIssues(value, table, `${key}.`, report);
190
+ }
191
+
192
+ function settingIssues(spec, report) {
193
+ rangeIssues(spec, INTEGER_SETTINGS, '', report);
194
+ if (spec.description !== undefined && typeof spec.description !== 'string') {
195
+ report('description', 'must be a string');
196
+ }
197
+ if (spec.writeConcern !== undefined && !isPlainObject(spec.writeConcern)) {
198
+ report('writeConcern', 'must be a write concern object ({ w, j, wtimeoutMS })');
199
+ } else if (spec.writeConcern?.w === 0) {
200
+ report('writeConcern', 'must be acknowledged — with w: 0 no conflict can be seen');
201
+ }
202
+ const lag = spec.maxReplicationLagMs;
203
+ if (lag !== undefined && lag !== false && !inRange(lag, 0, 3_600_000)) {
204
+ report('maxReplicationLagMs', 'must be false or an integer from 0 to 3600000');
205
+ }
206
+ if (spec.partitions !== undefined) {
207
+ if (!isPlainObject(spec.partitions)) {
208
+ report('partitions', 'must be an object');
209
+ } else {
210
+ const known = new Set(PARTITION_SETTINGS.map(([name]) => name));
211
+ for (const name of Object.keys(spec.partitions)) {
212
+ if (!known.has(name)) report(`partitions.${name}`, 'is not one of the partitions settings');
213
+ }
214
+ rangeIssues(spec.partitions, PARTITION_SETTINGS, 'partitions.', report);
215
+ }
216
+ }
217
+ objectSettingIssues(spec.transaction, 'transaction', TRANSACTION_SETTINGS, report);
218
+ objectSettingIssues(spec.adaptive, 'adaptive', ADAPTIVE_SETTINGS, report);
219
+ if (
220
+ isPlainObject(spec.adaptive) &&
221
+ Number.isSafeInteger(spec.adaptive.minBatchSize) &&
222
+ Number.isSafeInteger(spec.adaptive.maxBatchSize) &&
223
+ spec.adaptive.minBatchSize > spec.adaptive.maxBatchSize
224
+ ) {
225
+ report('adaptive.minBatchSize', 'must not exceed adaptive.maxBatchSize');
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Why a `background` export is not a valid background migration —
231
+ * `{ path, message }` issues, empty when it is. `versioning` is the declared
232
+ * collection's resolved versioning, when it has one.
233
+ */
234
+ function backgroundIssues(spec, { versioning } = {}) {
235
+ if (!isPlainObject(spec)) {
236
+ return [{ path: 'background', message: 'must be an object' }];
237
+ }
238
+ const issues = [];
239
+ const report = (key, message) => issues.push({ path: `background.${key}`, message });
240
+ const step = spec.step !== undefined;
241
+ const allowed = step ? STEP_KEYS : DECLARATIVE_KEYS;
242
+ for (const key of Object.keys(spec)) {
243
+ if (RESERVED_KEYS.has(key)) report(key, RESERVED_KEYS.get(key));
244
+ else if (!allowed.has(key)) {
245
+ report(
246
+ key,
247
+ step && DECLARATIVE_KEYS.has(key)
248
+ ? 'is not a step background migration key (step owns its own writes)'
249
+ : 'is not a background migration key',
250
+ );
251
+ }
252
+ }
253
+ for (const key of FUNCTION_KEYS) {
254
+ if (spec[key] !== undefined && typeof spec[key] !== 'function') {
255
+ report(key, 'must be a function');
256
+ }
257
+ }
258
+ settingIssues(spec, report);
259
+
260
+ if (step) {
261
+ if (spec.collection !== undefined && !isCollectionName(spec.collection)) {
262
+ report('collection', 'must be a valid collection name');
263
+ }
264
+ if (spec.maxParallel !== undefined && spec.maxParallel !== 1) {
265
+ report('maxParallel', 'must be 1 — a step background migration runs as one partition');
266
+ }
267
+ return issues;
268
+ }
269
+
270
+ if (spec.collection === undefined) {
271
+ report('collection', 'is required');
272
+ } else if (spec.collection === SCAFFOLD_PLACEHOLDER) {
273
+ // Registered as is, it would "complete" at once over an empty collection
274
+ // — and satisfy whatever requires it.
275
+ report('collection', `is still the scaffold's placeholder ('${SCAFFOLD_PLACEHOLDER}')`);
276
+ } else if (!isCollectionName(spec.collection)) {
277
+ report('collection', 'must be a valid collection name');
278
+ }
279
+ const fromValid = inRange(spec.from, 0, Number.MAX_SAFE_INTEGER);
280
+ const toValid = inRange(spec.to, 0, Number.MAX_SAFE_INTEGER);
281
+ if (spec.from === undefined) report('from', 'is required');
282
+ else if (!fromValid) report('from', 'must be an integer ≥ 0 (0: documents without a version)');
283
+ if (spec.to === undefined) report('to', 'is required');
284
+ else if (!toValid) report('to', 'must be an integer ≥ 0');
285
+ if (fromValid && toValid && spec.to <= spec.from) {
286
+ report('to', 'must be greater than from — the way back is revert');
287
+ }
288
+ if (spec.migrate === undefined && spec.migrateBatch === undefined) {
289
+ report('migrate', 'is required (or migrateBatch, or step)');
290
+ } else if (spec.migrate !== undefined && spec.migrateBatch !== undefined) {
291
+ report('migrateBatch', 'cannot be combined with migrate');
292
+ }
293
+ if (spec.revert !== undefined && spec.revertBatch !== undefined) {
294
+ report('revertBatch', 'cannot be combined with revert');
295
+ }
296
+ if (spec.occ !== undefined && !OCC_MODES.has(spec.occ)) {
297
+ report('occ', "must be 'revision' or 'version-only'");
298
+ }
299
+ for (const key of ['versionField', 'revisionField']) {
300
+ if (spec[key] === undefined) continue;
301
+ const issue = fieldNameIssue(spec[key]);
302
+ if (issue) report(key, issue);
303
+ }
304
+ const versionName = spec.versionField ?? versioning?.field ?? '__v';
305
+ if (spec.filter !== undefined) {
306
+ if (!isPlainObject(spec.filter)) {
307
+ report('filter', 'must be a query document');
308
+ } else {
309
+ const reason = unsendable(spec.filter);
310
+ if (reason) report('filter', reason);
311
+ else if (usesServerJs(spec.filter)) {
312
+ report(
313
+ 'filter',
314
+ 'must not run JavaScript on the server ($where, $function, $accumulator) — it ' +
315
+ 'cannot use an index, and many deployments turn it off',
316
+ );
317
+ } else if (filterTouches(spec.filter, versionName)) {
318
+ report('filter', `must not constrain "${versionName}" — from and to do`);
319
+ }
320
+ }
321
+ }
322
+ if (versioning) {
323
+ if (spec.versionField !== undefined && spec.versionField !== versioning.field) {
324
+ report(
325
+ 'versionField',
326
+ `differs from the collection's versioning.field ("${versioning.field}")`,
327
+ );
328
+ }
329
+ if (toValid && spec.to > versioning.current) {
330
+ report(
331
+ 'to',
332
+ `is past the collection's versioning.current (${versioning.current}) — raise current first`,
333
+ );
334
+ }
335
+ if (versioning.revisionField === null && spec.occ !== 'version-only') {
336
+ report(
337
+ 'occ',
338
+ "must be 'version-only' — the collection keeps no revision, so a concurrent write that " +
339
+ 'leaves the version alone would be lost',
340
+ );
341
+ }
342
+ }
343
+ if (spec.occ === 'version-only' && spec.revisionField !== undefined) {
344
+ report('revisionField', "has no effect with occ: 'version-only'");
345
+ }
346
+ return issues;
347
+ }
348
+
349
+ const settingOr = (value, fallback) => (value === undefined ? fallback : value);
350
+
351
+ /**
352
+ * A valid `background` export resolved with every default — split into what
353
+ * is stored (`spec`: plain data, the state document's summary) and what only
354
+ * this process holds (`fns`: the functions).
355
+ *
356
+ * @throws {MigrationInvalidExportError} with every issue in `context.issues`
357
+ */
358
+ function resolveBackgroundSpec(raw, { name, versioning } = {}) {
359
+ const issues = backgroundIssues(raw, { versioning });
360
+ if (issues.length > 0) {
361
+ throw new MigrationInvalidExportError(
362
+ `Invalid background migration${name ? ` ${name}` : ''}: ${issues[0].path} ${issues[0].message}`,
363
+ { ...(name ? { name } : {}), issues },
364
+ );
365
+ }
366
+ const step = raw.step !== undefined;
367
+ const transaction =
368
+ raw.transaction === true || isPlainObject(raw.transaction)
369
+ ? {
370
+ timeoutMs: raw.transaction?.timeoutMs ?? DEFAULTS.transactionTimeoutMs,
371
+ maxRetries: raw.transaction?.maxRetries ?? DEFAULTS.transactionMaxRetries,
372
+ }
373
+ : false;
374
+ const batchSize =
375
+ raw.batchSize ?? (transaction ? DEFAULTS.transactionBatchSize : DEFAULTS.batchSize);
376
+ const maxParallel = step ? 1 : (raw.maxParallel ?? DEFAULTS.maxParallel);
377
+ const adaptive =
378
+ raw.adaptive === false
379
+ ? false
380
+ : {
381
+ targetLatencyMs: raw.adaptive?.targetLatencyMs ?? DEFAULTS.targetLatencyMs,
382
+ minBatchSize: Math.min(raw.adaptive?.minBatchSize ?? DEFAULTS.minBatchSize, batchSize),
383
+ // Never above what the author chose.
384
+ maxBatchSize: Math.min(raw.adaptive?.maxBatchSize ?? batchSize, batchSize),
385
+ maxPauseMs: raw.adaptive?.maxPauseMs ?? DEFAULTS.maxPauseMs,
386
+ };
387
+ const overPartition = raw.partitions?.overPartition ?? DEFAULTS.overPartition;
388
+ const maxPartitions = raw.partitions?.maxPartitions ?? DEFAULTS.maxPartitions;
389
+ const settings = {
390
+ batchSize,
391
+ pauseMs: raw.pauseMs ?? DEFAULTS.pauseMs,
392
+ sliceMs: raw.sliceMs ?? DEFAULTS.sliceMs,
393
+ writeConcern: toWire(raw.writeConcern ?? DEFAULTS.writeConcern),
394
+ maxDocumentErrors: raw.maxDocumentErrors ?? DEFAULTS.maxDocumentErrors,
395
+ maxPasses: raw.maxPasses ?? DEFAULTS.maxPasses,
396
+ maxConflictRetries: raw.maxConflictRetries ?? DEFAULTS.maxConflictRetries,
397
+ maxSliceFailures: raw.maxSliceFailures ?? DEFAULTS.maxSliceFailures,
398
+ maxReplicationLagMs: settingOr(raw.maxReplicationLagMs, DEFAULTS.maxReplicationLagMs),
399
+ maxParallel,
400
+ shardConcurrency: raw.shardConcurrency ?? DEFAULTS.shardConcurrency,
401
+ partitions: {
402
+ overPartition,
403
+ maxPartitions,
404
+ minPartitionDocs: raw.partitions?.minPartitionDocs ?? 4 * batchSize,
405
+ sampleSize:
406
+ raw.partitions?.sampleSize ??
407
+ Math.min(10_000, 100 * Math.min(maxPartitions, overPartition * maxParallel)),
408
+ },
409
+ transaction,
410
+ adaptive,
411
+ };
412
+ const fns = {};
413
+ for (const key of FUNCTION_KEYS) if (typeof raw[key] === 'function') fns[key] = raw[key];
414
+
415
+ if (step) {
416
+ return {
417
+ spec: {
418
+ mode: 'step',
419
+ ...(raw.collection !== undefined ? { collection: raw.collection } : {}),
420
+ reversible: typeof raw.revertStep === 'function',
421
+ ...(raw.description !== undefined ? { description: raw.description } : {}),
422
+ ...settings,
423
+ },
424
+ fns,
425
+ };
426
+ }
427
+ const occ = raw.occ ?? 'revision';
428
+ const names = resolveFieldNames({
429
+ field: raw.versionField ?? versioning?.field,
430
+ revisionField: raw.revisionField ?? versioning?.revisionField ?? undefined,
431
+ revision: occ === 'revision',
432
+ });
433
+ return {
434
+ spec: {
435
+ mode: 'declarative',
436
+ collection: raw.collection,
437
+ from: raw.from,
438
+ to: raw.to,
439
+ filter: raw.filter === undefined ? {} : toWire(raw.filter),
440
+ field: names.field,
441
+ revisionField: names.revisionField,
442
+ occ,
443
+ batched: typeof raw.migrateBatch === 'function',
444
+ reversible: typeof raw.revert === 'function' || typeof raw.revertBatch === 'function',
445
+ ...(raw.description !== undefined ? { description: raw.description } : {}),
446
+ ...settings,
447
+ },
448
+ fns,
449
+ };
450
+ }
451
+
452
+ /**
453
+ * What a background migration's documents are, as one string: a change here
454
+ * (another version, filter, field or direction) invalidates every partition
455
+ * already planned — a pass over the old match would be over the wrong set.
456
+ */
457
+ function matchHash(spec, direction = 'forward') {
458
+ const identity =
459
+ spec.mode === 'step'
460
+ ? { mode: 'step', direction }
461
+ : {
462
+ collection: spec.collection,
463
+ from: spec.from,
464
+ to: spec.to,
465
+ filter: canonical(spec.filter),
466
+ field: spec.field,
467
+ revisionField: spec.revisionField,
468
+ direction,
469
+ };
470
+ return crypto.createHash('sha256').update(JSON.stringify(identity)).digest('hex').slice(0, 32);
471
+ }
472
+
473
+ // ─── State transitions ────────────────────────────────────────────────────────
474
+
475
+ const STATUSES = Object.freeze([
476
+ 'blocked',
477
+ 'pending',
478
+ 'running',
479
+ 'paused',
480
+ 'completed',
481
+ 'failed',
482
+ 'cancelled',
483
+ ]);
484
+
485
+ /** Done: nothing will change it without a deliberate retry or reopen */
486
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
487
+
488
+ /**
489
+ * Every control action: the statuses it applies to, what it moves them to,
490
+ * and the statuses in which it is already done (`unchanged`, not an error —
491
+ * a redelivered job or a second click must be harmless). Anything else is a
492
+ * conflict.
493
+ */
494
+ const TRANSITIONS = Object.freeze({
495
+ unblock: { from: ['blocked'], to: 'pending', done: ['pending', 'running', 'paused'] },
496
+ plan: { from: ['pending'], to: 'running', done: ['running'] },
497
+ complete: { from: ['running'], to: 'completed', done: ['completed'] },
498
+ fail: { from: ['blocked', 'pending', 'running'], to: 'failed', done: ['failed'] },
499
+ pause: { from: ['blocked', 'pending', 'running'], to: 'paused', done: ['paused'] },
500
+ resume: { from: ['paused'], to: 'pending', done: ['blocked', 'pending', 'running'] },
501
+ cancel: {
502
+ from: ['blocked', 'pending', 'running', 'paused'],
503
+ to: 'cancelled',
504
+ done: ['cancelled'],
505
+ },
506
+ retry: { from: ['failed', 'cancelled'], to: 'pending', done: ['pending'] },
507
+ reopen: { from: ['completed'], to: 'running', done: ['running'] },
508
+ });
509
+
510
+ /**
511
+ * Where `action` takes a background migration in `status`:
512
+ * `{ to, applied: 'changed' | 'unchanged' }`.
513
+ *
514
+ * @throws {BackgroundConflictError} when the action does not fit the status
515
+ */
516
+ function transition(status, action, { migration } = {}) {
517
+ const rule = TRANSITIONS[action];
518
+ if (rule === undefined) {
519
+ throw new BackgroundConflictError(`Unknown background action "${action}"`, { action });
520
+ }
521
+ if (rule.from.includes(status)) return { to: rule.to, applied: 'changed' };
522
+ if (rule.done.includes(status)) return { to: status, applied: 'unchanged' };
523
+ throw new BackgroundConflictError(
524
+ `Cannot ${action} ${migration ? `background migration ${migration}` : 'it'}: it is ${status}`,
525
+ { action, status, ...(migration ? { migration } : {}) },
526
+ );
527
+ }
528
+
529
+ // ─── _id brackets and partition scopes ────────────────────────────────────────
530
+
531
+ /**
532
+ * The BSON comparison brackets an `_id` can fall in, in server sort order.
533
+ * `$gt`/`$lt` compare only within one bracket, so a range never spans two:
534
+ * each partition lives in one, filtered by `$type`. Only the brackets whose
535
+ * values sort the same way in a sample and on the server are split into
536
+ * several partitions — never `object` (JavaScript reorders integer-like keys)
537
+ * and never the mixed `exotic` one.
538
+ */
539
+ const ID_BRACKETS = Object.freeze([
540
+ { name: 'minKey', aliases: ['minKey'], splittable: false },
541
+ { name: 'null', aliases: ['null'], splittable: false },
542
+ { name: 'number', aliases: ['int', 'long', 'double', 'decimal'], splittable: true },
543
+ { name: 'string', aliases: ['string', 'symbol'], splittable: true },
544
+ { name: 'object', aliases: ['object'], splittable: false },
545
+ { name: 'binData', aliases: ['binData'], splittable: true },
546
+ { name: 'objectId', aliases: ['objectId'], splittable: true },
547
+ { name: 'bool', aliases: ['bool'], splittable: false },
548
+ { name: 'date', aliases: ['date'], splittable: true },
549
+ { name: 'timestamp', aliases: ['timestamp'], splittable: true },
550
+ {
551
+ name: 'exotic',
552
+ aliases: ['regex', 'dbPointer', 'javascript', 'javascriptWithScope'],
553
+ splittable: false,
554
+ },
555
+ { name: 'maxKey', aliases: ['maxKey'], splittable: false },
556
+ ]);
557
+
558
+ const BRACKETS_BY_NAME = new Map(ID_BRACKETS.map((bracket) => [bracket.name, bracket]));
559
+
560
+ /** `$type` names (as `$type` reports them) → their bracket */
561
+ const BRACKET_OF_TYPE = new Map();
562
+ for (const bracket of ID_BRACKETS) {
563
+ for (const alias of bracket.aliases) BRACKET_OF_TYPE.set(alias, bracket.name);
564
+ }
565
+
566
+ /** The bracket of a `$type` name — `exotic` for anything this table does not list */
567
+ const bracketOfType = (type) => BRACKET_OF_TYPE.get(type) ?? 'exotic';
568
+
569
+ /** The filter that keeps a scan inside a partition's scope */
570
+ function scopeFilter(scope) {
571
+ if (scope.kind !== 'id-range') return {};
572
+ const condition = { $type: BRACKETS_BY_NAME.get(scope.bracket).aliases };
573
+ if (scope.gte !== undefined) condition.$gte = scope.gte;
574
+ if (scope.lt !== undefined) condition.$lt = scope.lt;
575
+ return { _id: condition };
576
+ }
577
+
578
+ /** Resume a keyset scan after `lastId` — within one bracket, `$gt` alone is enough */
579
+ const keysetFilter = (lastId) => (lastId === undefined ? {} : { _id: { $gt: lastId } });
580
+
581
+ module.exports = {
582
+ BACKGROUND_DEFAULTS: DEFAULTS,
583
+ ID_BRACKETS,
584
+ MAX_BAD_IDS,
585
+ MAX_CHECKPOINT_BYTES,
586
+ STATUSES,
587
+ TERMINAL,
588
+ TRANSITIONS,
589
+ assertSliceMs,
590
+ backgroundIssues,
591
+ bracketOfType,
592
+ keysetFilter,
593
+ matchHash,
594
+ resolveBackgroundSpec,
595
+ scopeFilter,
596
+ transition,
597
+ };