@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.
- package/CHANGELOG.md +436 -0
- package/README.md +235 -6
- package/bullmq.d.ts +860 -0
- package/bullmq.js +1 -0
- package/index.d.ts +888 -19
- package/migronaut.schema.json +238 -1
- package/package.json +21 -5
- package/src/bullmq/index.js +55 -0
- package/src/bullmq/jobs.js +454 -0
- package/src/bullmq/processor.js +632 -0
- package/src/bullmq/producer.js +427 -0
- package/src/bullmq/service.js +653 -0
- package/src/bullmq/wait.js +124 -0
- package/src/cli/args.js +12 -2
- package/src/cli/commands/converge.js +188 -0
- package/src/cli/commands/down.js +2 -0
- package/src/cli/commands/lock.js +2 -1
- package/src/cli/commands/redo.js +8 -1
- package/src/cli/commands/up.js +14 -1
- package/src/cli/exit-codes.js +9 -2
- package/src/cli/index.js +2 -0
- package/src/cli/shared.js +14 -4
- package/src/cli/table.js +164 -0
- package/src/core/audit.js +88 -3
- package/src/core/changelog.js +71 -6
- package/src/core/collections.js +396 -0
- package/src/core/config.js +130 -25
- package/src/core/converge-log.js +47 -0
- package/src/core/converge-plan.js +686 -0
- package/src/core/converge-search-run.js +440 -0
- package/src/core/converge-search.js +404 -0
- package/src/core/converge.js +1024 -0
- package/src/core/index-spec.js +507 -0
- package/src/core/lock-wait.js +260 -0
- package/src/core/lock.js +95 -28
- package/src/core/migrator.js +600 -287
- package/src/core/options.js +266 -0
- package/src/core/run-recorder.js +157 -0
- package/src/core/run.js +58 -90
- package/src/core/search-index-spec.js +758 -0
- package/src/core/sequence.js +134 -0
- package/src/core/server-info.js +63 -0
- package/src/errors/index.js +60 -0
- package/src/index.js +8 -0
- package/src/utils/actor.js +48 -0
- package/src/utils/canonical.js +212 -0
- package/src/utils/collection-name.js +21 -0
- package/src/utils/error.js +18 -1
- package/src/utils/id.js +77 -0
- package/src/utils/loader.js +39 -21
- package/src/utils/migration-name.js +32 -0
- package/src/utils/redact.js +21 -1
- package/src/utils/telemetry.js +410 -0
- package/src/utils/template.js +43 -2
package/src/core/config.js
CHANGED
|
@@ -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.
|
|
66
|
-
*
|
|
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
|
|
141
|
-
* (`migrationsDirectory`, `useTransactions`) at debug
|
|
142
|
-
* stay allowed, matching the documented non-strict
|
|
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([
|
|
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`, `
|
|
246
|
-
* `hooks`, `logger` — are
|
|
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)
|
|
474
|
-
//
|
|
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 };
|