@alexify/migronaut 2.0.0 → 2.2.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 (54) hide show
  1. package/CHANGELOG.md +436 -0
  2. package/README.md +235 -6
  3. package/bullmq.d.ts +860 -0
  4. package/bullmq.js +1 -0
  5. package/index.d.ts +888 -19
  6. package/migronaut.schema.json +238 -1
  7. package/package.json +21 -5
  8. package/src/bullmq/index.js +55 -0
  9. package/src/bullmq/jobs.js +454 -0
  10. package/src/bullmq/processor.js +632 -0
  11. package/src/bullmq/producer.js +427 -0
  12. package/src/bullmq/service.js +653 -0
  13. package/src/bullmq/wait.js +124 -0
  14. package/src/cli/args.js +12 -2
  15. package/src/cli/commands/converge.js +188 -0
  16. package/src/cli/commands/down.js +2 -0
  17. package/src/cli/commands/lock.js +2 -1
  18. package/src/cli/commands/redo.js +8 -1
  19. package/src/cli/commands/up.js +14 -1
  20. package/src/cli/exit-codes.js +9 -2
  21. package/src/cli/index.js +2 -0
  22. package/src/cli/shared.js +14 -4
  23. package/src/cli/table.js +164 -0
  24. package/src/core/audit.js +88 -3
  25. package/src/core/changelog.js +71 -6
  26. package/src/core/collections.js +396 -0
  27. package/src/core/config.js +130 -25
  28. package/src/core/converge-log.js +47 -0
  29. package/src/core/converge-plan.js +686 -0
  30. package/src/core/converge-search-run.js +440 -0
  31. package/src/core/converge-search.js +404 -0
  32. package/src/core/converge.js +1024 -0
  33. package/src/core/index-spec.js +507 -0
  34. package/src/core/lock-wait.js +260 -0
  35. package/src/core/lock.js +95 -28
  36. package/src/core/migrator.js +600 -287
  37. package/src/core/options.js +266 -0
  38. package/src/core/run-recorder.js +157 -0
  39. package/src/core/run.js +58 -90
  40. package/src/core/search-index-spec.js +758 -0
  41. package/src/core/sequence.js +134 -0
  42. package/src/core/server-info.js +63 -0
  43. package/src/errors/index.js +60 -0
  44. package/src/index.js +8 -0
  45. package/src/utils/actor.js +48 -0
  46. package/src/utils/canonical.js +212 -0
  47. package/src/utils/collection-name.js +21 -0
  48. package/src/utils/error.js +18 -1
  49. package/src/utils/id.js +77 -0
  50. package/src/utils/loader.js +39 -21
  51. package/src/utils/migration-name.js +32 -0
  52. package/src/utils/redact.js +21 -1
  53. package/src/utils/telemetry.js +410 -0
  54. package/src/utils/template.js +43 -2
@@ -2,10 +2,16 @@ const fs = require('node:fs/promises');
2
2
  const path = require('node:path');
3
3
  const { pathToFileURL } = require('node:url');
4
4
  const { ConfigInvalidError } = require('../errors/index.js');
5
+ const { isCollectionName } = require('../utils/collection-name.js');
6
+ const { TELEMETRY_KEYS, telemetryIssues } = require('../utils/telemetry.js');
7
+
8
+ /** The keys a `telemetry` object may hold — a Set, read once per key */
9
+ const TELEMETRY_KEY_SET = new Set(TELEMETRY_KEYS);
5
10
  const { applyEnvFile } = require('../utils/env.js');
6
11
  const { errorText } = require('../utils/error.js');
7
12
  const { resolveLogger } = require('../utils/logger.js');
8
13
  const { redactDeep } = require('../utils/redact.js');
14
+ const { collectionsIssues } = require('./collections.js');
9
15
 
