@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.
- package/CHANGELOG.md +320 -0
- package/README.md +208 -6
- package/bullmq.d.ts +845 -0
- package/bullmq.js +1 -0
- package/index.d.ts +634 -18
- package/migronaut.schema.json +182 -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 +608 -0
- package/src/bullmq/producer.js +424 -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 +160 -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 +105 -0
- package/src/core/changelog.js +71 -6
- package/src/core/collections.js +372 -0
- package/src/core/config.js +100 -25
- package/src/core/converge-log.js +47 -0
- package/src/core/converge-plan.js +483 -0
- package/src/core/converge.js +867 -0
- package/src/core/index-spec.js +496 -0
- package/src/core/lock-wait.js +260 -0
- package/src/core/lock.js +45 -16
- package/src/core/migrator.js +563 -283
- package/src/core/options.js +251 -0
- package/src/core/run-recorder.js +157 -0
- package/src/core/run.js +58 -90
- package/src/core/sequence.js +134 -0
- package/src/errors/index.js +56 -0
- package/src/index.js +8 -0
- package/src/utils/actor.js +48 -0
- package/src/utils/canonical.js +179 -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 +393 -0
- package/src/utils/template.js +36 -2
package/src/core/config.js
CHANGED
|
@@ -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.
|
|
66
|
-
*
|
|
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
|
|
141
|
-
* (`migrationsDirectory`, `useTransactions`) at debug
|
|
142
|
-
* stay allowed, matching the documented non-strict
|
|
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([
|
|
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`, `
|
|
246
|
-
* `hooks`, `logger` — are
|
|
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)
|
|
474
|
-
//
|
|
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 };
|