@alexify/migronaut 2.2.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 (64) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +33 -2
  3. package/bullmq.d.ts +449 -6
  4. package/index.d.ts +1010 -9
  5. package/migronaut.schema.json +93 -1
  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 +128 -14
  11. package/src/bullmq/producer.js +185 -13
  12. package/src/bullmq/service.js +480 -45
  13. package/src/cli/commands/background.js +500 -0
  14. package/src/cli/commands/create.js +6 -0
  15. package/src/cli/exit-codes.js +6 -0
  16. package/src/cli/index.js +2 -0
  17. package/src/core/audit.js +11 -1
  18. package/src/core/background-audit.js +139 -0
  19. package/src/core/background-drift.js +126 -0
  20. package/src/core/background-dry-run.js +366 -0
  21. package/src/core/background-engine.js +818 -0
  22. package/src/core/background-kit.js +425 -0
  23. package/src/core/background-partition.js +298 -0
  24. package/src/core/background-runner.js +305 -0
  25. package/src/core/background-sandbox.js +701 -0
  26. package/src/core/background-shard.js +542 -0
  27. package/src/core/background-spec.js +597 -0
  28. package/src/core/background-store.js +951 -0
  29. package/src/core/background-throttle.js +269 -0
  30. package/src/core/background-watch-plan.js +164 -0
  31. package/src/core/background-watch-store.js +78 -0
  32. package/src/core/background-watch.js +605 -0
  33. package/src/core/background.js +1121 -0
  34. package/src/core/bson-peer.js +23 -0
  35. package/src/core/changelog.js +32 -0
  36. package/src/core/collections.js +78 -8
  37. package/src/core/config.js +102 -12
  38. package/src/core/converge-plan.js +86 -7
  39. package/src/core/converge.js +88 -0
  40. package/src/core/lock.js +48 -21
  41. package/src/core/migrator.js +904 -12
  42. package/src/core/options.js +16 -0
  43. package/src/core/run.js +26 -12
  44. package/src/core/runner.js +1 -1
  45. package/src/core/server-info.js +9 -2
  46. package/src/core/shard-info.js +76 -0
  47. package/src/core/versioning-spec.js +181 -0
  48. package/src/errors/index.js +88 -0
  49. package/src/index.js +16 -0
  50. package/src/utils/error.js +11 -2
  51. package/src/utils/loader.js +77 -9
  52. package/src/utils/migration-name.js +33 -1
  53. package/src/utils/telemetry.js +107 -0
  54. package/src/utils/template.js +62 -1
  55. package/src/versioning/config.js +155 -0
  56. package/src/versioning/document.js +326 -0
  57. package/src/versioning/index.js +50 -0
  58. package/src/versioning/internal.js +279 -0
  59. package/src/versioning/mongoose.js +151 -0
  60. package/src/versioning/occ.js +318 -0
  61. package/src/versioning/registry.js +187 -0
  62. package/src/versioning/upcaster.js +213 -0
  63. package/versioning.d.ts +666 -0
  64. package/versioning.js +1 -0
@@ -29,4 +29,36 @@ function assertMigrationName(name, context = {}) {
29
29
  }
30
30
  }
31
31
 
