@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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What converge and audit read about the server, and how: the read options
|
|
3
|
+
* forced onto every read, the pace of reading many collections, the server's
|
|
4
|
+
* version and topology, and the error codes for a namespace or an index that
|
|
5
|
+
* is not there. Mechanism only — no logger, no decisions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read options forced onto both reads: the primary (a secondary may not have
|
|
10
|
+
* an index build yet), and BSON values as plain JavaScript — an injected
|
|
11
|
+
* client configured with `promoteValues: false` or `useBigInt64: true` would
|
|
12
|
+
* otherwise hand back `Int32` objects or `1n`, and everything would compare as
|
|
13
|
+
* changed.
|
|
14
|
+
*/
|
|
15
|
+
const READ_OPTIONS = Object.freeze({
|
|
16
|
+
readPreference: 'primary',
|
|
17
|
+
promoteLongs: true,
|
|
18
|
+
promoteValues: true,
|
|
19
|
+
useBigInt64: false,
|
|
20
|
+
bsonRegExp: false,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** listIndexes calls in flight while reading many collections — a pace, not a pool */
|
|
24
|
+
const READ_CONCURRENCY = 8;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What the server is: a mongos in front of shards (its shard keys matter to
|
|
28
|
+
* prune), and its version (what it can change in place). Best-effort — a
|
|
29
|
+
* server that refuses to say gets the conservative answer: no in-place
|
|
30
|
+
* extras, no shard-key handling.
|
|
31
|
+
*/
|
|
32
|
+
async function readServer(db) {
|
|
33
|
+
const server = { mongos: false, version: undefined };
|
|
34
|
+
if (typeof db.admin !== 'function') return server;
|
|
35
|
+
try {
|
|
36
|
+
const hello = await db.admin().command({ hello: 1 });
|
|
37
|
+
server.mongos = hello?.msg === 'isdbgrid';
|
|
38
|
+
} catch {
|
|
39
|
+
// Unknown — treated as a replica set or standalone.
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const info = await db.admin().command({ buildInfo: 1 });
|
|
43
|
+
const [major, minor, patch] = Array.isArray(info?.versionArray) ? info.versionArray : [];
|
|
44
|
+
if (Number.isInteger(major) && Number.isInteger(minor)) {
|
|
45
|
+
server.version = { major, minor, ...(Number.isInteger(patch) ? { patch } : {}) };
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Unknown version: only the always-available in-place changes.
|
|
49
|
+
}
|
|
50
|
+
return server;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const NAMESPACE_NOT_FOUND = 26;
|
|
54
|
+
|
|
55
|
+
const INDEX_NOT_FOUND = 27;
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
INDEX_NOT_FOUND,
|
|
59
|
+
NAMESPACE_NOT_FOUND,
|
|
60
|
+
READ_CONCURRENCY,
|
|
61
|
+
READ_OPTIONS,
|
|
62
|
+
readServer,
|
|
63
|
+
};
|
package/src/errors/index.js
CHANGED
|
@@ -204,6 +204,62 @@ 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. `context.phase` says where it stopped: `'plan'` (a conflict refused
|
|
247
|
+
* the run before any write), `'replan'` (a collection changed while the run
|
|
248
|
+
* was under way), `'apply'` (a step failed) or `'wait'` (the search index
|
|
249
|
+
* builds did not finish — `context.reason`: `'failed'`, `'timeout'` or
|
|
250
|
+
* `'unreadable'`). A search index list that could not be read is reported in
|
|
251
|
+
* the phase that read it. `context.converge` is the converge result so far —
|
|
252
|
+
* which steps were applied, which failed, which were never reached — and
|
|
253
|
+
* `context.hint`, when present, says what usually fixes the server error
|
|
254
|
+
* behind it.
|
|
255
|
+
*/
|
|
256
|
+
class ConvergeFailedError extends MigronautError {
|
|
257
|
+
constructor(message, context, options) {
|
|
258
|
+
super('CONVERGE_FAILED', message, context, options);
|
|
259
|
+
this.name = 'ConvergeFailedError';
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
207
263
|
module.exports = {
|
|
208
264
|
MigronautError,
|
|
209
265
|
LockAlreadyHeldError,
|
|
@@ -226,4 +282,8 @@ module.exports = {
|
|
|
226
282
|
ImportTargetNotEmptyError,
|
|
227
283
|
IrreversibleMigrationError,
|
|
228
284
|
OutOfOrderMigrationError,
|
|
285
|
+
MigrationBlockedError,
|
|
286
|
+
QueueJobInvalidError,
|
|
287
|
+
QueueJobFailedError,
|
|
288
|
+
ConvergeFailedError,
|
|
229
289
|
};
|
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,212 @@
|
|
|
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
|
+
* Why `value` cannot be sent as declared — a function, a symbol, a cycle, or a
|
|
105
|
+
* RegExp the driver would change (see {@link regExpIssue}) — or null. Walks
|
|
106
|
+
* plain objects and arrays, the shapes a declaration is written in.
|
|
107
|
+
*/
|
|
108
|
+
function unsendable(value, seen = new Set()) {
|
|
109
|
+
if (seen.size === 0) {
|
|
110
|
+
const issue = regExpIssue(value);
|
|
111
|
+
if (issue) return issue;
|
|
112
|
+
}
|
|
113
|
+
const type = typeof value;
|
|
114
|
+
if (type === 'function') return 'must not contain functions';
|
|
115
|
+
if (type === 'symbol') return 'must not contain symbols';
|
|
116
|
+
if (value === null || type !== 'object') return null;
|
|
117
|
+
if (seen.has(value)) return 'must not contain circular references';
|
|
118
|
+
seen.add(value);
|
|
119
|
+
const items = Array.isArray(value) ? value : isPlainObject(value) ? Object.values(value) : [];
|
|
120
|
+
for (const item of items) {
|
|
121
|
+
const reason = unsendable(item, seen);
|
|
122
|
+
if (reason) return reason;
|
|
123
|
+
}
|
|
124
|
+
seen.delete(value);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Assign without invoking setters: a key named `__proto__` (JSON.parse makes
|
|
130
|
+
* one an own property) must stay a key, not replace the object's prototype —
|
|
131
|
+
* otherwise it vanishes from what is sent and what is compared.
|
|
132
|
+
*/
|
|
133
|
+
function assign(target, key, value) {
|
|
134
|
+
Object.defineProperty(target, key, {
|
|
135
|
+
value,
|
|
136
|
+
enumerable: true,
|
|
137
|
+
writable: true,
|
|
138
|
+
configurable: true,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A JSON-safe, key-sorted stand-in for `value` (see the module comment) */
|
|
143
|
+
function canonical(value) {
|
|
144
|
+
if (value === undefined || value === null) return null;
|
|
145
|
+
const type = typeof value;
|
|
146
|
+
if (type === 'string' || type === 'boolean') return value;
|
|
147
|
+
if (type === 'number') return canonicalNumber(value);
|
|
148
|
+
if (type === 'bigint') {
|
|
149
|
+
const number = Number(value);
|
|
150
|
+
return Number.isSafeInteger(number) ? number : { $long: value.toString() };
|
|
151
|
+
}
|
|
152
|
+
if (type !== 'object') return { $opaque: type };
|
|
153
|
+
if (Array.isArray(value)) {
|
|
154
|
+
const out = new Array(value.length);
|
|
155
|
+
for (let i = 0; i < value.length; i++) out[i] = canonical(value[i]);
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
if (value instanceof Date) {
|
|
159
|
+
const time = value.getTime();
|
|
160
|
+
return { $date: Number.isNaN(time) ? 'invalid' : value.toISOString() };
|
|
161
|
+
}
|
|
162
|
+
if (value instanceof RegExp) {
|
|
163
|
+
return { $regex: value.source, $options: sortFlags(storedFlags(value.flags)) };
|
|
164
|
+
}
|
|
165
|
+
const bson = bsonType(value);
|
|
166
|
+
if (bson !== undefined) return canonicalBson(bson, value);
|
|
167
|
+
const entries = value instanceof Map ? [...value.entries()] : Object.entries(value);
|
|
168
|
+
if (!(value instanceof Map) && !isPlainObject(value)) return { $opaque: 'object' };
|
|
169
|
+
const out = {};
|
|
170
|
+
const keys = [];
|
|
171
|
+
for (const [key, item] of entries) {
|
|
172
|
+
// Dropped, not nulled: that is what the declaration means, and what a
|
|
173
|
+
// client with `ignoreUndefined` stores. toWire() makes sure it is also
|
|
174
|
+
// what migronaut itself sends.
|
|
175
|
+
if (item !== undefined) keys.push([String(key), item]);
|
|
176
|
+
}
|
|
177
|
+
keys.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
178
|
+
for (const [key, item] of keys) assign(out, key, canonical(item));
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Deep equality under {@link canonical} */
|
|
183
|
+
function deepEqual(a, b) {
|
|
184
|
+
return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A copy of a declared value fit to send: plain objects lose their
|
|
189
|
+
* `undefined` properties, everything else (arrays, Dates, RegExps, BSON
|
|
190
|
+
* values) is kept as is. Without it a declared `undefined` would be stored as
|
|
191
|
+
* `null` by a client with the default `ignoreUndefined: false`, and the next
|
|
192
|
+
* comparison would report a change that can never converge.
|
|
193
|
+
*/
|
|
194
|
+
function toWire(value) {
|
|
195
|
+
if (Array.isArray(value)) return value.map((item) => toWire(item));
|
|
196
|
+
if (!isPlainObject(value)) return value;
|
|
197
|
+
const out = {};
|
|
198
|
+
for (const [key, item] of Object.entries(value)) {
|
|
199
|
+
if (item !== undefined) assign(out, key, toWire(item));
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
assign,
|
|
206
|
+
canonical,
|
|
207
|
+
deepEqual,
|
|
208
|
+
isPlainObject,
|
|
209
|
+
regExpIssue,
|
|
210
|
+
toWire,
|
|
211
|
+
unsendable,
|
|
212
|
+
};
|
|
@@ -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 };
|