10
16
  /**
11
17
  * Default values applied when no flag, env var, or config-file value is
@@ -17,6 +23,7 @@ const DEFAULT_CONFIG = {
17
23
  migrationsDir: './migrations',
18
24
  migrationsCollection: '_migronaut_migrations',
19
25
  lockCollection: '_migronaut_locks',
26
+ convergeLogCollection: '_migronaut_converge',
20
27
  lockTTLSeconds: 60,
21
28
  strict: false,
22
29
  useTransaction: false,
@@ -27,6 +34,10 @@ const DEFAULT_CONFIG = {
27
34
  onLockLost: 'abort',
28
35
  onOutOfOrder: 'warn',
29
36
  reloadMigrations: false,
37
+ convergeAfterUp: false,
38
+ onSearchUnavailable: 'fail',
39
+ waitForSearchIndexes: false,
40
+ searchIndexWaitTimeoutMs: 600_000,
30
41
  };
31
42
 
32
43
  /** Candidate config file names, checked in priority order within the cwd */
@@ -37,20 +48,6 @@ const isBoolean = (value) => typeof value === 'boolean';
37
48
  const isPositiveInteger = (value) => Number.isInteger(value) && value > 0;
38
49
  const isExtension = (value) => value === 'ts' || value === 'js';
39
50
 