32
- module.exports = { assertMigrationName, isBareFilename };
32
+ /**
33
+ * Why a file's `requires` export is not valid: an array of bare migration
34
+ * file names, no duplicates, each sorting strictly before the file itself
35
+ * (`name`). Files run in name order, so an edge that only ever points
36
+ * backwards can never close a cycle — the whole "is it a DAG?" question,
37
+ * answered by the name. Returns `{ path, message }` issues.
38
+ */
39
+ function requiresIssues(requires, name) {
40
+ if (requires === undefined) return [];
41
+ if (!Array.isArray(requires)) {
42
+ return [{ path: 'requires', message: 'must be an array of migration file names' }];
43
+ }
44
+ const issues = [];
45
+ const seen = new Set();
46
+ for (const [position, required] of requires.entries()) {
47
+ const path = `requires[${position}]`;
48
+ if (!isBareFilename(required)) {
49
+ issues.push({ path, message: 'must be a bare migration file name' });
50
+ } else if (seen.has(required)) {
51
+ issues.push({ path, message: `names "${required}" twice` });
52
+ } else if (name !== undefined && required >= name) {
53
+ issues.push({
54
+ path,
55
+ message: `must name an earlier migration ("${required}" does not sort before "${name}")`,
56
+ });
57
+ } else {
58
+ seen.add(required);
59
+ }
60
+ }
61
+ return issues;
62
+ }
63
+
64
+ module.exports = { assertMigrationName, isBareFilename, requiresIssues };
@@ -13,6 +13,8 @@ const SPAN_STATUS_ERROR = 2;
13
13
  const SPANS = {
14
14
  RUN: 'migronaut.run',
15
15
  MIGRATION: 'migronaut.migration',
16
+ BACKGROUND_SLICE: 'migronaut.background.slice',
17
+ BACKGROUND_COORDINATE: 'migronaut.background.coordinate',
16
18
  };
17
19
 
18
20
  /** Every attribute key migronaut sets, on spans and on metric points */
