@alexify/migronaut 1.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +409 -1
  2. package/README.md +248 -24
  3. package/bin/migronaut.js +11 -3
  4. package/bullmq.d.ts +845 -0
  5. package/bullmq.js +1 -0
  6. package/index.d.ts +757 -29
  7. package/migronaut.schema.json +191 -1
  8. package/package.json +27 -6
  9. package/src/bullmq/index.js +55 -0
  10. package/src/bullmq/jobs.js +454 -0
  11. package/src/bullmq/processor.js +608 -0
  12. package/src/bullmq/producer.js +424 -0
  13. package/src/bullmq/service.js +653 -0
  14. package/src/bullmq/wait.js +124 -0
  15. package/src/cli/args.js +12 -2
  16. package/src/cli/commands/baseline.js +45 -0
  17. package/src/cli/commands/converge.js +160 -0
  18. package/src/cli/commands/down.js +2 -0
  19. package/src/cli/commands/lock.js +2 -1
  20. package/src/cli/commands/redo.js +8 -1
  21. package/src/cli/commands/unlock.js +12 -2
  22. package/src/cli/commands/up.js +14 -1
  23. package/src/cli/exit-codes.js +10 -2
  24. package/src/cli/index.js +4 -0
  25. package/src/cli/shared.js +29 -7
  26. package/src/cli/table.js +105 -0
  27. package/src/core/audit.js +17 -3
  28. package/src/core/baseline.js +80 -0
  29. package/src/core/changelog.js +140 -24
  30. package/src/core/collections.js +372 -0
  31. package/src/core/config.js +125 -27
  32. package/src/core/converge-log.js +47 -0
  33. package/src/core/converge-plan.js +483 -0
  34. package/src/core/converge.js +867 -0
  35. package/src/core/import-runner.js +34 -6
  36. package/src/core/import.js +14 -7
  37. package/src/core/index-spec.js +496 -0
  38. package/src/core/lock-wait.js +260 -0
  39. package/src/core/lock.js +71 -20
  40. package/src/core/migrator.js +805 -304
  41. package/src/core/options.js +251 -0
  42. package/src/core/run-recorder.js +157 -0
  43. package/src/core/run.js +71 -71
  44. package/src/core/runner.js +70 -20
  45. package/src/core/sequence.js +134 -0
  46. package/src/errors/index.js +71 -1
  47. package/src/index.js +16 -0
  48. package/src/utils/actor.js +48 -0
  49. package/src/utils/canonical.js +179 -0
  50. package/src/utils/collection-name.js +21 -0
  51. package/src/utils/error.js +18 -1
  52. package/src/utils/id.js +77 -0
  53. package/src/utils/loader.js +39 -21
  54. package/src/utils/logger.js +30 -12
  55. package/src/utils/migration-name.js +32 -0
  56. package/src/utils/redact.js +57 -4
  57. package/src/utils/sanitize.js +8 -3
  58. package/src/utils/telemetry.js +393 -0
  59. package/src/utils/template.js +60 -12