40
- /**
41
- * Collection names we accept for the changelog/lock collections and
42
- * `import --from/--to`: non-empty, no `$` or NUL (invalid server-side), and
43
- * outside the reserved `system.` namespace — so a flag can never point a
44
- * read or write at a system collection.
45
- */
46
- function isCollectionName(value) {
47
- return (
48
- isNonEmptyString(value) &&
49
- !value.includes('$') &&
50
- !value.includes('\0') &&
51
- !value.startsWith('system.')
52
- );
53
- }
54
51
  function isStringList(value) {
55
52
  if (!Array.isArray(value) || value.length === 0) return false;
56
53
  for (const item of value) {
@@ -62,8 +59,13 @@ function isStringList(value) {
62
59
  /**
63
60
  * Validation spec for every checked config key: predicate + failure message.
64
61
  * `mongoose`, `hooks`, `logger` and `client` are deliberately unchecked —
65
- * they hold live instances the validator has nothing to say about. Unknown
66
- * keys are allowed, matching the previous zod (non-strict object) behavior.
62
+ * they hold live instances the validator has nothing to say about.
63
+ * `generateId` and `telemetry` are code-only too, but each has exactly one
64
+ * valid shape, so validateConfig checks them on their own rather than through
65
+ * this table (which a test pins against the JSON schema — and a function or a
66
+ * tracer has no place there).
67
+ * Unknown keys are allowed, matching the previous zod (non-strict object)
68
+ * behavior.
67
69
  */
68
70
  const CONFIG_KEYS = [
69
71
  { path: 'uri', check: isNonEmptyString, message: 'uri is required' },
@@ -79,6 +81,11 @@ const CONFIG_KEYS = [
79
81
  check: isCollectionName,
80
82
  message: "must be a valid collection name (no '$'/NUL, not system.*)",
81
83
  },
84
+ {
85
+ path: 'convergeLogCollection',
86
+ check: isCollectionName,
87
+ message: "must be a valid collection name (no '$'/NUL, not system.*)",
88
+ },
82
89
  { path: 'lockTTLSeconds', check: isPositiveInteger, message: 'must be a positive integer' },
83
90
  { path: 'strict', check: isBoolean, message: 'must be a boolean' },
84
91
  { path: 'useTransaction', check: isBoolean, message: 'must be a boolean' },
@@ -133,15 +140,52 @@ const CONFIG_KEYS = [
133
140
  optional: true,
134
141
  },
135
142
  { path: 'reloadMigrations', check: isBoolean, message: 'must be a boolean', optional: true },
143
+ // Only the outer shape here — each definition is checked by
144
+ // collectionsIssues (core/collections.js), which reports nested paths
145
+ // (`collections[2].indexes[0].key`) instead of one opaque message.
146
+ {
147
+ path: 'collections',
148
+ check: Array.isArray,
149
+ message: 'must be an array of collection definitions',
150
+ optional: true,
151
+ },
152
+ {
153
+ path: 'collectionsDir',
154
+ check: isNonEmptyString,
155
+ message: 'must be a non-empty string',
156
+ optional: true,
157
+ },
158
+ { path: 'convergeAfterUp', check: isBoolean, message: 'must be a boolean', optional: true },
159
+ {
160
+ path: 'onSearchUnavailable',
161
+ check: (value) => value === 'fail' || value === 'skip',
162
+ message: "must be 'fail' or 'skip'",
163
+ optional: true,
164
+ },
165
+ { path: 'waitForSearchIndexes', check: isBoolean, message: 'must be a boolean', optional: true },
166
+ {
167
+ path: 'searchIndexWaitTimeoutMs',
168
+ check: isPositiveInteger,
169
+ message: 'must be a positive integer',
170
+ optional: true,
171
+ },
136
172
  ];
137
173
 
138
174
  /**
139
175
  * Every key the merged config legitimately carries: the validated ones plus
140
- * the deliberately-unchecked live instances. Used only to *mention* typos
141
- * (`migrationsDirectory`, `useTransactions`) at debug level — unknown keys
142
- * stay allowed, matching the documented non-strict contract.
176
+ * the code-only ones (the live instances, `generateId` and `telemetry`). Used
177
+ * only to *mention* typos (`migrationsDirectory`, `useTransactions`) at debug
178
+ * level — unknown keys stay allowed, matching the documented non-strict
179
+ * contract.
143
180
  */
144
- const KNOWN_CONFIG_KEYS = new Set(['logger', 'hooks', 'mongoose', 'client']);
181
+ const KNOWN_CONFIG_KEYS = new Set([
182
+ 'logger',
183
+ 'hooks',
184
+ 'mongoose',
185
+ 'client',
186
+ 'generateId',
187
+ 'telemetry',
188
+ ]);
145
189
  for (const spec of CONFIG_KEYS) KNOWN_CONFIG_KEYS.add(spec.path);
146
190
 
147
191
  /**
@@ -165,6 +209,34 @@ function validateConfig(config, options = {}) {
165
209
  }
166
210
  if (!spec.check(value)) issues.push({ path: spec.path, message: spec.message });
167
211
  }
212
+ // What it returns is checked on every call (utils/id.js); that it is callable
213
+ // at all is a config mistake — a JSON config's `"generateId": "ulid"` — and
214
+ // belongs with the others, before a run is started.
215
+ if (config.generateId !== undefined && typeof config.generateId !== 'function') {
216
+ issues.push({ path: 'generateId', message: 'must be a function' });
217
+ }
218
+ for (const issue of telemetryIssues(config.telemetry)) issues.push(issue);
219
+ // Inline definitions are checked with the rest of the config — they are
220
+ // pure data, so this costs nothing. Definition *files* are loaded only when
221
+ // a converge runs: importing them here would make one broken file block
222
+ // every command, an emergency `down` included.
223
+ // Three bookkeeping collections, three jobs: sharing one would mix records.
224
+ const bookkeeping = ['migrationsCollection', 'lockCollection', 'convergeLogCollection'];
225
+ for (const [position, key] of bookkeeping.entries()) {
226
+ for (const other of bookkeeping.slice(0, position)) {
227
+ if (config[key] !== undefined && config[key] === config[other]) {
228
+ issues.push({ path: key, message: `must differ from ${other}` });
229
+ }
230
+ }
231
+ }
232
+ if (Array.isArray(config.collections)) {
233
+ const reserved = [
234
+ config.migrationsCollection,
235
+ config.lockCollection,
236
+ config.convergeLogCollection,
237
+ ];
238
+ for (const issue of collectionsIssues(config.collections, { reserved })) issues.push(issue);
239
+ }
168
240
  return issues;
169
241
  }
170
242
 
@@ -242,8 +314,9 @@ const parseString = (value) => value;
242
314
  *
243
315
  * Every *scalar* config option has an entry here, which is what makes the
244
316
  * documented "a config file is never required" promise literally true. Options
245
- * holding non-scalars — `fileExtensions`, `clientOptions`, `client`, `mongoose`,
246
- * `hooks`, `logger` — are config-file/API only; an env var cannot express them.
317
+ * holding non-scalars — `fileExtensions`, `clientOptions`, `collections`,
318
+ * `client`, `mongoose`, `hooks`, `logger`, `generateId`, `telemetry` — are
319
+ * config-file/API only; an env var cannot express them.
247
320
  *
248
321
  * MIGRONAUT_ENV_FILE is deliberately absent: it selects which .env file to load,
249
322
  * so it has to be read before this table can run (see loadConfig).
@@ -254,6 +327,11 @@ const ENV_KEYS = [
254
327
  { env: 'MIGRONAUT_MIGRATIONS_DIR', path: 'migrationsDir', parse: parseString },
255
328
  { env: 'MIGRONAUT_COLLECTION', path: 'migrationsCollection', parse: parseString },
256
329
  { env: 'MIGRONAUT_LOCK_COLLECTION', path: 'lockCollection', parse: parseString },
330
+ {
331
+ env: 'MIGRONAUT_CONVERGE_LOG_COLLECTION',
332
+ path: 'convergeLogCollection',
333
+ parse: parseString,
334
+ },
257
335
  { env: 'MIGRONAUT_LOCK_TTL', path: 'lockTTLSeconds', parse: parsePositiveInteger },
258
336
  { env: 'MIGRONAUT_STRICT', path: 'strict', parse: parseBoolean },
259
337
  { env: 'MIGRONAUT_USE_TRANSACTION', path: 'useTransaction', parse: parseBoolean },
@@ -270,6 +348,19 @@ const ENV_KEYS = [
270
348
  },
271
349
  { env: 'MIGRONAUT_ENSURE_INDEXES', path: 'ensureIndexes', parse: parseBoolean },
272
350
  { env: 'MIGRONAUT_RELOAD_MIGRATIONS', path: 'reloadMigrations', parse: parseBoolean },
351
+ { env: 'MIGRONAUT_COLLECTIONS_DIR', path: 'collectionsDir', parse: parseString },
352
+ { env: 'MIGRONAUT_CONVERGE_AFTER_UP', path: 'convergeAfterUp', parse: parseBoolean },
353
+ {
354
+ env: 'MIGRONAUT_ON_SEARCH_UNAVAILABLE',
355
+ path: 'onSearchUnavailable',
356
+ parse: parseEnum(['fail', 'skip']),
357
+ },
358
+ { env: 'MIGRONAUT_WAIT_FOR_SEARCH_INDEXES', path: 'waitForSearchIndexes', parse: parseBoolean },
359
+ {
360
+ env: 'MIGRONAUT_SEARCH_INDEX_WAIT_TIMEOUT_MS',
361
+ path: 'searchIndexWaitTimeoutMs',
362
+ parse: parsePositiveInteger,
363
+ },
273
364
  ];
274
365
 
275
366
  /** Build a partial config from the MIGRONAUT_* environment variables */
@@ -458,6 +549,13 @@ async function loadConfig(options = {}) {
458
549
  for (const key in config) {
459
550
  if (!KNOWN_CONFIG_KEYS.has(key)) (unknown ??= []).push(key);
460
551
  }
552
+ // Same for `telemetry`: handing over the whole `@opentelemetry/api` module
553
+ // (`trace`, `metrics`) instead of a tracer and a meter turns it off silently.
554
+ if (config.telemetry) {
555
+ for (const key in config.telemetry) {
556
+ if (!TELEMETRY_KEY_SET.has(key)) (unknown ??= []).push(`telemetry.${key}`);
557
+ }
558
+ }
461
559
  if (unknown) {
462
560
  effectiveLogger(config.logger).debug(
463
561
  `Unrecognized config key(s), ignored: ${unknown.join(', ')}`,
@@ -470,18 +568,23 @@ async function loadConfig(options = {}) {
470
568
  }
471
569
 
472
570
  // "Which config did it actually pick up?" — the merged result, once, at
473
- // debug level. Live instances (client, mongoose, hooks, logger) are elided:
474
- // they are not serializable and redactDeep rightly refuses to clone them.
571
+ // debug level. Live instances (client, mongoose, hooks, logger, telemetry)
572
+ // and the generateId function are elided: they are not serializable and
573
+ // redactDeep rightly refuses to clone them. Declared collections show as
574
+ // names only — validators can run to hundreds of lines.
475
575
  {
476
- const { client, mongoose, hooks, logger, ...rest } = config;
576
+ const { client, mongoose, hooks, logger, generateId, telemetry, collections, ...rest } = config;
477
577
  effectiveLogger(config.logger).debug(
478
578
  `Resolved config (source: ${configFilePath ? path.basename(configFilePath) : 'env/flags/defaults'})`,
479
579
  redactDeep({
480
580
  ...rest,
581
+ ...(collections ? { collections: collections.map((definition) => definition.name) } : {}),
481
582
  ...(client ? { client: '[injected]' } : {}),
482
583
  ...(mongoose ? { mongoose: '[injected]' } : {}),
483
584
  ...(hooks ? { hooks: Object.keys(hooks) } : {}),
484
585
  ...(logger !== undefined ? { logger: logger === null ? null : '[injected]' } : {}),
586
+ ...(generateId ? { generateId: '[injected]' } : {}),
587
+ ...(telemetry ? { telemetry: '[injected]' } : {}),
485
588
  }),
486
589
  );
487
590
  }
@@ -496,6 +599,8 @@ module.exports = {
496
599
  CONFIG_KEYS,
497
600
  DEFAULT_CONFIG,
498
601
  ENV_KEYS,
602
+ // Re-exported from utils/collection-name.js, where it moved so that
603
+ // core/collections.js can use it without a require cycle through here.
499
604
  isCollectionName,
500
605
  loadConfig,
501
606
  validateConfig,
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The converge history: one append-only document per converge that changed
3
+ * something or failed (`_migronaut_converge` by default). Converge itself is
4
+ * stateless — it reads the live database and the declarations every time —
5
+ * so this is not state it acts on; it is the audit trail a migration gets
6
+ * from the changelog: who dropped which index, when, and why.
7
+ *
8
+ * Mechanism only, like changelog.js: no logger, no decisions about what to
9
+ * record — converge.js builds the entry.
10
+ */
11
+ class ConvergeLog {
12
+ #collectionName;
13
+ #indexed = false;
14
+
15
+ constructor(collectionName) {
16
+ this.#collectionName = collectionName;
17
+ }
18
+
19
+ #coll(db) {
20
+ return db.collection(this.#collectionName);
21
+ }
22
+
23
+ /**
24
+ * Append one entry. The `startedAt` index is created with the first entry
25
+ * this instance writes, not at connect: a database that never converges
26
+ * gets no history collection at all.
27
+ */
28
+ async append(db, entry) {
29
+ if (!this.#indexed) {
30
+ await this.#coll(db).createIndex({ startedAt: -1 }, { name: 'startedAt' });
31
+ this.#indexed = true;
32
+ }
33
+ await this.#coll(db).insertOne({ ...entry });
34
+ }
35
+
36
+ /** The newest `limit` entries, newest first */
37
+ async list(db, limit) {
38
+ return this.#coll(db)
39
+ .find({})
40
+ .sort({ startedAt: -1 })
41
+ .limit(limit)
42
+ .project({ _id: 0 })
43
+ .toArray();
44
+ }
45
+ }
46
+
47
+ module.exports = { ConvergeLog };