@alexify/migronaut 2.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 (49) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/README.md +208 -6
  3. package/bullmq.d.ts +845 -0
  4. package/bullmq.js +1 -0
  5. package/index.d.ts +634 -18
  6. package/migronaut.schema.json +182 -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 +608 -0
  11. package/src/bullmq/producer.js +424 -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 +160 -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 +105 -0
  24. package/src/core/changelog.js +71 -6
  25. package/src/core/collections.js +372 -0
  26. package/src/core/config.js +100 -25
  27. package/src/core/converge-log.js +47 -0
  28. package/src/core/converge-plan.js +483 -0
  29. package/src/core/converge.js +867 -0
  30. package/src/core/index-spec.js +496 -0
  31. package/src/core/lock-wait.js +260 -0
  32. package/src/core/lock.js +45 -16
  33. package/src/core/migrator.js +563 -283
  34. package/src/core/options.js +251 -0
  35. package/src/core/run-recorder.js +157 -0
  36. package/src/core/run.js +58 -90
  37. package/src/core/sequence.js +134 -0
  38. package/src/errors/index.js +56 -0
  39. package/src/index.js +8 -0
  40. package/src/utils/actor.js +48 -0
  41. package/src/utils/canonical.js +179 -0
  42. package/src/utils/collection-name.js +21 -0
  43. package/src/utils/error.js +18 -1
  44. package/src/utils/id.js +77 -0
  45. package/src/utils/loader.js +39 -21
  46. package/src/utils/migration-name.js +32 -0
  47. package/src/utils/redact.js +21 -1
  48. package/src/utils/telemetry.js +393 -0
  49. package/src/utils/template.js +36 -2
@@ -2,10 +2,13 @@ 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');
5
7
  const { applyEnvFile } = require('../utils/env.js');
6
8
  const { errorText } = require('../utils/error.js');
7
9
  const { resolveLogger } = require('../utils/logger.js');
8
10
  const { redactDeep } = require('../utils/redact.js');
11
+ const { collectionsIssues } = require('./collections.js');
9
12
 
10
13
  /**
11
14
  * Default values applied when no flag, env var, or config-file value is
@@ -17,6 +20,7 @@ const DEFAULT_CONFIG = {
17
20
  migrationsDir: './migrations',
18
21
  migrationsCollection: '_migronaut_migrations',
19
22
  lockCollection: '_migronaut_locks',
23
+ convergeLogCollection: '_migronaut_converge',
20
24
  lockTTLSeconds: 60,
21
25
  strict: false,
22
26
  useTransaction: false,
@@ -27,6 +31,7 @@ const DEFAULT_CONFIG = {
27
31
  onLockLost: 'abort',
28
32
  onOutOfOrder: 'warn',
29
33
  reloadMigrations: false,
34
+ convergeAfterUp: false,
30
35
  };
31
36
 
32
37
  /** Candidate config file names, checked in priority order within the cwd */
@@ -37,20 +42,6 @@ const isBoolean = (value) => typeof value === 'boolean';
37
42
  const isPositiveInteger = (value) => Number.isInteger(value) && value > 0;
38
43
  const isExtension = (value) => value === 'ts' || value === 'js';