@@ -35,6 +37,11 @@ const ATTRIBUTES = {
35
37
  MIGRATION_INDEX: 'migronaut.migration.index',
36
38
  MIGRATION_TOTAL: 'migronaut.migration.total',
37
39
  MIGRATION_TRANSACTION: 'migronaut.migration.transaction',
40
+ BACKGROUND_NAME: 'migronaut.background.name',
41
+ BACKGROUND_OUTCOME: 'migronaut.background.outcome',
42
+ BACKGROUND_RESULT: 'migronaut.background.result',
43
+ BACKGROUND_REASON: 'migronaut.background.reason',
44
+ BACKGROUND_SHARD: 'migronaut.background.shard',
38
45
  ERROR_TYPE: 'error.type',
39
46
  /** The database a run is against — OpenTelemetry's database semantic convention */
40
47
  DB_NAMESPACE: 'db.namespace',
@@ -48,6 +55,14 @@ const METRICS = {
48
55
  LOCK_REFUSED: 'migronaut.lock.refused',
49
56
  LOCK_LOST: 'migronaut.lock.lost',
50
57
  SEARCH_WAIT_DURATION: 'migronaut.converge.search.wait.duration',
58
+ BACKGROUND_DOCUMENTS: 'migronaut.background.documents',
59
+ BACKGROUND_SLICE_DURATION: 'migronaut.background.slice.duration',
60
+ BACKGROUND_BATCH_WRITE_DURATION: 'migronaut.background.batch.write.duration',
61
+ BACKGROUND_THROTTLE: 'migronaut.background.throttled',
62
+ BACKGROUND_DRIFT: 'migronaut.background.drift.detected',
63
+ BACKGROUND_TRANSACTION_RETRIES: 'migronaut.background.transaction.retried',
64
+ BACKGROUND_LEASES_RECLAIMED: 'migronaut.background.leases.reclaimed',
65
+ BACKGROUND_WATCH_DELAY: 'migronaut.background.watch.delay',
51
66
  };
52
67
 
53
68
  /**
@@ -339,6 +354,47 @@ function createTelemetry(telemetry, { dbName } = {}) {
339
354
  'Time a converge waited for its search index builds, by how the wait ended',
340
355
  );
341
356
 
357
+ const backgroundDocuments = counter(
358
+ METRICS.BACKGROUND_DOCUMENTS,
359
+ 'Documents a background migration handled, by result (migrated, skipped, conflict, failed)',
360
+ '{document}',
361
+ );
362
+ const backgroundSliceDuration = histogram(
363
+ METRICS.BACKGROUND_SLICE_DURATION,
364
+ 'Duration of one background migration slice, by outcome',
365
+ );
366
+ const backgroundBatchWrite = histogram(
367
+ METRICS.BACKGROUND_BATCH_WRITE_DURATION,
368
+ 'Time to write one background migration batch',
369
+ );
370
+ const backgroundThrottle = counter(
371
+ METRICS.BACKGROUND_THROTTLE,
372
+ 'Adaptive throttle changes of background migrations, by reason',
373
+ '{change}',
374
+ );
375
+ const backgroundDrift = counter(
376
+ METRICS.BACKGROUND_DRIFT,
377
+ 'Old-shape documents found after a background migration completed',
378
+ '{finding}',
379
+ );
380
+ const backgroundTransactionRetries = counter(
381
+ METRICS.BACKGROUND_TRANSACTION_RETRIES,
382
+ 'Transactional background batches retried, by reason',
383
+ '{retry}',
384
+ );
385
+ const backgroundLeasesReclaimed = counter(
386
+ METRICS.BACKGROUND_LEASES_RECLAIMED,
387
+ 'Partition leases reclaimed from a lane that stopped renewing',
388
+ '{lease}',
389
+ );
390
+ const backgroundWatchDelay = histogram(
391
+ METRICS.BACKGROUND_WATCH_DELAY,
392
+ 'Time from an old-shape write to its upgrade by the live drift watcher',
393
+ );
394
+ const add = (instrument, value, attributes) => {
395
+ if (instrument && value > 0) safe(() => instrument.add(value, withBase(attributes)));
396
+ };
397
+
342
398
  // Durations are measured in milliseconds everywhere in migronaut and
343
399
  // reported in seconds, the unit OpenTelemetry's conventions settle on.
344
400
  const record = (instrument, durationMs, attributes) => {
@@ -391,6 +447,57 @@ function createTelemetry(telemetry, { dbName } = {}) {
391
447
  searchWaited({ waitedMs, outcome }) {
392
448
  record(searchWaitDuration, waitedMs, { [ATTRIBUTES.SEARCH_WAIT_OUTCOME]: outcome });
393
449
  },
450
+ /**
451
+ * A background migration slice ended: its duration by outcome, and the
452
+ * documents it handled by result. The partition is never an attribute —
453
+ * dimensions stay low-cardinality.
454
+ */
455
+ backgroundSliceEnded({ name, durationMs, outcome, counters = {}, error }) {
456
+ const at = { [ATTRIBUTES.BACKGROUND_NAME]: name };
457
+ record(backgroundSliceDuration, durationMs, {
458
+ ...at,
459
+ [ATTRIBUTES.BACKGROUND_OUTCOME]: outcome,
460
+ ...failure(error),
461
+ });
462
+ for (const [key, result] of [
463
+ ['migrated', 'migrated'],
464
+ ['skipped', 'skipped'],
465
+ ['conflicts', 'conflict'],
466
+ ['failed', 'failed'],
467
+ ]) {
468
+ add(backgroundDocuments, counters[key] ?? 0, {
469
+ ...at,
470
+ [ATTRIBUTES.BACKGROUND_RESULT]: result,
471
+ });
472
+ }
473
+ },
474
+ backgroundBatchWritten({ name, durationMs, shard }) {
475
+ record(backgroundBatchWrite, durationMs, {
476
+ [ATTRIBUTES.BACKGROUND_NAME]: name,
477
+ [ATTRIBUTES.BACKGROUND_SHARD]: shard,
478
+ });
479
+ },
480
+ backgroundThrottled({ name, reason }) {
481
+ add(backgroundThrottle, 1, {
482
+ [ATTRIBUTES.BACKGROUND_NAME]: name,
483
+ [ATTRIBUTES.BACKGROUND_REASON]: reason,
484
+ });
485
+ },
486
+ backgroundDrift({ name, count = 1 }) {
487
+ add(backgroundDrift, count, { [ATTRIBUTES.BACKGROUND_NAME]: name });
488
+ },
489
+ backgroundTransactionRetried({ name, reason, count = 1 }) {
490
+ add(backgroundTransactionRetries, count, {
491
+ [ATTRIBUTES.BACKGROUND_NAME]: name,
492
+ [ATTRIBUTES.BACKGROUND_REASON]: reason,
493
+ });
494
+ },
495
+ backgroundLeasesReclaimed({ name, count }) {
496
+ add(backgroundLeasesReclaimed, count, { [ATTRIBUTES.BACKGROUND_NAME]: name });
497
+ },
498
+ backgroundWatchDelay({ name, delayMs }) {
499
+ record(backgroundWatchDelay, delayMs, { [ATTRIBUTES.BACKGROUND_NAME]: name });
500
+ },
394
501
  };
