@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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const { MigrationBlockedError, MigrationFileNotFoundError } = require('../errors/index.js');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The migration sequence: the files on disk, in name order, measured against
|
|
6
|
+
* the names the changelog holds as applied — which are pending, which arrived
|
|
7
|
+
* late, which stand in the way of an ordered step, and in what order applied
|
|
8
|
+
* records are reverted. Pure apart from reading the directory; what to do
|
|
9
|
+
* with each answer (refuse, warn, wait) is the kit's decision.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Declaration files sit next to TypeScript migrations and are never one */
|
|
13
|
+
const DECLARATION_SUFFIXES = ['.d.ts', '.d.mts', '.d.cts'];
|
|
14
|
+
|
|
15
|
+
/** The migration files in `dir`, sorted ascending — empty when the directory does not exist */
|
|
16
|
+
async function listMigrationFiles(dir, extensions) {
|
|
17
|
+
let entries;
|
|
18
|
+
try {
|
|
19
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error.code === 'ENOENT') return [];
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
const matches = [];
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
// A directory named `foo.js`, a dotfile, or a `types.d.ts` sitting next
|
|
27
|
+
// to the migrations is not a migration — including it would hard-fail
|
|
28
|
+
// the whole run with MigrationInvalidExportError.
|
|
29
|
+
if (!entry.isFile()) continue;
|
|
30
|
+
const file = entry.name;
|
|
31
|
+
if (file.startsWith('.')) continue;
|
|
32
|
+
if (DECLARATION_SUFFIXES.some((suffix) => file.endsWith(suffix))) continue;
|
|
33
|
+
for (const ext of extensions) {
|
|
34
|
+
if (file.endsWith(ext)) {
|
|
35
|
+
matches.push(file);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return matches.sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The files of `sequence` with no applied name — all of them, or only those
|
|
45
|
+
* sorting before `before`. Pending means "no applied record": a `'failed'`
|
|
46
|
+
* trace counts, so a migration that failed stops the line exactly like one
|
|
47
|
+
* that never ran.
|
|
48
|
+
*/
|
|
49
|
+
function pendingIn(sequence, appliedNames, before) {
|
|
50
|
+
const pending = [];
|
|
51
|
+
for (const file of sequence) {
|
|
52
|
+
if (before !== undefined && file >= before) break;
|
|
53
|
+
if (!appliedNames.has(file)) pending.push(file);
|
|
54
|
+
}
|
|
55
|
+
return pending;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Keep only the pending migrations up to and including `to`.
|
|
60
|
+
*
|
|
61
|
+
* `to` must name a migration that exists; it may already be applied (then
|
|
62
|
+
* nothing before it is pending either, and the result is empty), which is
|
|
63
|
+
* what makes `up --to X` idempotent — running it twice is a no-op rather
|
|
64
|
+
* than an error.
|
|
65
|
+
*/
|
|
66
|
+
function truncateAtTarget(pending, allFiles, to) {
|
|
67
|
+
if (!allFiles.includes(to)) {
|
|
68
|
+
throw new MigrationFileNotFoundError('Migration file not found', { to });
|
|
69
|
+
}
|
|
70
|
+
const kept = [];
|
|
71
|
+
for (const file of pending) {
|
|
72
|
+
if (file > to) break;
|
|
73
|
+
kept.push(file);
|
|
74
|
+
}
|
|
75
|
+
return kept;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The name that sorts last — '' for none */
|
|
79
|
+
function newestOf(names) {
|
|
80
|
+
let newest = '';
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
if (name > newest) newest = name;
|
|
83
|
+
}
|
|
84
|
+
return newest;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Out-of-order arrivals among `targets`: pending files that sort before the
|
|
89
|
+
* newest applied name — migrations merged late from a parallel branch, which
|
|
90
|
+
* will run after migrations authored later. `null` when there are none.
|
|
91
|
+
*/
|
|
92
|
+
function lateArrivals(targets, appliedNames) {
|
|
93
|
+
if (targets.length === 0 || appliedNames.size === 0) return null;
|
|
94
|
+
const newestApplied = newestOf(appliedNames);
|
|
95
|
+
const late = [];
|
|
96
|
+
for (const target of targets) {
|
|
97
|
+
if (!appliedNames.has(target) && target < newestApplied) late.push(target);
|
|
98
|
+
}
|
|
99
|
+
return late.length > 0 ? { late, newestApplied } : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The names of the records to revert, newest first unless already in revert order */
|
|
103
|
+
function revertOrder(records, preserveOrder) {
|
|
104
|
+
const names = [];
|
|
105
|
+
for (const record of records) names.push(record.name);
|
|
106
|
+
if (!preserveOrder) {
|
|
107
|
+
names.sort();
|
|
108
|
+
names.reverse();
|
|
109
|
+
}
|
|
110
|
+
return names;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The refusal of an ordered step: "`subject` is blocked: N `what`: a, b".
|
|
115
|
+
* `context` carries `blockedBy` (and `failed`, the blockers with a failed
|
|
116
|
+
* trace — a stopped line rather than one still on its way).
|
|
117
|
+
*/
|
|
118
|
+
function blockedError(subject, what, context) {
|
|
119
|
+
const { blockedBy } = context;
|
|
120
|
+
return new MigrationBlockedError(
|
|
121
|
+
`${subject} is blocked: ${blockedBy.length} ${what}: ${blockedBy.join(', ')}`,
|
|
122
|
+
context,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
blockedError,
|
|
128
|
+
lateArrivals,
|
|
129
|
+
listMigrationFiles,
|
|
130
|
+
newestOf,
|
|
131
|
+
pendingIn,
|
|
132
|
+
revertOrder,
|
|
133
|
+
truncateAtTarget,
|
|
134
|
+
};
|
package/src/errors/index.js
CHANGED
|
@@ -204,6 +204,58 @@ class OutOfOrderMigrationError extends MigronautError {
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Thrown by an `ordered` single-file run that would apply or revert out of
|
|
209
|
+
* sequence: an earlier migration is still pending (`up`), or one applied later
|
|
210
|
+
* is still applied (`down`). `context.blockedBy` names what must go first.
|
|
211
|
+
*/
|
|
212
|
+
class MigrationBlockedError extends MigronautError {
|
|
213
|
+
constructor(message, context, options) {
|
|
214
|
+
super('MIGRATION_BLOCKED', message, context, options);
|
|
215
|
+
this.name = 'MigrationBlockedError';
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Thrown by the queue adapter when a job's payload fails the contract check.
|
|
221
|
+
* Job data comes back from Redis, so it is untrusted input — never a config
|
|
222
|
+
* mistake of the process that reads it.
|
|
223
|
+
*/
|
|
224
|
+
class QueueJobInvalidError extends MigronautError {
|
|
225
|
+
constructor(message, context, options) {
|
|
226
|
+
super('QUEUE_JOB_INVALID', message, context, options);
|
|
227
|
+
this.name = 'QueueJobInvalidError';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Thrown by a queue group's `wait()` when one of its jobs failed or the wait
|
|
233
|
+
* timed out. The worker's typed error does not cross the queue — only its
|
|
234
|
+
* message does — so `context.failedReason` carries it and `context.results`
|
|
235
|
+
* lists the jobs that finished before it.
|
|
236
|
+
*/
|
|
237
|
+
class QueueJobFailedError extends MigronautError {
|
|
238
|
+
constructor(message, context, options) {
|
|
239
|
+
super('QUEUE_JOB_FAILED', message, context, options);
|
|
240
|
+
this.name = 'QueueJobFailedError';
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Thrown by `converge` when the database cannot be brought to the declared
|
|
246
|
+
* state: the plan has a conflict (refused before any write — `context.phase`
|
|
247
|
+
* is `'plan'`), or a step failed (`'apply'`). `context.converge` is the
|
|
248
|
+
* converge result so far — which steps were applied, which failed, which were
|
|
249
|
+
* never reached — and `context.hint`, when present, says what usually fixes
|
|
250
|
+
* the server error behind it.
|
|
251
|
+
*/
|
|
252
|
+
class ConvergeFailedError extends MigronautError {
|
|
253
|
+
constructor(message, context, options) {
|
|
254
|
+
super('CONVERGE_FAILED', message, context, options);
|
|
255
|
+
this.name = 'ConvergeFailedError';
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
207
259
|
module.exports = {
|
|
208
260
|
MigronautError,
|
|
209
261
|
LockAlreadyHeldError,
|
|
@@ -226,4 +278,8 @@ module.exports = {
|
|
|
226
278
|
ImportTargetNotEmptyError,
|
|
227
279
|
IrreversibleMigrationError,
|
|
228
280
|
OutOfOrderMigrationError,
|
|
281
|
+
MigrationBlockedError,
|
|
282
|
+
QueueJobInvalidError,
|
|
283
|
+
QueueJobFailedError,
|
|
284
|
+
ConvergeFailedError,
|
|
229
285
|
};
|
package/src/index.js
CHANGED
|
@@ -7,12 +7,14 @@ const {
|
|
|
7
7
|
ConfigFileExistsError,
|
|
8
8
|
ConfigInvalidError,
|
|
9
9
|
ConnectionFailedError,
|
|
10
|
+
ConvergeFailedError,
|
|
10
11
|
HookFailedError,
|
|
11
12
|
ImportTargetNotEmptyError,
|
|
12
13
|
IrreversibleMigrationError,
|
|
13
14
|
LockAlreadyHeldError,
|
|
14
15
|
LockLostError,
|
|
15
16
|
LockReleaseFailedError,
|
|
17
|
+
MigrationBlockedError,
|
|
16
18
|
MigrationExecutionFailedError,
|
|
17
19
|
MigrationFileExistsError,
|
|
18
20
|
MigrationFileNotFoundError,
|
|
@@ -23,6 +25,8 @@ const {
|
|
|
23
25
|
MigronautError,
|
|
24
26
|
NotAppliedError,
|
|
25
27
|
OutOfOrderMigrationError,
|
|
28
|
+
QueueJobFailedError,
|
|
29
|
+
QueueJobInvalidError,
|
|
26
30
|
RunAbortedError,
|
|
27
31
|
} = require('./errors/index.js');
|
|
28
32
|
|
|
@@ -47,12 +51,14 @@ module.exports = {
|
|
|
47
51
|
ConfigFileExistsError,
|
|
48
52
|
ConfigInvalidError,
|
|
49
53
|
ConnectionFailedError,
|
|
54
|
+
ConvergeFailedError,
|
|
50
55
|
HookFailedError,
|
|
51
56
|
ImportTargetNotEmptyError,
|
|
52
57
|
IrreversibleMigrationError,
|
|
53
58
|
LockAlreadyHeldError,
|
|
54
59
|
LockLostError,
|
|
55
60
|
LockReleaseFailedError,
|
|
61
|
+
MigrationBlockedError,
|
|
56
62
|
MigrationExecutionFailedError,
|
|
57
63
|
MigrationFileExistsError,
|
|
58
64
|
MigrationFileNotFoundError,
|
|
@@ -63,5 +69,7 @@ module.exports = {
|
|
|
63
69
|
MigronautError,
|
|
64
70
|
NotAppliedError,
|
|
65
71
|
OutOfOrderMigrationError,
|
|
72
|
+
QueueJobFailedError,
|
|
73
|
+
QueueJobInvalidError,
|
|
66
74
|
RunAbortedError,
|
|
67
75
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who asked for a run, and why: `requestedBy` and `reason`, stamped on what
|
|
3
|
+
* the run writes to the changelog (and on a converge's history entry). Kept
|
|
4
|
+
* apart from `executedBy` — the OS user that ran it, which on a queue worker
|
|
5
|
+
* is the container's, not the person behind the request.
|
|
6
|
+
*
|
|
7
|
+
* The one definition of the two fields' limits, shared by the kit's options
|
|
8
|
+
* and the queue's job contract, so a producer can never send what its worker
|
|
9
|
+
* refuses.
|
|
10
|
+
*/
|
|
11
|
+
const ACTOR_LIMITS = Object.freeze({ requestedBy: 128, reason: 512 });
|
|
12
|
+
|
|
13
|
+
/** The problem with an actor field, or null when it is absent or valid */
|
|
14
|
+
function actorIssue(key, value) {
|
|
15
|
+
if (value === undefined) return null;
|
|
16
|
+
const max = ACTOR_LIMITS[key];
|
|
17
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > max) {
|
|
18
|
+
return `${key} must be a non-empty string of at most ${max} characters`;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Just the actor fields of `source` that are set */
|
|
24
|
+
function pickActor(source) {
|
|
25
|
+
const actor = {};
|
|
26
|
+
for (const key of Object.keys(ACTOR_LIMITS)) {
|
|
27
|
+
if (source?.[key] !== undefined) actor[key] = source[key];
|
|
28
|
+
}
|
|
29
|
+
return actor;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The actor fields of `options` as changelog fields: `requestedBy` / `reason`
|
|
34
|
+
* for what a run applies, `<prefix>RequestedBy` / `<prefix>Reason` for what
|
|
35
|
+
* it reverts (`prefix: 'revert'`), so a revert never overwrites who applied.
|
|
36
|
+
*/
|
|
37
|
+
function actorFields(options, prefix) {
|
|
38
|
+
const fields = {};
|
|
39
|
+
if (options?.requestedBy !== undefined) {
|
|
40
|
+
fields[prefix ? `${prefix}RequestedBy` : 'requestedBy'] = options.requestedBy;
|
|
41
|
+
}
|
|
42
|
+
if (options?.reason !== undefined) {
|
|
43
|
+
fields[prefix ? `${prefix}Reason` : 'reason'] = options.reason;
|
|
44
|
+
}
|
|
45
|
+
return fields;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { ACTOR_LIMITS, actorFields, actorIssue, pickActor };
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value comparison for declared state against what the server returns.
|
|
3
|
+
*
|
|
4
|
+
* The server stores a validator or a partial filter exactly as it was sent,
|
|
5
|
+
* but "as sent" and "as declared" are not the same JavaScript value: object
|
|
6
|
+
* keys may come back in another order, an `Int32` or a `Long` may stand where
|
|
7
|
+
* the declaration had a plain number, and an `undefined` property is either
|
|
8
|
+
* dropped or stored as `null` depending on the client's `ignoreUndefined`.
|
|
9
|
+
* `canonical` maps both sides onto one JSON-safe shape so that a plain string
|
|
10
|
+
* comparison decides equality.
|
|
11
|
+
*
|
|
12
|
+
* Arrays keep their order — `required: ['a', 'b']` and `['b', 'a']` are
|
|
13
|
+
* different documents to the server, and treating them as equal would hide a
|
|
14
|
+
* real change. A false "changed" costs one idempotent command; a false "same"
|
|
15
|
+
* would leave the database out of step with the declaration forever.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const isPlainObject = (value) => {
|
|
19
|
+
if (value === null || typeof value !== 'object') return false;
|
|
20
|
+
const proto = Object.getPrototypeOf(value);
|
|
21
|
+
return proto === Object.prototype || proto === null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A BSON value class from the driver (`Int32`, `Long`, `ObjectId`, …) */
|
|
25
|
+
const bsonType = (value) =>
|
|
26
|
+
value !== null && typeof value === 'object' && typeof value._bsontype === 'string'
|
|
27
|
+
? value._bsontype
|
|
28
|
+
: undefined;
|
|
29
|
+
|
|
30
|
+
function canonicalNumber(number) {
|
|
31
|
+
if (Number.isNaN(number)) return { $number: 'NaN' };
|
|
32
|
+
if (!Number.isFinite(number)) return { $number: number > 0 ? 'Infinity' : '-Infinity' };
|
|
33
|
+
// -0 and 0 are the same BSON value for every purpose a validator has.
|
|
34
|
+
return number === 0 ? 0 : number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function canonicalBson(type, value) {
|
|
38
|
+
if (type === 'Int32' || type === 'Double') return canonicalNumber(Number(value.valueOf()));
|
|
39
|
+
if (type === 'Long') {
|
|
40
|
+
const number = value.toNumber();
|
|
41
|
+
return Number.isSafeInteger(number) ? number : { $long: value.toString() };
|
|
42
|
+
}
|
|
43
|
+
if (type === 'ObjectId' || type === 'ObjectID') return { $oid: value.toHexString() };
|
|
44
|
+
if (type === 'Decimal128') return { $decimal: value.toString() };
|
|
45
|
+
if (type === 'BSONRegExp') return { $regex: value.pattern, $options: sortFlags(value.options) };
|
|
46
|
+
// Binary, Timestamp, MinKey, … — rare in a validator. Their JSON form is
|
|
47
|
+
// stable and type-tagged, which is all equality needs.
|
|
48
|
+
const json = typeof value.toJSON === 'function' ? value.toJSON() : String(value);
|
|
49
|
+
return { $bson: type, value: canonical(json) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sortFlags(flags) {
|
|
53
|
+
return [...String(flags)].sort().join('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A JavaScript RegExp's flags as the server stores them. The driver writes
|
|
58
|
+
* `i` and `m` as they are, `g` as the server's `s` (dotAll) — and drops every
|
|
59
|
+
* other flag. Comparing in this form is what lets a declared `/x/i` and the
|
|
60
|
+
* `/x/i` read back compare equal, and a `BSONRegExp('x', 's')` match too.
|
|
61
|
+
*/
|
|
62
|
+
const DRIVER_REGEXP_FLAGS = { i: 'i', m: 'm', g: 's' };
|
|
63
|
+
function storedFlags(flags) {
|
|
64
|
+
let out = '';
|
|
65
|
+
for (const flag of String(flags)) out += DRIVER_REGEXP_FLAGS[flag] ?? '';
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** JavaScript RegExp flags that do not survive the trip to the server as written */
|
|
70
|
+
const UNSTORABLE_FLAGS = /[^im]/g;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Why `value` cannot be stored as declared — a RegExp whose flags the driver
|
|
74
|
+
* changes (`g` becomes dotAll) or drops (`s`, `u`, `y`, `d`, `v`) — or null.
|
|
75
|
+
* A `BSONRegExp` states server options directly and is always fine.
|
|
76
|
+
*/
|
|
77
|
+
function regExpIssue(value, seen = new Set()) {
|
|
78
|
+
if (value instanceof RegExp) {
|
|
79
|
+
const bad = value.flags.match(UNSTORABLE_FLAGS);
|
|
80
|
+
return bad
|
|
81
|
+
? `regular expression /${value.source}/${value.flags}: flag(s) ${bad.join('')} cannot be ` +
|
|
82
|
+
'stored as written (the driver keeps only i and m, and turns g into dotAll) — use ' +
|
|
83
|
+
"BSONRegExp from 'bson' for server options"
|
|
84
|
+
: null;
|
|
85
|
+
}
|
|
86
|
+
if (value === null || typeof value !== 'object' || seen.has(value)) return null;
|
|
87
|
+
seen.add(value);
|
|
88
|
+
const items =
|
|
89
|
+
value instanceof Map
|
|
90
|
+
? [...value.values()]
|
|
91
|
+
: Array.isArray(value)
|
|
92
|
+
? value
|
|
93
|
+
: isPlainObject(value)
|
|
94
|
+
? Object.values(value)
|
|
95
|
+
: [];
|
|
96
|
+
for (const item of items) {
|
|
97
|
+
const issue = regExpIssue(item, seen);
|
|
98
|
+
if (issue) return issue;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Assign without invoking setters: a key named `__proto__` (JSON.parse makes
|
|
105
|
+
* one an own property) must stay a key, not replace the object's prototype —
|
|
106
|
+
* otherwise it vanishes from what is sent and what is compared.
|
|
107
|
+
*/
|
|
108
|
+
function assign(target, key, value) {
|
|
109
|
+
Object.defineProperty(target, key, {
|
|
110
|
+
value,
|
|
111
|
+
enumerable: true,
|
|
112
|
+
writable: true,
|
|
113
|
+
configurable: true,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A JSON-safe, key-sorted stand-in for `value` (see the module comment) */
|
|
118
|
+
function canonical(value) {
|
|
119
|
+
if (value === undefined || value === null) return null;
|
|
120
|
+
const type = typeof value;
|
|
121
|
+
if (type === 'string' || type === 'boolean') return value;
|
|
122
|
+
if (type === 'number') return canonicalNumber(value);
|
|
123
|
+
if (type === 'bigint') {
|
|
124
|
+
const number = Number(value);
|
|
125
|
+
return Number.isSafeInteger(number) ? number : { $long: value.toString() };
|
|
126
|
+
}
|
|
127
|
+
if (type !== 'object') return { $opaque: type };
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
const out = new Array(value.length);
|
|
130
|
+
for (let i = 0; i < value.length; i++) out[i] = canonical(value[i]);
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
if (value instanceof Date) {
|
|
134
|
+
const time = value.getTime();
|
|
135
|
+
return { $date: Number.isNaN(time) ? 'invalid' : value.toISOString() };
|
|
136
|
+
}
|
|
137
|
+
if (value instanceof RegExp) {
|
|
138
|
+
return { $regex: value.source, $options: sortFlags(storedFlags(value.flags)) };
|
|
139
|
+
}
|
|
140
|
+
const bson = bsonType(value);
|
|
141
|
+
if (bson !== undefined) return canonicalBson(bson, value);
|
|
142
|
+
const entries = value instanceof Map ? [...value.entries()] : Object.entries(value);
|
|
143
|
+
if (!(value instanceof Map) && !isPlainObject(value)) return { $opaque: 'object' };
|
|
144
|
+
const out = {};
|
|
145
|
+
const keys = [];
|
|
146
|
+
for (const [key, item] of entries) {
|
|
147
|
+
// Dropped, not nulled: that is what the declaration means, and what a
|
|
148
|
+
// client with `ignoreUndefined` stores. toWire() makes sure it is also
|
|
149
|
+
// what migronaut itself sends.
|
|
150
|
+
if (item !== undefined) keys.push([String(key), item]);
|
|
151
|
+
}
|
|
152
|
+
keys.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
153
|
+
for (const [key, item] of keys) assign(out, key, canonical(item));
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Deep equality under {@link canonical} */
|
|
158
|
+
function deepEqual(a, b) {
|
|
159
|
+
return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A copy of a declared value fit to send: plain objects lose their
|
|
164
|
+
* `undefined` properties, everything else (arrays, Dates, RegExps, BSON
|
|
165
|
+
* values) is kept as is. Without it a declared `undefined` would be stored as
|
|
166
|
+
* `null` by a client with the default `ignoreUndefined: false`, and the next
|
|
167
|
+
* comparison would report a change that can never converge.
|
|
168
|
+
*/
|
|
169
|
+
function toWire(value) {
|
|
170
|
+
if (Array.isArray(value)) return value.map((item) => toWire(item));
|
|
171
|
+
if (!isPlainObject(value)) return value;
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const [key, item] of Object.entries(value)) {
|
|
174
|
+
if (item !== undefined) assign(out, key, toWire(item));
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { canonical, deepEqual, isPlainObject, regExpIssue, toWire };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection names we accept for the changelog/lock collections,
|
|
3
|
+
* `import --from/--to` and declared collections: non-empty, no `$` or NUL
|
|
4
|
+
* (invalid server-side), and outside the reserved `system.` namespace — so no
|
|
5
|
+
* config value can ever point a read or write at a system collection.
|
|
6
|
+
*
|
|
7
|
+
* Lives here rather than in config.js because the collection-definition
|
|
8
|
+
* validator (core/collections.js) needs it too, and config.js requires that
|
|
9
|
+
* validator — keeping the predicate in config.js would make the two a cycle.
|
|
10
|
+
*/
|
|
11
|
+
function isCollectionName(value) {
|
|
12
|
+
return (
|
|
13
|
+
typeof value === 'string' &&
|
|
14
|
+
value.length > 0 &&
|
|
15
|
+
!value.includes('$') &&
|
|
16
|
+
!value.includes('\0') &&
|
|
17
|
+
!value.startsWith('system.')
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { isCollectionName };
|
package/src/utils/error.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const { MigronautError } = require('../errors/index.js');
|
|
1
2
|
const { redactUris } = require('./redact.js');
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -8,4 +9,20 @@ const { redactUris } = require('./redact.js');
|
|
|
8
9
|
*/
|
|
9
10
|
const errorText = (error) => redactUris(error instanceof Error ? error.message : String(error));
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* {@link errorText} joined with the wrapped cause: "Migration up failed: X"
|
|
14
|
+
* says WHICH migration failed, and its `context.cause` says WHY — the half
|
|
15
|
+
* forensics actually needs. Both halves are redacted (the cause is the raw
|
|
16
|
+
* thrown message). What the changelog's failure trace and a failed span's
|
|
17
|
+
* status message both carry.
|
|
18
|
+
*/
|
|
19
|
+
function errorWithCause(error) {
|
|
20
|
+
const message = errorText(error);
|
|
21
|
+
const cause =
|
|
22
|
+
error instanceof MigronautError && typeof error.context?.cause === 'string'
|
|
23
|
+
? errorText(error.context.cause)
|
|
24
|
+
: undefined;
|
|
25
|
+
return cause ? `${message} — ${cause}` : message;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { errorText, errorWithCause };
|
package/src/utils/id.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const { randomUUID } = require('node:crypto');
|
|
2
|
+
const { ConfigInvalidError } = require('../errors/index.js');
|
|
3
|
+
const { errorText } = require('./error.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Longest id migronaut accepts. Also the limit a queue worker enforces on a
|
|
7
|
+
* job's group id — one constant, so a producer can never mint an id its own
|
|
8
|
+
* worker would reject.
|
|
9
|
+
*/
|
|
10
|
+
const MAX_ID_LENGTH = 128;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The default id: a random (v4) UUID. This module is the only place in `src/`
|
|
14
|
+
* that mints one — everything else asks for an id through it, which is what
|
|
15
|
+
* lets the `generateId` config option replace the format everywhere at once.
|
|
16
|
+
*/
|
|
17
|
+
const randomId = () => randomUUID();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return `value` when it is usable as an id, throw otherwise. An id is stored
|
|
21
|
+
* as a string field (changelog `runId`, lock `owner`, a job's `groupId`) and
|
|
22
|
+
* gated on by truthiness in the kit, so an empty or non-string value would
|
|
23
|
+
* silently switch off the reentrancy guard and the owner-scoped lock release.
|
|
24
|
+
*/
|
|
25
|
+
function assertId(value) {
|
|
26
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ID_LENGTH) {
|
|
27
|
+
throw new ConfigInvalidError(
|
|
28
|
+
`generateId must return a non-empty string of at most ${MAX_ID_LENGTH} characters`,
|
|
29
|
+
typeof value === 'string' ? { length: value.length } : { returned: typeof value },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Turn the `generateId` config option into the function the kit mints ids
|
|
37
|
+
* with. `undefined` keeps the default (`randomId`); anything else must be a
|
|
38
|
+
* function, and every id it returns is checked.
|
|
39
|
+
*
|
|
40
|
+
* The user's function is called bare — no arguments, no receiver — so a
|
|
41
|
+
* third-party generator passes straight through (`generateId: ulid`,
|
|
42
|
+
* `generateId: nanoid`): their first parameter means something of its own
|
|
43
|
+
* (a seed time, a size), and any argument migronaut passed would be read as it.
|
|
44
|
+
*
|
|
45
|
+
* It must be synchronous: the run id is minted in the same tick as the
|
|
46
|
+
* reentrancy guard that checks it, and a promise would be stored as a truthy
|
|
47
|
+
* non-id.
|
|
48
|
+
*/
|
|
49
|
+
function createIdGenerator(generateId) {
|
|
50
|
+
if (generateId === undefined) return randomId;
|
|
51
|
+
if (typeof generateId !== 'function') {
|
|
52
|
+
throw new ConfigInvalidError('generateId must be a function', {
|
|
53
|
+
generateId: typeof generateId,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return () => {
|
|
57
|
+
let value;
|
|
58
|
+
try {
|
|
59
|
+
value = generateId();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new ConfigInvalidError(
|
|
62
|
+
'generateId threw',
|
|
63
|
+
{ cause: errorText(error) },
|
|
64
|
+
{ cause: error },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (typeof value?.then === 'function') {
|
|
68
|
+
// The promise is dropped, so its rejection must not surface as an
|
|
69
|
+
// unhandled one on top of the error below.
|
|
70
|
+
value.then(undefined, () => {});
|
|
71
|
+
throw new ConfigInvalidError('generateId must be synchronous — it returned a promise');
|
|
72
|
+
}
|
|
73
|
+
return assertId(value);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { MAX_ID_LENGTH, assertId, createIdGenerator, randomId };
|