@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
@@ -1,3 +1,5 @@
1
+ const { actorFields } = require('../utils/actor.js');
2
+
1
3
  /**
2
4
  * Reads and writes migration records in the changelog collection
3
5
  * (`_migronaut_migrations` by default).
@@ -90,6 +92,23 @@ class Changelog {
90
92
  return names;
91
93
  }
92
94
 
95
+ /**
96
+ * Which of `names` carry a `'failed'` trace — what tells a migration that
97
+ * failed (the line is stopped) from one that simply has not run yet (it may
98
+ * be in flight elsewhere). Served by the `status_name` index.
99
+ */
100
+ async getFailedNames(db, names) {
101
+ if (names.length === 0) return [];
102
+ const docs = await this.#coll(db)
103
+ .find({ status: 'failed', name: { $in: names } })
104
+ .sort({ name: 1 })
105
+ .project({ name: 1, _id: 0 })
106
+ .toArray();
107
+ const failed = [];
108
+ for (const doc of docs) failed.push(doc.name);
109
+ return failed;
110
+ }
111
+
93
112
  /** Return a single record by migration name, or null */
94
113
  async getByName(db, name) {
95
114
  return this.#coll(db).findOne({ name });
@@ -135,6 +154,29 @@ class Changelog {
135
154
  .toArray();
136
155
  }
137
156
 
157
+ /**
158
+ * Applied records that were applied *after* `record`, newest first — the
159
+ * revert order `down --steps` uses (`appliedAt`, name-desc tiebreak). An
160
+ * `ordered` rollback refuses while any exist: undoing effects is only safe in
161
+ * reverse of the order they were made. A record with no `appliedAt` (a
162
+ * hand-edited or legacy document) treats every other applied record as
163
+ * newer — the conservative answer.
164
+ */
165
+ async getAppliedNewerThan(db, { appliedAt, name }) {
166
+ const filter =
167
+ appliedAt instanceof Date
168
+ ? {
169
+ status: 'applied',
170
+ $or: [{ appliedAt: { $gt: appliedAt } }, { appliedAt, name: { $gt: name } }],
171
+ }
172
+ : { status: 'applied', name: { $ne: name } };
173
+ return this.#coll(db)
174
+ .find(filter)
175
+ .sort({ appliedAt: -1, name: -1 })
176
+ .project({ _id: 0, name: 1, appliedAt: 1, batch: 1 })
177
+ .toArray();
178
+ }
179
+
138
180
  /** Return the highest batch number among currently-applied migrations, or null */
139
181
  async getLastBatch(db) {
140
182
  const docs = await this.#coll(db)
@@ -184,10 +226,22 @@ class Changelog {
184
226
  const { name, appliedAt, ...fields } = record;
185
227
  const update = {
186
228
  $set: fields,
187
- // A re-apply clears the stale revert marker — and the failure trace a
188
- // markFailed() from an earlier crashed attempt may have left.
189
- $unset: { revertedAt: '', failedAt: '', error: '' },
229
+ // A re-apply clears the stale revert marker (and who asked for the
230
+ // revert, and why) — and the failure trace a markFailed() from an earlier
231
+ // crashed attempt may have left.
232
+ $unset: {
233
+ revertedAt: '',
234
+ revertRequestedBy: '',
235
+ revertReason: '',
236
+ failedAt: '',
237
+ error: '',
238
+ },
190
239
  };
240
+ // Who asked for this apply, and why — or nobody said: then the previous
241
+ // apply's answer must not linger as if it were this one's.
242
+ for (const key of ['requestedBy', 'reason']) {
243
+ if (fields[key] === undefined) update.$unset[key] = '';
244
+ }
191
245
  if (appliedAt !== undefined) {
192
246
  update.$set.appliedAt = appliedAt;
193
247
  update.$setOnInsert = { firstAppliedAt: appliedAt };
@@ -248,11 +302,22 @@ class Changelog {
248
302
  * was no longer `'applied'` (a concurrent peer got there first) — the caller
249
303
  * decides what to do with that, since this module stays logger-free.
250
304
  */
251
- async markReverted(db, name, session) {
305
+ async markReverted(db, name, session, actor = {}) {
306
+ const update = {
307
+ $set: {
308
+ status: 'reverted',
309
+ ...actorFields(actor, 'revert'),
310
+ },
311
+ // Server time, like markApplied's appliedAt — one clock for the whole trail.
312
+ $currentDate: { revertedAt: true },
313
+ };
314
+ const unset = {};
315
+ if (actor.requestedBy === undefined) unset.revertRequestedBy = '';
316
+ if (actor.reason === undefined) unset.revertReason = '';
317
+ if (Object.keys(unset).length > 0) update.$unset = unset;
252
318
  return this.#coll(db).updateOne(
253
319
  { name, status: 'applied' },
254
- // Server time, like markApplied's appliedAt — one clock for the whole trail.
255
- { $set: { status: 'reverted' }, $currentDate: { revertedAt: true } },
320
+ update,
256
321
  session ? { session } : {},
257
322
  );
258
323
  }
@@ -0,0 +1,372 @@
1
+ const fs = require('node:fs/promises');
2
+ const path = require('node:path');
3
+ const { ConfigInvalidError } = require('../errors/index.js');
4
+ const { isPlainObject, regExpIssue, toWire } = require('../utils/canonical.js');
5
+ const { isCollectionName } = require('../utils/collection-name.js');
6
+ const { mapLimit } = require('../utils/concurrency.js');
7
+ const { errorText } = require('../utils/error.js');
8
+ const { importUserFile, tsLoadMessageOrNull } = require('../utils/loader.js');
9
+ const { indexIssues, normalizeDeclaredIndex, sameDeclaredSignature } = require('./index-spec.js');
10
+
11
+ /**
12
+ * Declared collections: validating a definition, normalizing it for the
13
+ * planner, and gathering definitions from their two sources — the
14
+ * `collections` config key and the files in `collectionsDir`.
15
+ *
16
+ * Knows nothing about the database. Definitions are validated strictly: an
17
+ * unknown key is an error rather than ignored, because every typo here
18
+ * (`indexs`, `validtor`) would otherwise read as "not managed" and silently
19
+ * leave the database alone.
20
+ */
21
+
22
+ /** Every key a collection definition may carry */
23
+ const DEFINITION_KEYS = [
24
+ 'name',
25
+ 'indexes',
26
+ 'validator',
27
+ 'validationLevel',
28
+ 'validationAction',
29
+ 'prune',
30
+ ];
31
+ const DEFINITION_KEY_SET = new Set(DEFINITION_KEYS);
32
+ const VALIDATION_LEVELS = ['off', 'strict', 'moderate'];
33
+ const VALIDATION_ACTIONS = ['error', 'warn', 'errorAndLog'];
34
+
35
+ /** Simultaneous definition-file loads — the same EMFILE bound as every other multi-file path */
36
+ const FS_CONCURRENCY = 16;
37
+
38
+ /** `collections[2]` + `indexes` → `collections[2].indexes`; `users.ts:` + `indexes` → `users.ts: indexes` */
39
+ function join(base, key) {
40
+ return base.endsWith(':') ? `${base} ${key}` : `${base}.${key}`;
41
+ }
42
+
43
+ /** What makes a validator unsendable — a function, a symbol, a cycle — or null */
44
+ function unsendable(value, seen = new Set()) {
45
+ if (seen.size === 0) {
46
+ const issue = regExpIssue(value);
47
+ if (issue) return issue;
48
+ }
49
+ const type = typeof value;
50
+ if (type === 'function') return 'must not contain functions';
51
+ if (type === 'symbol') return 'must not contain symbols';
52
+ if (value === null || type !== 'object') return null;
53
+ if (seen.has(value)) return 'must not contain circular references';
54
+ seen.add(value);
55
+ const items = Array.isArray(value) ? value : isPlainObject(value) ? Object.values(value) : [];
56
+ for (const item of items) {
57
+ const reason = unsendable(item, seen);
58
+ if (reason) return reason;
59
+ }
60
+ seen.delete(value);
61
+ return null;
62
+ }
63
+
64
+ const isEmptyObject = (value) => isPlainObject(value) && Object.keys(value).length === 0;
65
+
66
+ function indexListIssues(indexes, base, issues) {
67
+ if (!Array.isArray(indexes)) {
68
+ issues.push({ path: join(base, 'indexes'), message: 'must be an array of index definitions' });
69
+ return;
70
+ }
71
+ const valid = [];
72
+ for (const [position, index] of indexes.entries()) {
73
+ const indexPath = `${join(base, 'indexes')}[${position}]`;
74
+ const found = indexIssues(index, indexPath);
75
+ issues.push(...found);
76
+ if (found.length === 0) {
77
+ valid.push({ position, path: indexPath, index: normalizeDeclaredIndex(index) });
78
+ }
79
+ }
80
+ for (let a = 1; a < valid.length; a++) {
81
+ for (let b = 0; b < a; b++) {
82
+ const later = valid[a];
83
+ const earlier = valid[b];
84
+ let message;
85
+ if (later.index.name === earlier.index.name) {
86
+ message = `has the same name as indexes[${earlier.position}] ("${later.index.name}")`;
87
+ } else if (later.index.isText && earlier.index.isText) {
88
+ message = `is a second text index (after indexes[${earlier.position}]) — a collection has at most one`;
89
+ } else if (sameDeclaredSignature(later.index, earlier.index)) {
90
+ message =
91
+ `has the same key, partialFilterExpression and collation as indexes[${earlier.position}] ` +
92
+ '— the server keeps only one of them';
93
+ }
94
+ if (message) {
95
+ issues.push({ path: later.path, message });
96
+ break;
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Validate one collection definition, returning `{ path, message }` issues
104
+ * (empty when valid). `path` prefixes every issue (`collections[2]`, or
105
+ * `users.ts:` for a file); `reserved` are migronaut's own collection names;
106
+ * `fallbackName` is the name a file-based definition gets when it sets none.
107
+ */
108
+ function definitionIssues(definition, { path: base, reserved = [], fallbackName } = {}) {
109
+ const self = base.endsWith(':') ? base.slice(0, -1) : base;
110
+ if (!isPlainObject(definition)) {
111
+ return [{ path: self, message: 'must be a collection definition object' }];
112
+ }
113
+ const issues = [];
114
+ const report = (key, message) => issues.push({ path: join(base, key), message });
115
+ for (const key of Object.keys(definition)) {
116
+ if (!DEFINITION_KEY_SET.has(key)) {
117
+ report(
118
+ key,
119
+ `is not a collection definition key (expected one of: ${DEFINITION_KEYS.join(', ')})`,
120
+ );
121
+ }
122
+ }
123
+
124
+ const name = definition.name ?? fallbackName;
125
+ const nameSource =
126
+ definition.name === undefined && fallbackName !== undefined ? 'file name' : 'name';
127
+ if (name === undefined) {
128
+ report('name', 'is required');
129
+ } else if (!isCollectionName(name)) {
130
+ report(
131
+ 'name',
132
+ nameSource === 'name'
133
+ ? "must be a valid collection name (no '$'/NUL, not system.*)"
134
+ : `the file name gives "${name}", which is not a valid collection name — set name explicitly`,
135
+ );
136
+ } else if (reserved.includes(name)) {
137
+ report('name', `"${name}" is one of migronaut's own collections`);
138
+ }
139
+
140
+ if (definition.indexes === undefined && definition.validator === undefined) {
141
+ issues.push({
142
+ path: self,
143
+ message: 'declares neither indexes nor a validator — nothing to manage',
144
+ });
145
+ }
146
+ if (definition.indexes !== undefined) indexListIssues(definition.indexes, base, issues);
147
+
148
+ const { validator } = definition;
149
+ if (validator !== undefined && validator !== null) {
150
+ if (!isPlainObject(validator)) {
151
+ report('validator', 'must be an object (a query or { $jsonSchema }), or null for none');
152
+ } else {
153
+ const reason = unsendable(validator);
154
+ if (reason) report('validator', reason);
155
+ }
156
+ }
157
+ const hasValidator = isPlainObject(validator) && !isEmptyObject(validator);
158
+ for (const [key, allowed] of [
159
+ ['validationLevel', VALIDATION_LEVELS],
160
+ ['validationAction', VALIDATION_ACTIONS],
161
+ ]) {
162
+ const value = definition[key];
163
+ if (value === undefined) continue;
164
+ if (!allowed.includes(value)) {
165
+ report(key, `must be ${allowed.map((item) => `'${item}'`).join(', ')}`);
166
+ } else if (!hasValidator) {
167
+ report(key, 'has no effect without a validator');
168
+ }
169
+ }
170
+ if (definition.prune !== undefined && typeof definition.prune !== 'boolean') {
171
+ report('prune', 'must be a boolean');
172
+ }
173
+ return issues;
174
+ }
175
+
176
+ /**
177
+ * Validate the `collections` config key: each definition, plus no collection
178
+ * declared twice. `reserved` are the changelog and lock collection names.
179
+ */
180
+ function collectionsIssues(list, { reserved = [] } = {}) {
181
+ if (list === undefined) return [];
182
+ if (!Array.isArray(list)) {
183
+ return [{ path: 'collections', message: 'must be an array of collection definitions' }];
184
+ }
185
+ const issues = [];
186
+ const seen = new Map();
187
+ for (const [position, definition] of list.entries()) {
188
+ const base = `collections[${position}]`;
189
+ issues.push(...definitionIssues(definition, { path: base, reserved }));
190
+ const name = isPlainObject(definition) ? definition.name : undefined;
191
+ if (typeof name !== 'string') continue;
192
+ if (seen.has(name)) {
193
+ issues.push({
194
+ path: `${base}.name`,
195
+ message: `declares "${name}" again (already collections[${seen.get(name)}])`,
196
+ });
197
+ } else {
198
+ seen.set(name, position);
199
+ }
200
+ }
201
+ return issues;
202
+ }
203
+
204
+ /**
205
+ * A valid definition in the planner's shape: indexes normalized (effective
206
+ * names, server-form keys, the spec to send), the validator cleaned for the
207
+ * wire. `indexes`/`validator` stay `undefined` when not managed.
208
+ */
209
+ function normalizeDefinition(definition, { name, source } = {}) {
210
+ const { validator } = definition;
211
+ return {
212
+ name: definition.name ?? name,
213
+ source,
214
+ indexes: definition.indexes?.map((index) => normalizeDeclaredIndex(index)),
215
+ validator: validator === undefined || validator === null ? validator : toWire(validator),
216
+ ...(definition.validationLevel !== undefined
217
+ ? { validationLevel: definition.validationLevel }
218
+ : {}),
219
+ ...(definition.validationAction !== undefined
220
+ ? { validationAction: definition.validationAction }
221
+ : {}),
222
+ ...(definition.prune !== undefined ? { prune: definition.prune } : {}),
223
+ };
224
+ }
225
+
226
+ /** The extensions a definition file may have: `fileExtensions`, dotted, plus `.json` — longest first */
227
+ function definitionExtensions(extensions) {
228
+ const set = new Set(['.json']);
229
+ for (const ext of extensions) set.add(ext.startsWith('.') ? ext : `.${ext}`);
230
+ return [...set].sort((a, b) => b.length - a.length);
231
+ }
232
+
233
+ /**
234
+ * Load one definition file. JSON is parsed; anything else is imported, and
235
+ * its default export (or, for an ES module with only named exports, the
236
+ * exports themselves) is the definition. A function is refused rather than
237
+ * called — a Mongoose model is a function, and a definitions directory is
238
+ * exactly where one might be left by mistake.
239
+ */
240
+ async function loadDefinitionFile(filepath, options) {
241
+ if (filepath.endsWith('.json')) {
242
+ const raw = await fs.readFile(filepath, 'utf8');
243
+ try {
244
+ return JSON.parse(raw);
245
+ } catch (error) {
246
+ throw new ConfigInvalidError(
247
+ 'Collection definition file is not valid JSON',
248
+ { path: filepath, cause: errorText(error) },
249
+ { cause: error },
250
+ );
251
+ }
252
+ }
253
+ let mod;
254
+ try {
255
+ mod = await importUserFile(filepath, { reload: options.reload });
256
+ } catch (error) {
257
+ throw new ConfigInvalidError(
258
+ tsLoadMessageOrNull(filepath, error, 'collection definition') ??
259
+ 'Collection definition file failed to load',
260
+ { path: filepath, cause: errorText(error) },
261
+ { cause: error },
262
+ );
263
+ }
264
+ const exported = mod.default ?? mod;
265
+ if (typeof exported === 'function') {
266
+ throw new ConfigInvalidError(
267
+ 'A collection definition file must export one definition object, not a function',
268
+ { path: filepath },
269
+ );
270
+ }
271
+ // A shallow copy: an ES module namespace is an exotic object, not a plain one.
272
+ return isPlainObject(exported) || exported === mod ? { ...exported } : exported;
273
+ }
274
+
275
+ /**
276
+ * Load every definition file in `dir`: one collection per file, non-recursive,
277
+ * sorted by file name. Dotfiles and declaration files (`.d.ts`) are skipped,
278
+ * like in the migrations directory. An explicitly configured directory that
279
+ * does not exist is an error, not "no definitions".
280
+ */
281
+ async function loadCollectionsDir(dir, { extensions, reload = false }) {
282
+ let entries;
283
+ try {
284
+ entries = await fs.readdir(dir, { withFileTypes: true });
285
+ } catch (error) {
286
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
287
+ throw new ConfigInvalidError('collectionsDir not found', { path: dir });
288
+ }
289
+ throw new ConfigInvalidError(
290
+ 'collectionsDir could not be read',
291
+ { path: dir, cause: errorText(error) },
292
+ { cause: error },
293
+ );
294
+ }
295
+ const accepted = definitionExtensions(extensions);
296
+ const files = [];
297
+ for (const entry of entries) {
298
+ if (!entry.isFile()) continue;
299
+ const file = entry.name;
300
+ if (file.startsWith('.')) continue;
301
+ if (file.endsWith('.d.ts') || file.endsWith('.d.mts') || file.endsWith('.d.cts')) continue;
302
+ const ext = accepted.find(
303
+ (candidate) => file.endsWith(candidate) && file.length > candidate.length,
304
+ );
305
+ if (ext) files.push({ file, name: file.slice(0, -ext.length) });
306
+ }
307
+ files.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
308
+ return mapLimit(files, FS_CONCURRENCY, async ({ file, name }) => {
309
+ const filepath = path.join(dir, file);
310
+ return { file, name, definition: await loadDefinitionFile(filepath, { reload }) };
311
+ });
312
+ }
313
+
314
+ /**
315
+ * Every declared collection, normalized: the `collections` key first (already
316
+ * validated with the rest of the config), then the files in `dir`, if one is
317
+ * configured. A collection declared by both sources — or by two files — is
318
+ * an error: which declaration wins would otherwise depend on load order.
319
+ *
320
+ * @throws {ConfigInvalidError} on a missing directory, a file that does not
321
+ * load, or invalid definitions (all issues at once, in `context.issues`)
322
+ */
323
+ async function resolveDefinitions({
324
+ inline,
325
+ dir,
326
+ extensions = ['.ts', '.js'],
327
+ reload,
328
+ reserved = [],
329
+ }) {
330
+ const definitions = [];
331
+ const declaredBy = new Map();
332
+ for (const [position, definition] of (inline ?? []).entries()) {
333
+ const source = `collections[${position}]`;
334
+ definitions.push(normalizeDefinition(definition, { source }));
335
+ declaredBy.set(definition.name, source);
336
+ }
337
+ if (dir === undefined) return definitions;
338
+
339
+ const files = await loadCollectionsDir(dir, { extensions, reload });
340
+ const issues = [];
341
+ for (const { file, name: fallbackName, definition } of files) {
342
+ const base = `${file}:`;
343
+ const found = definitionIssues(definition, { path: base, reserved, fallbackName });
344
+ if (found.length > 0) {
345
+ issues.push(...found);
346
+ continue;
347
+ }
348
+ const name = definition.name ?? fallbackName;
349
+ if (declaredBy.has(name)) {
350
+ issues.push({
351
+ path: join(base, 'name'),
352
+ message: `declares "${name}" again (already ${declaredBy.get(name)})`,
353
+ });
354
+ continue;
355
+ }
356
+ declaredBy.set(name, file);
357
+ definitions.push(normalizeDefinition(definition, { name, source: file }));
358
+ }
359
+ if (issues.length > 0) {
360
+ throw new ConfigInvalidError('Invalid collection definition(s)', { path: dir, issues });
361
+ }
362
+ return definitions;
363
+ }
364
+
365
+ module.exports = {
366
+ DEFINITION_KEYS,
367
+ collectionsIssues,
368
+ definitionIssues,
369
+ loadCollectionsDir,
370
+ normalizeDefinition,
371
+ resolveDefinitions,
372
+ };