395
502
  }
396
503
 
@@ -145,6 +145,51 @@ module.exports = { description, up, down };
145
145
  `;
146
146
  }
147
147
 
148
+ /**
149
+ * The built-in background migration template (`create --background`): the
150
+ * declarative form, with the knobs most worth knowing about spelled out.
151
+ */
152
+ function defaultBackgroundTemplate(js, esm = false) {
153
+ const typed = js
154
+ ? "/** @type {import('@alexify/migronaut').DeclarativeBackgroundMigration} */\n"
155
+ : '';
156
+ const typeImport = js
157
+ ? ''
158
+ : "import type { DeclarativeBackgroundMigration } from '@alexify/migronaut';\n\n";
159
+ const annotation = js ? '' : ': DeclarativeBackgroundMigration';
160
+ const body = `{
161
+ // The collection to rewrite (\`up\` refuses this placeholder).
162
+ collection: 'TODO',
163
+ // Documents at version \`from\` (0: no version field yet) become version \`to\`.
164
+ from: 1,
165
+ to: 2,
166
+ // The new document for one old one — return it reshaped; migronaut sets the
167
+ // version, bumps the revision and writes only the fields that changed.
168
+ migrate: (doc) => {
169
+ // TODO: reshape doc, and return it
170
+ throw new Error('migrate is not written yet');
171
+ },
172
+ // The way back, for \`down\`. Without one, \`down\` refuses once documents
173
+ // were rewritten. Never \`(doc) => doc\`: that would stamp the old version
174
+ // on documents still in the new shape.
175
+ // revert: ({ shipping, ...doc }) => ({ ...doc, address: shipping.address }),
176
+ // Partitions worked at once, across every process (default 1).
177
+ // maxParallel: 4,
178
+ }`;
179
+ if (esm) {
180
+ return `${typeImport}export const description = '';
181
+
182
+ ${typed}export const background${annotation} = ${body};
183
+ `;
184
+ }
185
+ return `${typeImport}const description = '';
186
+
187
+ ${typed}const background${annotation} = ${body};
188
+
189
+ module.exports = { description, background };
190
+ `;
191
+ }
192
+
148
193
  /** Extensions a custom `--template` file may have — anything else is refused */
149
194
  const TEMPLATE_EXTENSIONS = ['.ts', '.js', '.cjs', '.mjs'];
150
195
 
@@ -152,7 +197,8 @@ const TEMPLATE_EXTENSIONS = ['.ts', '.js', '.cjs', '.mjs'];
152
197
  const MAX_TEMPLATE_BYTES = 1024 * 1024;
153
198
 
154
199
  /** Resolve template file contents — a custom template if provided, else the built-in */
155
- async function resolveTemplateContent(templatePath, js, esm = false) {
200
+ async function resolveTemplateContent(templatePath, js, esm = false, background = false) {
201
+ if (background) return defaultBackgroundTemplate(js, esm);
156
202
  if (templatePath) {
157
203
  const ext = path.extname(templatePath);
158
204
  if (!TEMPLATE_EXTENSIONS.includes(ext)) {
@@ -198,6 +244,7 @@ async function createMigrationFile(options) {
198
244
  // Match the project's module system, so a generated migration never makes
199
245
  // Node reparse it and warn.
200
246
  await isEsmProject(options.dir),
247
+ options.background === true,
201
248
  );
202
249
  try {
203
250
  // 'wx' fails if the path exists — creating a migration must never silently
@@ -366,6 +413,20 @@ function configBody(values, createExtension) {
366
413
  // waitForSearchIndexes: false,
367
414
  // searchIndexWaitTimeoutMs: 600000,
368
415
 
416
+ // ── Background migrations (experimental) ────────────────────
417
+ // A migration file with \`export const background = {…}\` is registered by
418
+ // \`up\` and runs in partitions (BullMQ, \`migronaut background run\`, or an
419
+ // in-process runner) without holding the migration lock.
420
+ // backgroundCollection: '_migronaut_background',
421
+ // Run it to the end inside the \`up\` that registers it instead.
422
+ // backgroundInline: false,
423
+ // Old-shape documents after it completed: reopen it ('reopen') or report.
424
+ // backgroundOnDrift: 'reopen',
425
+ // How drift is watched: 'poll' (every 10 minutes), 'stream', or 'both'.
426
+ // backgroundDrift: 'poll',
427
+ // Partition a sharded collection by its shard key ('auto') or not ('off').
428
+ // backgroundShardAware: 'auto',
429
+
369
430
  // ── Lifecycle hooks (code only — not available in JSON config) ──
370
431
  // hooks: {
371
432
  // beforeAll: async (ctx) => {},
@@ -0,0 +1,155 @@
1
+ const { ConfigInvalidError } = require('../errors/index.js');
2
+ const { isPlainObject } = require('./internal.js');
3
+
4
+ /**
5
+ * The `versioning` block of a collection definition — one source of truth for
6
+ * the shape version a collection is at, read by converge (validator, index,
7
+ * the `min` guard), by the background-migration engine and by the
8
+ * application's repository layer through `defineShapes`.
9
+ *
10
+ * Validated strictly, like every other definition key: a typo (`currnet`)
11
+ * must not read as "not versioned".
12
+ */
13
+
14
+ const VERSIONING_KEYS = ['current', 'min', 'field', 'revision', 'revisionField', 'index'];
15
+ const VERSIONING_KEY_SET = new Set(VERSIONING_KEYS);
16
+
17
+ const VERSIONING_DEFAULTS = Object.freeze({
18
+ min: 1,
19
+ field: '__v',
20
+ revision: true,
21
+ revisionField: '__rev',
22
+ index: true,
23
+ });
24
+
25
+ /** Longest field name accepted for the version or revision field */
26
+ const MAX_FIELD_NAME = 64;
27
+
28
+ /**
29
+ * Why `name` cannot be a system field, or `null`. Top-level only: the engine
30
+ * writes it with `$set`/`$inc`, so a dot would address a nested path and a
31
+ * leading `$` an operator; `_id` is immutable.
32
+ */
33
+ function fieldNameIssue(name) {
34
+ if (typeof name !== 'string' || name.length === 0) return 'must be a non-empty string';
35
+ if (name.length > MAX_FIELD_NAME) return `must be at most ${MAX_FIELD_NAME} characters`;
36
+ if (name.startsWith('$')) return "must not start with '$'";
37
+ if (name.includes('.')) return "must be a top-level field (no '.')";
38
+ if (name.includes('\0')) return 'must not contain NUL';
39
+ if (name === '_id') return 'must not be _id';
40
+ // As a JavaScript key it names the prototype: every write of it would be lost.
41
+ if (name === '__proto__') return 'must not be __proto__';
42
+ return null;
43
+ }
44
+
45
+ const isCount = (value, floor) => Number.isSafeInteger(value) && value >= floor;
46
+
47
+ /**
48
+ * Validate a `versioning` value, returning `{ path, message }` issues (empty
49
+ * when valid). `path` is where it sits — `collections[2].versioning`,
50
+ * `orders.ts: versioning`.
51
+ */
52
+ function versioningIssues(value, path = 'versioning') {
53
+ if (!isPlainObject(value)) {
54
+ return [
55
+ {
56
+ path,
57
+ message: 'must be an object: { current, min?, field?, revision?, revisionField?, index? }',
58
+ },
59
+ ];
60
+ }
61
+ const issues = [];
62
+ const report = (key, message) => issues.push({ path: `${path}.${key}`, message });
63
+ for (const key of Object.keys(value)) {
64
+ if (!VERSIONING_KEY_SET.has(key)) {
65
+ report(key, `is not a versioning key (expected one of: ${VERSIONING_KEYS.join(', ')})`);
66
+ }
67
+ }
68
+ const { current, min, field, revision, revisionField, index } = value;
69
+ const currentValid = isCount(current, 1);
70
+ if (current === undefined) report('current', 'is required');
71
+ else if (!currentValid) report('current', 'must be an integer ≥ 1');
72
+ // The default min (1) never exceeds a valid current (≥ 1).
73
+ if (min !== undefined) {
74
+ if (!isCount(min, 0)) report('min', 'must be an integer ≥ 0');
75
+ else if (currentValid && min > current) {
76
+ report('min', `must not exceed current (${current})`);
77
+ }
78
+ }
79
+ if (field !== undefined) {
80
+ const issue = fieldNameIssue(field);
81
+ if (issue) report('field', issue);
82
+ }
83
+ if (revision !== undefined && typeof revision !== 'boolean') {
84
+ report('revision', 'must be a boolean');
85
+ }
86
+ if (revisionField !== undefined) {
87
+ const issue = fieldNameIssue(revisionField);
88
+ if (issue) report('revisionField', issue);
89
+ else if (revision === false) report('revisionField', 'has no effect with revision: false');
90
+ }
91
+ if (revision !== false) {
92
+ const versionName = field ?? VERSIONING_DEFAULTS.field;
93
+ const revisionName = revisionField ?? VERSIONING_DEFAULTS.revisionField;
94
+ if (versionName === revisionName) {
95
+ report('revisionField', `must differ from the version field ("${versionName}")`);
96
+ }
97
+ }
98
+ if (index !== undefined && typeof index !== 'boolean') report('index', 'must be a boolean');
99
+ return issues;
100
+ }
101
+
102
+ /**
103
+ * A validated `versioning` value with every default filled in, frozen.
104
+ * `revisionField` is `null` when revisions are off.
105
+ *
106
+ * @throws {ConfigInvalidError} with every issue in `context.issues`
107
+ */
108
+ function resolveVersioning(value, { path = 'versioning' } = {}) {
109
+ const issues = versioningIssues(value, path);
110
+ if (issues.length > 0) {
111
+ throw new ConfigInvalidError(`Invalid ${path}: ${issues[0].path} ${issues[0].message}`, {
112
+ issues,
113
+ });
114
+ }
115
+ const revision = value.revision ?? VERSIONING_DEFAULTS.revision;
116
+ return Object.freeze({
117
+ current: value.current,
118
+ min: value.min ?? VERSIONING_DEFAULTS.min,
119
+ field: value.field ?? VERSIONING_DEFAULTS.field,
120
+ revision,
121
+ revisionField: revision ? (value.revisionField ?? VERSIONING_DEFAULTS.revisionField) : null,
122
+ index: value.index ?? VERSIONING_DEFAULTS.index,
123
+ });
124
+ }
125
+
126
+ /**
127
+ * The resolved versioning of `collection` among `definitions` — an array of
128
+ * definitions (each with `name`) or a `{ name: definition }` map — or `null`
129
+ * when it is not declared or not versioned.
130
+ */
131
+ function versioningOf(definitions, collection) {
132
+ let definition;
133
+ if (Array.isArray(definitions)) {
134
+ for (const candidate of definitions) {
135
+ if (isPlainObject(candidate) && candidate.name === collection) {
136
+ definition = candidate;
137
+ break;
138
+ }
139
+ }
140
+ } else if (isPlainObject(definitions) && Object.hasOwn(definitions, collection)) {
141
+ definition = definitions[collection];
142
+ }
143
+ if (!isPlainObject(definition) || definition.versioning === undefined) return null;
144
+ return resolveVersioning(definition.versioning, { path: `${collection}.versioning` });
145
+ }
146
+
147
+ module.exports = {
148
+ MAX_FIELD_NAME,
149
+ VERSIONING_DEFAULTS,
150
+ VERSIONING_KEYS,
151
+ fieldNameIssue,
152
+ resolveVersioning,
153
+ versioningIssues,
154
+ versioningOf,
155
+ };