39
44
 
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
45
  function isStringList(value) {
55
46
  if (!Array.isArray(value) || value.length === 0) return false;
56
47
  for (const item of value) {
@@ -62,8 +53,13 @@ function isStringList(value) {
62
53
  /**
63
54
  * Validation spec for every checked config key: predicate + failure message.
64
55
  * `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.
56
+ * they hold live instances the validator has nothing to say about.
57
+ * `generateId` and `telemetry` are code-only too, but each has exactly one
58
+ * valid shape, so validateConfig checks them on their own rather than through
59
+ * this table (which a test pins against the JSON schema — and a function or a
60
+ * tracer has no place there).
61
+ * Unknown keys are allowed, matching the previous zod (non-strict object)
62
+ * behavior.
67
63
  */
68
64
  const CONFIG_KEYS = [
69
65
  { path: 'uri', check: isNonEmptyString, message: 'uri is required' },
@@ -79,6 +75,11 @@ const CONFIG_KEYS = [
79
75
  check: isCollectionName,
80
76
  message: "must be a valid collection name (no '$'/NUL, not system.*)",
81
77
  },
78
+ {
79
+ path: 'convergeLogCollection',
80
+ check: isCollectionName,
81
+ message: "must be a valid collection name (no '$'/NUL, not system.*)",
82
+ },
82
83
  { path: 'lockTTLSeconds', check: isPositiveInteger, message: 'must be a positive integer' },
83
84
  { path: 'strict', check: isBoolean, message: 'must be a boolean' },
84
85
  { path: 'useTransaction', check: isBoolean, message: 'must be a boolean' },
@@ -133,15 +134,39 @@ const CONFIG_KEYS = [
133
134
  optional: true,
134
135
  },
135
136
  { path: 'reloadMigrations', check: isBoolean, message: 'must be a boolean', optional: true },
137
+ // Only the outer shape here — each definition is checked by
138
+ // collectionsIssues (core/collections.js), which reports nested paths
139
+ // (`collections[2].indexes[0].key`) instead of one opaque message.
140
+ {
141
+ path: 'collections',
142
+ check: Array.isArray,
143
+ message: 'must be an array of collection definitions',
144
+ optional: true,
145
+ },
146
+ {
147
+ path: 'collectionsDir',
148
+ check: isNonEmptyString,
149
+ message: 'must be a non-empty string',
150
+ optional: true,
151
+ },
152
+ { path: 'convergeAfterUp', check: isBoolean, message: 'must be a boolean', optional: true },
136
153
  ];
137
154
 
138
155
  /**
139
156
  * 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.
157
+ * the code-only ones (the live instances, `generateId` and `telemetry`). Used
158
+ * only to *mention* typos (`migrationsDirectory`, `useTransactions`) at debug
159
+ * level — unknown keys stay allowed, matching the documented non-strict
160
+ * contract.
143
161
  */
144
- const KNOWN_CONFIG_KEYS = new Set(['logger', 'hooks', 'mongoose', 'client']);
162
+ const KNOWN_CONFIG_KEYS = new Set([
163
+ 'logger',
164
+ 'hooks',
165
+ 'mongoose',
166
+ 'client',
167
+ 'generateId',
168
+ 'telemetry',
169
+ ]);
145
170
  for (const spec of CONFIG_KEYS) KNOWN_CONFIG_KEYS.add(spec.path);
146
171
 
147
172
  /**
@@ -165,6 +190,34 @@ function validateConfig(config, options = {}) {
165
190
  }
166
191
  if (!spec.check(value)) issues.push({ path: spec.path, message: spec.message });
167
192
  }
193
+ // What it returns is checked on every call (utils/id.js); that it is callable
194
+ // at all is a config mistake — a JSON config's `"generateId": "ulid"` — and
195
+ // belongs with the others, before a run is started.
196
+ if (config.generateId !== undefined && typeof config.generateId !== 'function') {
197
+ issues.push({ path: 'generateId', message: 'must be a function' });
198
+ }
199
+ for (const issue of telemetryIssues(config.telemetry)) issues.push(issue);
200
+ // Inline definitions are checked with the rest of the config — they are
201
+ // pure data, so this costs nothing. Definition *files* are loaded only when
202
+ // a converge runs: importing them here would make one broken file block
203
+ // every command, an emergency `down` included.
204
+ // Three bookkeeping collections, three jobs: sharing one would mix records.
205
+ const bookkeeping = ['migrationsCollection', 'lockCollection', 'convergeLogCollection'];
206
+ for (const [position, key] of bookkeeping.entries()) {
207
+ for (const other of bookkeeping.slice(0, position)) {
208
+ if (config[key] !== undefined && config[key] === config[other]) {
209
+ issues.push({ path: key, message: `must differ from ${other}` });
210
+ }
211
+ }
212
+ }
213
+ if (Array.isArray(config.collections)) {
214
+ const reserved = [
215
+ config.migrationsCollection,
216
+ config.lockCollection,
217
+ config.convergeLogCollection,
218
+ ];
219
+ for (const issue of collectionsIssues(config.collections, { reserved })) issues.push(issue);
220
+ }
168
221
  return issues;
169
222
  }
170
223
 
@@ -242,8 +295,9 @@ const parseString = (value) => value;
242
295
  *
243
296
  * Every *scalar* config option has an entry here, which is what makes the
244
297
  * 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.
298
+ * holding non-scalars — `fileExtensions`, `clientOptions`, `collections`,
299
+ * `client`, `mongoose`, `hooks`, `logger`, `generateId`, `telemetry` — are
300
+ * config-file/API only; an env var cannot express them.
247
301
  *
248
302
  * MIGRONAUT_ENV_FILE is deliberately absent: it selects which .env file to load,
249
303
  * so it has to be read before this table can run (see loadConfig).
@@ -254,6 +308,11 @@ const ENV_KEYS = [
254
308
  { env: 'MIGRONAUT_MIGRATIONS_DIR', path: 'migrationsDir', parse: parseString },
255
309
  { env: 'MIGRONAUT_COLLECTION', path: 'migrationsCollection', parse: parseString },
256
310
  { env: 'MIGRONAUT_LOCK_COLLECTION', path: 'lockCollection', parse: parseString },
311
+ {
312
+ env: 'MIGRONAUT_CONVERGE_LOG_COLLECTION',
313
+ path: 'convergeLogCollection',
314
+ parse: parseString,
315
+ },
257
316
  { env: 'MIGRONAUT_LOCK_TTL', path: 'lockTTLSeconds', parse: parsePositiveInteger },
258
317
  { env: 'MIGRONAUT_STRICT', path: 'strict', parse: parseBoolean },
259
318
  { env: 'MIGRONAUT_USE_TRANSACTION', path: 'useTransaction', parse: parseBoolean },
@@ -270,6 +329,8 @@ const ENV_KEYS = [
270
329
  },
271
330
  { env: 'MIGRONAUT_ENSURE_INDEXES', path: 'ensureIndexes', parse: parseBoolean },
272
331
  { env: 'MIGRONAUT_RELOAD_MIGRATIONS', path: 'reloadMigrations', parse: parseBoolean },
332
+ { env: 'MIGRONAUT_COLLECTIONS_DIR', path: 'collectionsDir', parse: parseString },
333
+ { env: 'MIGRONAUT_CONVERGE_AFTER_UP', path: 'convergeAfterUp', parse: parseBoolean },
273
334
  ];
274
335
 
275
336
  /** Build a partial config from the MIGRONAUT_* environment variables */
@@ -458,6 +519,13 @@ async function loadConfig(options = {}) {
458
519
  for (const key in config) {
459
520
  if (!KNOWN_CONFIG_KEYS.has(key)) (unknown ??= []).push(key);
460
521
  }
522
+ // Same for `telemetry`: handing over the whole `@opentelemetry/api` module
523
+ // (`trace`, `metrics`) instead of a tracer and a meter turns it off silently.
524
+ if (config.telemetry) {
525
+ for (const key in config.telemetry) {
526
+ if (!TELEMETRY_KEYS.includes(key)) (unknown ??= []).push(`telemetry.${key}`);
527
+ }
528
+ }
461
529
  if (unknown) {
462
530
  effectiveLogger(config.logger).debug(
463
531
  `Unrecognized config key(s), ignored: ${unknown.join(', ')}`,
@@ -470,18 +538,23 @@ async function loadConfig(options = {}) {
470
538
  }
471
539
 
472
540
  // "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.
541
+ // debug level. Live instances (client, mongoose, hooks, logger, telemetry)
542
+ // and the generateId function are elided: they are not serializable and
543
+ // redactDeep rightly refuses to clone them. Declared collections show as
544
+ // names only — validators can run to hundreds of lines.
475
545
  {
476
- const { client, mongoose, hooks, logger, ...rest } = config;
546
+ const { client, mongoose, hooks, logger, generateId, telemetry, collections, ...rest } = config;
477
547
  effectiveLogger(config.logger).debug(
478
548
  `Resolved config (source: ${configFilePath ? path.basename(configFilePath) : 'env/flags/defaults'})`,
479
549
  redactDeep({
480
550
  ...rest,
551
+ ...(collections ? { collections: collections.map((definition) => definition.name) } : {}),
481
552
  ...(client ? { client: '[injected]' } : {}),
482
553
  ...(mongoose ? { mongoose: '[injected]' } : {}),
483
554
  ...(hooks ? { hooks: Object.keys(hooks) } : {}),
484
555
  ...(logger !== undefined ? { logger: logger === null ? null : '[injected]' } : {}),
556
+ ...(generateId ? { generateId: '[injected]' } : {}),
557
+ ...(telemetry ? { telemetry: '[injected]' } : {}),
485
558
  }),
486
559
  );
487
560
  }
@@ -496,6 +569,8 @@ module.exports = {
496
569
  CONFIG_KEYS,
497
570
  DEFAULT_CONFIG,
498
571
  ENV_KEYS,
572
+ // Re-exported from utils/collection-name.js, where it moved so that
573
+ // core/collections.js can use it without a require cycle through here.
499
574
  isCollectionName,
500
575
  loadConfig,
501
576
  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 };