@@ -27,9 +27,9 @@ function isTransactionsUnsupported(error) {
27
27
  * timeout buys is the *run* stopping instead of hanging forever — which also
28
28
  * lets the lock's TTL expire, so a wedged migration no longer blocks every
29
29
  * other instance indefinitely. Migrations that need real cancellation should
30
- * watch `ctx.signal`.
30
+ * watch `ctx.signal`, which `onTimeout` aborts when the timer fires.
31
31
  */
32
- async function withTimeout(promise, timeoutMs, name, direction) {
32
+ async function withTimeout(promise, timeoutMs, name, direction, onTimeout) {
33
33
  if (!timeoutMs) return promise;
34
34
  let timer;
35
35
  try {
@@ -43,16 +43,19 @@ async function withTimeout(promise, timeoutMs, name, direction) {
43
43
  // unhandledRejection long after the run already reported the
44
44
  // timeout. Swallow it: the timeout is the reported failure.
45
45
  Promise.resolve(promise).catch(() => {});
46
- reject(
47
- new MigrationTimeoutError(
48
- `Migration ${direction} timed out after ${timeoutMs}ms: ${name}`,
49
- {
50
- name,
51
- direction,
52
- timeoutMs,
53
- },
54
- ),
46
+ const timeoutError = new MigrationTimeoutError(
47
+ `Migration ${direction} timed out after ${timeoutMs}ms: ${name}`,
48
+ {
49
+ name,
50
+ direction,
51
+ timeoutMs,
52
+ },
55
53
  );
54
+ // Told, not just abandoned: the caller aborts the context's signal
55
+ // with this error, so a body that watches ctx.signal can stop
56
+ // writing instead of racing whoever acquires the lock next.
57
+ onTimeout?.(timeoutError);
58
+ reject(timeoutError);
56
59
  }, timeoutMs);
57
60
  timer.unref?.();
58
61
  }),
@@ -77,7 +80,10 @@ async function withTimeout(promise, timeoutMs, name, direction) {
77
80
  *
78
81
  * On any error the `onError` hook is invoked before a
79
82
  * MigrationExecutionFailedError is thrown — the error is never swallowed, and a
80
- * throwing hook cannot mask the original failure.
83
+ * throwing hook cannot mask the original failure. The one exception: when the
84
+ * body succeeded and only the (non-transactional) changelog write failed, the
85
+ * error carries `context.phase = 'changelog-write'` and `onError` is not fired —
86
+ * the migration itself did not fail.
81
87
  */
82
88
  async function runMigration(params) {
83
89
  const { name, migration, direction, context, useTransaction, hooks, onSuccess, logger } = params;
@@ -87,30 +93,71 @@ async function runMigration(params) {
87
93
 
88
94
  const start = Date.now();
89
95
  let session;
90
- let runtimeContext = context;
96
+ // JavaScript cannot cancel a running body, but it can tell it to stop: this
97
+ // controller feeds the context's signal, so the documented "watch ctx.signal"
98
+ // advice covers the migration's own timeout too — not only lock loss and
99
+ // stop(). Without it a timed-out body keeps writing after the lock is
100
+ // released, racing whoever acquires it next.
101
+ const timedOut = new AbortController();
102
+ const signal = context.signal
103
+ ? AbortSignal.any([context.signal, timedOut.signal])
104
+ : timedOut.signal;
105
+ let runtimeContext = { ...context, signal };
106
+ const onTimeout = (timeoutError) => timedOut.abort(timeoutError);
91
107
  let duration = 0;
108
+ // 'body' while the migration's own code runs; 'changelog' once it committed
109
+ // and only the record write remains. The two failures need different
110
+ // reporting: a changelog failure after a committed body must not read as
111
+ // "the migration failed" — that invites a re-run of already-applied writes.
112
+ let phase = 'body';
92
113
 
93
114
  try {
94
115
  if (useTransaction) {
95
116
  session = context.client.startSession();
96
- runtimeContext = { ...context, session };
117
+ runtimeContext = { ...runtimeContext, session };
97
118
  // withTransaction may run the body more than once when the driver retries
98
- // a transient failure, so duration is re-measured on each attempt.
119
+ // a transient failure, so duration is re-measured on each attempt. The
120
+ // changelog write stays inside the transaction, so a failure there
121
+ // aborts the body's writes too — 'body' phase is accurate throughout.
99
122
  await session.withTransaction(async () => {
100
123
  const attemptStart = Date.now();
101
- await withTimeout(fn(runtimeContext), timeoutMs, name, direction);
124
+ await withTimeout(fn(runtimeContext), timeoutMs, name, direction, onTimeout);
102
125
  duration = Date.now() - attemptStart;
103
126
  await onSuccess?.(duration, session);
104
127
  });
105
128
  } else {
106
- await withTimeout(fn(runtimeContext), timeoutMs, name, direction);
129
+ await withTimeout(fn(runtimeContext), timeoutMs, name, direction, onTimeout);
107
130
  duration = Date.now() - start;
131
+ phase = 'changelog';
108
132
  await onSuccess?.(duration, undefined);
109
133
  }
110
134
 
111
135
  return { duration };
112
136
  } catch (error) {
113
137
  const err = error instanceof Error ? error : new Error(String(error));
138
+ // Failures deserve timing data as much as successes — a slow-then-failing
139
+ // migration is exactly what an operator alerts on.
140
+ const elapsed = Date.now() - start;
141
+
142
+ // Without a transaction the body's writes are already committed when the
143
+ // changelog write fails — say exactly that, instead of the generic
144
+ // "migration failed" that would invite re-running committed writes. The
145
+ // onError hook is for migration failures, so it does not fire here.
146
+ if (phase === 'changelog') {
147
+ throw new MigrationExecutionFailedError(
148
+ `Migration ${direction} succeeded but recording it in the changelog failed: ${name} — ` +
149
+ 'its own writes are committed; verify the changelog before re-running',
150
+ {
151
+ name,
152
+ direction,
153
+ phase: 'changelog-write',
154
+ bodySucceeded: true,
155
+ durationMs: elapsed,
156
+ cause: err.message,
157
+ },
158
+ { cause: err },
159
+ );
160
+ }
114
161
 
115
162
  if (hooks?.onError) {
116
163
  // A throwing onError hook must not replace the real cause.
@@ -122,14 +169,17 @@ async function runMigration(params) {
122
169
  }
123
170
  }
124
171
 
125
- if (err instanceof MigrationTimeoutError) throw err;
172
+ if (err instanceof MigrationTimeoutError) {
173
+ err.context = { durationMs: elapsed, ...err.context };
174
+ throw err;
175
+ }
126
176
  // A standalone deployment refusing the transaction is a topology problem,
127
177
  // not a bug in the migration — say so instead of blaming the file.
128
178
  if (useTransaction && isTransactionsUnsupported(err)) {
129
179
  throw new TransactionsUnsupportedError(
130
180
  `Cannot run ${name} in a transaction — this deployment is standalone. ` +
131
181
  'Set useTransaction: false, or run against a replica set / mongos.',
132
- { name, direction, cause: err.message },
182
+ { name, direction, durationMs: elapsed, cause: err.message },
133
183
  { cause: err },
134
184
  );
135
185
  }
@@ -137,7 +187,7 @@ async function runMigration(params) {
137
187
  `Migration ${direction} failed: ${name}`,
138
188
  // The message is duplicated into context because that is what survives
139
189
  // JSON serialization; `cause` keeps the real Error (and its stack).
140
- { name, direction, cause: err.message },
190
+ { name, direction, durationMs: elapsed, cause: err.message },
141
191
  { cause: err },
142
192
  );
143
193
  } finally {
@@ -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
+ };
@@ -183,7 +183,7 @@ class ImportTargetNotEmptyError extends MigronautError {
183
183
  }
184
184
  }
185
185
 
186
- /** Thrown when attempting to roll back a migrate-mongo-imported (forward-only) migration */
186
+ /** Thrown when attempting to roll back a forward-only (imported or baselined) migration */
187
187
  class IrreversibleMigrationError extends MigronautError {
188
188
  constructor(message, context, options) {
189
189
  super('MIGRATION_IRREVERSIBLE', message, context, options);
@@ -191,6 +191,71 @@ class IrreversibleMigrationError extends MigronautError {
191
191
  }
192
192
  }
193
193
 
194
+ /**
195
+ * Thrown by a bulk `up` under `onOutOfOrder: 'error'` when a pending migration
196
+ * sorts before the newest applied one — a file merged late from a parallel
197
+ * branch, which would otherwise run after migrations authored later and leave
198
+ * environments with different effective apply orders.
199
+ */
200
+ class OutOfOrderMigrationError extends MigronautError {
201
+ constructor(message, context, options) {
202
+ super('MIGRATION_OUT_OF_ORDER', message, context, options);
203
+ this.name = 'OutOfOrderMigrationError';
204
+ }
205
+ }
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
+
194
259
  module.exports = {
195
260
  MigronautError,
196
261
  LockAlreadyHeldError,
@@ -212,4 +277,9 @@ module.exports = {
212
277
  NotAppliedError,
213
278
  ImportTargetNotEmptyError,
214
279
  IrreversibleMigrationError,
280
+ OutOfOrderMigrationError,
281
+ MigrationBlockedError,
282
+ QueueJobInvalidError,
283
+ QueueJobFailedError,
284
+ ConvergeFailedError,
215
285
  };
package/src/index.js CHANGED
@@ -1,17 +1,20 @@
1
1
  const { EXIT_CODES } = require('./cli/exit-codes.js');
2
2
  const { MigratorKit } = require('./core/migrator.js');
3
3
  const { pendingMigrations, runMigrations } = require('./core/run.js');
4
+ const { createLogger } = require('./utils/logger.js');
4
5
  const {
5
6
  ChecksumMismatchError,
6
7
  ConfigFileExistsError,
7
8
  ConfigInvalidError,
8
9
  ConnectionFailedError,
10
+ ConvergeFailedError,
9
11
  HookFailedError,
10
12
  ImportTargetNotEmptyError,
11
13
  IrreversibleMigrationError,
12
14
  LockAlreadyHeldError,
13
15
  LockLostError,
14
16
  LockReleaseFailedError,
17
+ MigrationBlockedError,
15
18
  MigrationExecutionFailedError,
16
19
  MigrationFileExistsError,
17
20
  MigrationFileNotFoundError,
@@ -21,6 +24,9 @@ const {
21
24
  TransactionsUnsupportedError,
22
25
  MigronautError,
23
26
  NotAppliedError,
27
+ OutOfOrderMigrationError,
28
+ QueueJobFailedError,
29
+ QueueJobInvalidError,
24
30
  RunAbortedError,
25
31
  } = require('./errors/index.js');
26
32
 
@@ -32,6 +38,11 @@ module.exports = {
32
38
  pendingMigrations,
33
39
  runMigrations,
34
40
 
41
+ // The default console logger, for programmatic callers who want migronaut's
42
+ // own output at a chosen level (e.g. createLogger(process.stdout, 'debug'))
43
+ // without hand-writing a four-method logger
44
+ createLogger,
45
+
35
46
  // The CLI's exit-code map, for wrappers that mirror its semantics
36
47
  EXIT_CODES,
37
48
 
@@ -40,12 +51,14 @@ module.exports = {
40
51
  ConfigFileExistsError,
41
52
  ConfigInvalidError,
42
53
  ConnectionFailedError,
54
+ ConvergeFailedError,
43
55
  HookFailedError,
44
56
  ImportTargetNotEmptyError,
45
57
  IrreversibleMigrationError,
46
58
  LockAlreadyHeldError,
47
59
  LockLostError,
48
60
  LockReleaseFailedError,
61
+ MigrationBlockedError,
49
62
  MigrationExecutionFailedError,
50
63
  MigrationFileExistsError,
51
64
  MigrationFileNotFoundError,
@@ -55,5 +68,8 @@ module.exports = {
55
68
  TransactionsUnsupportedError,
56
69
  MigronautError,
57
70
  NotAppliedError,
71
+ OutOfOrderMigrationError,
72
+ QueueJobFailedError,
73
+ QueueJobInvalidError,
58
74
  RunAbortedError,
59
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 };