@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
@@ -1,4 +1,4 @@
1
- const { ImportTargetNotEmptyError } = require('../errors/index.js');
1
+ const { ImportTargetNotEmptyError, MigronautError } = require('../errors/index.js');
2
2
  const { computeChecksum } = require('../utils/checksum.js');
3
3
  const { Changelog } = require('./changelog.js');
4
4
  const { isMigrateMongoDoc, mapMigrateMongoDocs } = require('./import.js');
@@ -52,7 +52,14 @@ async function runImport(deps, options, signal) {
52
52
  valid.push(doc);
53
53
  } else {
54
54
  skipped += 1;
55
- logger.warn('⚠ Skipping source doc without a usable fileName');
55
+ // The _id is the only handle the operator has for locating the offending
56
+ // source document — an anonymous count is undebuggable after the fact.
57
+ // String() keeps an ObjectId safe for any sink; the default logger
58
+ // already sanitizes terminal escapes in DB-derived text.
59
+ logger.warn(
60
+ `⚠ Skipping source doc without a usable fileName (_id: ${String(doc?._id)})`,
61
+ deps.fields({ source, docId: String(doc?._id) }),
62
+ );
56
63
  }
57
64
  }
58
65
 
@@ -91,7 +98,10 @@ async function runImport(deps, options, signal) {
91
98
  );
92
99
  rowSources.set(fileName, resolved.source);
93
100
  if (resolved.source === 'missing') {
94
- logger.warn(`⚠ File not found on disk: ${fileName} — checksum unverifiable`);
101
+ logger.warn(
102
+ `⚠ File not found on disk: ${fileName} — checksum unverifiable`,
103
+ deps.fields({ file: fileName, checksumSource: 'missing' }),
104
+ );
95
105
  }
96
106
  return resolved;
97
107
  },
@@ -120,9 +130,27 @@ async function runImport(deps, options, signal) {
120
130
  // adopting a 5,000-record changelog is 5 round trips, not 5,000, all while
121
131
  // holding the migration lock. The abort check between chunks lets stop() /
122
132
  // a lost lock halt a long import instead of running it to completion.
123
- for (let start = 0; start < records.length; start += IMPORT_CHUNK_SIZE) {
124
- deps.assertNotAborted(signal);
125
- await targetChangelog.markAppliedBulk(db, records.slice(start, start + IMPORT_CHUNK_SIZE));
133
+ let written = 0;
134
+ try {
135
+ for (let start = 0; start < records.length; start += IMPORT_CHUNK_SIZE) {
136
+ deps.assertNotAborted(signal);
137
+ await targetChangelog.markAppliedBulk(db, records.slice(start, start + IMPORT_CHUNK_SIZE));
138
+ written += Math.min(IMPORT_CHUNK_SIZE, records.length - start);
139
+ }
140
+ } catch (error) {
141
+ // Earlier chunks are already committed; without a count the operator only
142
+ // discovers the half-populated target when the next plain `import` throws
143
+ // ImportTargetNotEmptyError. Recovery is safe — the upserts are keyed on
144
+ // `name`, so a --force re-run is idempotent and simply resumes.
145
+ logger.warn(
146
+ `⚠ Import interrupted after ${written}/${records.length} record(s) — ` +
147
+ 'a --force re-run is idempotent and will resume',
148
+ deps.fields({ source, target, imported: written, total: records.length }),
149
+ );
150
+ if (error instanceof MigronautError && error.context) {
151
+ error.context = { ...error.context, imported: written, total: records.length };
152
+ }
153
+ throw error;
126
154
  }
127
155
 
128
156
  logger.info(
@@ -1,3 +1,12 @@
1
+ const { mapLimit } = require('../utils/concurrency.js');
2
+
3
+ /**
4
+ * Simultaneous checksum resolutions. Each one is a file read — an unbounded
5
+ * fan-out over a thousands-record legacy changelog would exhaust the
6
+ * descriptor limit (EMFILE), the exact hazard mapLimit exists for.
7
+ */
8
+ const CHECKSUM_CONCURRENCY = 16;
9
+
1
10
  /** Returns true when a value looks like a usable migrate-mongo changelog doc */
2
11
  function isMigrateMongoDoc(value) {
3
12
  return (
@@ -30,13 +39,11 @@ async function mapMigrateMongoDocs(docs, options) {
30
39
  return delta !== 0 ? delta : a.fileName.localeCompare(b.fileName);
31
40
  });
32
41
 
33
- // Independent per-doc disk reads — resolve them concurrently rather than
34
- // one at a time.
35
- const checksumPromises = [];
36
- for (const doc of sorted) {
37
- checksumPromises.push(options.resolveChecksum(doc.fileName, doc.fileHash));
38
- }
39
- const resolutions = await Promise.all(checksumPromises);
42
+ // Independent per-doc disk reads — resolved concurrently, but bounded:
43
+ // mapLimit preserves input order exactly like Promise.all would.
44
+ const resolutions = await mapLimit(sorted, CHECKSUM_CONCURRENCY, (doc) =>
45
+ options.resolveChecksum(doc.fileName, doc.fileHash),
46
+ );
40
47
 
41
48
  const records = [];
42
49
  for (let index = 0; index < sorted.length; index++) {
@@ -0,0 +1,496 @@
1
+ const { deepEqual, isPlainObject, regExpIssue, toWire } = require('../utils/canonical.js');
2
+
3
+ /**
4
+ * One declared index against one live index: validation, normalization, and
5
+ * the comparison the converge planner is built on. Pure — no database, no
6
+ * I/O — so every rule here is pinned by a table-driven unit test.
7
+ *
8
+ * The one invariant every rule must keep: an index created from a declaration
9
+ * must compare as unchanged against that same declaration once the server has
10
+ * stored it. A rule that breaks it rebuilds the index on every run.
11
+ */
12
+
13
+ const TEXT = 'text';
14
+ const DIRECTIONS = new Set([1, -1, 'text', 'hashed', '2d', '2dsphere']);
15
+
16
+ /** Options whose absence means the server's default — compared even when not declared */
17
+ const SEMANTIC_OPTIONS = [
18
+ 'unique',
19
+ 'sparse',
20
+ 'hidden',
21
+ 'expireAfterSeconds',
22
+ 'partialFilterExpression',
23
+ 'collation',
24
+ 'wildcardProjection',
25
+ 'weights',
26
+ 'default_language',
27
+ 'language_override',
28
+ ];
29
+
30
+ /** Compared only when the declaration states them — the server fills in its own otherwise */
31
+ const DECLARED_ONLY_OPTIONS = [
32
+ 'bits',
33
+ 'min',
34
+ 'max',
35
+ 'storageEngine',
36
+ 'textIndexVersion',
37
+ '2dsphereIndexVersion',
38
+ ];
39
+
40
+ /** Accepted so a spec copied from `listIndexes` validates, never sent: a no-op since MongoDB 4.2 */
41
+ const IGNORED_OPTIONS = ['background'];
42
+
43
+ /**
44
+ * Every key an index declaration may carry. Strict on purpose: the driver
45
+ * silently drops an option it does not know, so `uniqe: true` would build a
46
+ * non-unique index — and then compare as in sync forever.
47
+ */
48
+ const INDEX_KEYS = new Set([
49
+ 'key',
50
+ 'name',
51
+ ...SEMANTIC_OPTIONS,
52
+ ...DECLARED_ONLY_OPTIONS,
53
+ ...IGNORED_OPTIONS,
54
+ ]);
55
+
56
+ const BOOLEAN_OPTIONS = new Set(['unique', 'sparse', 'hidden', 'background']);
57
+ const OBJECT_OPTIONS = new Set([
58
+ 'partialFilterExpression',
59
+ 'wildcardProjection',
60
+ 'weights',
61
+ 'storageEngine',
62
+ ]);
63
+ const TEXT_ONLY_OPTIONS = new Set([
64
+ 'weights',
65
+ 'default_language',
66
+ 'language_override',
67
+ 'textIndexVersion',
68
+ ]);
69
+ const TEXT_DEFAULTS = { default_language: 'english', language_override: 'language' };
70
+
71
+ /** Field names a plain object moves to the front, whatever order they were written in */
72
+ const INTEGER_LIKE = /^(?:0|[1-9]\d*)$/;
73
+
74
+ /** Whether `entries` name their fields in exactly the order of `fields` */
75
+ function sameFieldOrder(entries, fields) {
76
+ return entries.every(([field], position) => fields[position] === field);
77
+ }
78
+
79
+ /** `[field, direction]` pairs, in the order the index uses them */
80
+ function keyEntries(key) {
81
+ return key instanceof Map ? [...key.entries()] : Object.entries(key);
82
+ }
83
+
84
+ /** The name MongoDB (and the driver) gives an index declared without one: `email_1`, `a_1_b_-1` */
85
+ function defaultIndexName(entries) {
86
+ return entries.map(([field, direction]) => `${field}_${direction}`).join('_');
87
+ }
88
+
89
+ /** Issues with an index key — `null` entries when the key is unusable */
90
+ function keyIssues(key, report) {
91
+ if (!(key instanceof Map) && !isPlainObject(key)) {
92
+ report('must be an object (or a Map) of field → direction');
93
+ return null;
94
+ }
95
+ const entries = keyEntries(key);
96
+ if (entries.length === 0) {
97
+ report('must name at least one field');
98
+ return null;
99
+ }
100
+ let usable = true;
101
+ for (const [field, direction] of entries) {
102
+ if (typeof field !== 'string' || field.length === 0) {
103
+ report('field names must be non-empty strings');
104
+ usable = false;
105
+ } else if (!DIRECTIONS.has(direction)) {
106
+ report(`direction of "${field}" must be 1, -1, 'text', 'hashed', '2d' or '2dsphere'`);
107
+ usable = false;
108
+ }
109
+ }
110
+ if (usable && entries.length > 1 && entries.some(([field]) => INTEGER_LIKE.test(field))) {
111
+ if (!(key instanceof Map)) {
112
+ report(
113
+ 'has an integer-like field name — JavaScript reorders such keys, so declare this key ' +
114
+ 'as a Map to keep the field order',
115
+ );
116
+ usable = false;
117
+ } else if (!sameFieldOrder(entries, Object.keys(Object.fromEntries(entries)))) {
118
+ // The server keeps the order, but the driver reads `listIndexes` back
119
+ // into a plain object, where integer-like fields move to the front: the
120
+ // live key could never compare as the declared one, and the index would
121
+ // be rebuilt on every run.
122
+ report(
123
+ 'puts an integer-like field after another field — the server reports such a key ' +
124
+ 'reordered, so converge could never see it as unchanged; manage this index in a ' +
125
+ 'migration',
126
+ );
127
+ usable = false;
128
+ }
129
+ }
130
+ if (entries.length === 1 && entries[0][0] === '_id' && entries[0][1] === 1) {
131
+ report('is the _id index, which every collection already has');
132
+ usable = false;
133
+ }
134
+ return usable ? entries : null;
135
+ }
136
+
137
+ /** Validate one index declaration, returning `{ path, message }` issues (empty when valid) */
138
+ function indexIssues(index, path) {
139
+ if (!isPlainObject(index)) {
140
+ return [{ path, message: 'must be an index object ({ key, ...options })' }];
141
+ }
142
+ const issues = [];
143
+ const report = (key) => (message) => issues.push({ path: `${path}.${key}`, message });
144
+ for (const key of Object.keys(index)) {
145
+ if (!INDEX_KEYS.has(key)) report(key)('is not a supported index option');
146
+ }
147
+ const entries = keyIssues(index.key, report('key'));
148
+ const isText = entries?.some(([, direction]) => direction === TEXT) ?? false;
149
+ // `wildcardProjection` belongs to an all-fields wildcard (`$**`, compound or
150
+ // not) — the server refuses it on a path wildcard such as `a.$**`.
151
+ const isAllFieldsWildcard = entries?.some(([field]) => field === '$**') ?? false;
152
+
153
+ if (index.name !== undefined) {
154
+ if (typeof index.name !== 'string' || index.name.length === 0) {
155
+ report('name')('must be a non-empty string');
156
+ } else if (index.name === '_id_') {
157
+ report('name')('is reserved for the _id index');
158
+ }
159
+ }
160
+ for (const option of BOOLEAN_OPTIONS) {
161
+ if (index[option] !== undefined && typeof index[option] !== 'boolean') {
162
+ report(option)('must be a boolean');
163
+ }
164
+ }
165
+ for (const option of OBJECT_OPTIONS) {
166
+ if (index[option] !== undefined && !isPlainObject(index[option])) {
167
+ report(option)('must be an object');
168
+ }
169
+ }
170
+ const filterIssue = regExpIssue(index.partialFilterExpression);
171
+ if (filterIssue) report('partialFilterExpression')(filterIssue);
172
+ const ttl = index.expireAfterSeconds;
173
+ if (ttl !== undefined && (!Number.isSafeInteger(ttl) || ttl < 0)) {
174
+ report('expireAfterSeconds')('must be a non-negative integer (seconds)');
175
+ }
176
+ const collation = index.collation;
177
+ if (
178
+ collation !== undefined &&
179
+ (!isPlainObject(collation) || typeof collation.locale !== 'string' || collation.locale === '')
180
+ ) {
181
+ report('collation')('must be an object with a locale');
182
+ }
183
+ for (const option of ['default_language', 'language_override']) {
184
+ const value = index[option];
185
+ if (value !== undefined && (typeof value !== 'string' || value.length === 0)) {
186
+ report(option)('must be a non-empty string');
187
+ }
188
+ }
189
+ for (const option of ['textIndexVersion', '2dsphereIndexVersion', 'bits']) {
190
+ const value = index[option];
191
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) {
192
+ report(option)('must be a positive integer');
193
+ }
194
+ }
195
+ for (const option of ['min', 'max']) {
196
+ if (index[option] !== undefined && !Number.isFinite(index[option])) {
197
+ report(option)('must be a finite number');
198
+ }
199
+ }
200
+ if (entries) {
201
+ for (const option of TEXT_ONLY_OPTIONS) {
202
+ if (index[option] !== undefined && !isText) report(option)('only applies to a text index');
203
+ }
204
+ if (index.wildcardProjection !== undefined && !isAllFieldsWildcard) {
205
+ report('wildcardProjection')('only applies to an all-fields wildcard ($**) index');
206
+ }
207
+ }
208
+ return issues;
209
+ }
210
+
211
+ /**
212
+ * The key as the server stores it. A text index is not stored under its
213
+ * fields: they collapse into `_fts: 'text', _ftsx: 1` at the position of the
214
+ * first text field, and move into `weights`.
215
+ */
216
+ function serverKeyOf(entries) {
217
+ if (!entries.some(([, direction]) => direction === TEXT)) return entries;
218
+ const out = [];
219
+ let placed = false;
220
+ for (const entry of entries) {
221
+ if (entry[1] !== TEXT) {
222
+ out.push(entry);
223
+ } else if (!placed) {
224
+ out.push(['_fts', TEXT], ['_ftsx', 1]);
225
+ placed = true;
226
+ }
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /** Text fields at weight 1, then whatever the declaration weighs differently */
232
+ function textWeights(entries, declared) {
233
+ const weights = {};
234
+ for (const [field, direction] of entries) {
235
+ if (direction === TEXT) weights[field] = 1;
236
+ }
237
+ return { ...weights, ...declared };
238
+ }
239
+
240
+ /**
241
+ * A declaration in comparable form, plus the exact spec to send. Assumes
242
+ * {@link indexIssues} passed.
243
+ */
244
+ function normalizeDeclaredIndex(index) {
245
+ const entries = keyEntries(index.key);
246
+ const isText = entries.some(([, direction]) => direction === TEXT);
247
+ const name = index.name ?? defaultIndexName(entries);
248
+ const options = {};
249
+ for (const option of [...SEMANTIC_OPTIONS, ...DECLARED_ONLY_OPTIONS]) {
250
+ const value = index[option];
251
+ if (value === undefined) continue;
252
+ // `false` is the server default; sending it would only make the stored
253
+ // spec differ from one created without it (`sparse: false` is kept
254
+ // verbatim by the server).
255
+ if (BOOLEAN_OPTIONS.has(option) && value === false) continue;
256
+ options[option] = toWire(value);
257
+ }
258
+ return {
259
+ name,
260
+ entries,
261
+ serverKey: serverKeyOf(entries),
262
+ isText,
263
+ options,
264
+ // The key travels as a Map: the field order is the index, and the driver
265
+ // keeps a Map's order where it would re-read a plain object's.
266
+ spec: { key: new Map(entries), name, ...options },
267
+ };
268
+ }
269
+
270
+ /** A `listIndexes` document in comparable form */
271
+ function normalizeLiveIndex(raw) {
272
+ const entries = Object.entries(raw.key ?? {});
273
+ const options = {};
274
+ for (const option of [...SEMANTIC_OPTIONS, ...DECLARED_ONLY_OPTIONS]) {
275
+ const value = raw[option];
276
+ if (value === undefined) continue;
277
+ if (BOOLEAN_OPTIONS.has(option)) {
278
+ // `1` is how some older tools (and shells) stored a flag.
279
+ if (value === true || value === 1) options[option] = true;
280
+ continue;
281
+ }
282
+ options[option] = value;
283
+ }
284
+ return {
285
+ name: raw.name,
286
+ serverKey: entries,
287
+ isText: entries.some(([field, direction]) => field === '_fts' && direction === TEXT),
288
+ options,
289
+ raw,
290
+ };
291
+ }
292
+
293
+ /** Number directions compare by sign — a shell of old stored `1.0`, and Int32/Double wrappers happen */
294
+ function sameDirection(a, b) {
295
+ if (typeof a === 'string' || typeof b === 'string') return a === b;
296
+ return Math.sign(Number(a)) === Math.sign(Number(b));
297
+ }
298
+
299
+ function sameKey(declared, live) {
300
+ const a = declared.serverKey;
301
+ const b = live.serverKey;
302
+ if (a.length !== b.length) return false;
303
+ for (let i = 0; i < a.length; i++) {
304
+ if (a[i][0] !== b[i][0] || !sameDirection(a[i][1], b[i][1])) return false;
305
+ }
306
+ return true;
307
+ }
308
+
309
+ /**
310
+ * Collation fields whose default is the same for every locale. A declaration
311
+ * that leaves one out means this value — so a live index built with another
312
+ * (`strength: 2`) is a difference, not a match. Every other field (`caseFirst`,
313
+ * `alternate`, `backwards`, `normalization`, …) has locale-specific defaults
314
+ * and is compared only when declared.
315
+ */
316
+ const UNIVERSAL_COLLATION_DEFAULTS = Object.freeze({
317
+ strength: 3,
318
+ caseLevel: false,
319
+ numericOrdering: false,
320
+ });
321
+
322
+ /** A declared collation with the universal defaults filled in */
323
+ function withCollationDefaults(collation) {
324
+ if (collation === undefined || collation.locale === 'simple') return collation;
325
+ const filled = { ...UNIVERSAL_COLLATION_DEFAULTS };
326
+ for (const [field, value] of Object.entries(collation)) {
327
+ if (value !== undefined) filled[field] = value;
328
+ }
329
+ return filled;
330
+ }
331
+
332
+ /**
333
+ * Whether the live index's collation is what the declaration asks for.
334
+ *
335
+ * - none declared: the index inherits the collection's default collation —
336
+ * which the server then writes onto the index, so the live value must be
337
+ * exactly that default (or absent when the collection has none);
338
+ * - `{ locale: 'simple' }`: binary comparison, stored as no collation at all;
339
+ * - anything else: every declared field must match, and every field with a
340
+ * locale-independent default ({@link UNIVERSAL_COLLATION_DEFAULTS}) must
341
+ * hold that default when not declared. The rest is a subset match because
342
+ * the server expands `{ locale: 'fr' }` into a full ICU spec with
343
+ * locale-specific defaults — guessing those here would rebuild forever on
344
+ * the first locale whose defaults differ from the guess.
345
+ */
346
+ function collationMatches(declared, liveCollation, defaultCollation) {
347
+ if (declared === undefined) {
348
+ if (defaultCollation === undefined) return liveCollation === undefined;
349
+ return liveCollation !== undefined && deepEqual(liveCollation, defaultCollation);
350
+ }
351
+ if (declared.locale === 'simple') return liveCollation === undefined;
352
+ if (liveCollation === undefined) return false;
353
+ for (const [field, value] of Object.entries(withCollationDefaults(declared))) {
354
+ // A server that omits a universal field reports its default.
355
+ const stored = liveCollation[field] ?? UNIVERSAL_COLLATION_DEFAULTS[field];
356
+ if (!deepEqual(value, stored)) return false;
357
+ }
358
+ return true;
359
+ }
360
+
361
+ /**
362
+ * Same key, same partial filter, same collation: the server treats two such
363
+ * indexes as one and refuses the second (IndexOptionsConflict), whatever
364
+ * their names.
365
+ */
366
+ function sameSignature(declared, live, defaultCollation) {
367
+ return (
368
+ sameKey(declared, live) &&
369
+ deepEqual(declared.options.partialFilterExpression, live.options.partialFilterExpression) &&
370
+ collationMatches(declared.options.collation, live.options.collation, defaultCollation)
371
+ );
372
+ }
373
+
374
+ /**
375
+ * Whether two valid declarations describe one index to the server — the
376
+ * check that refuses a definition declaring the same index twice. Collations
377
+ * compare with their universal defaults filled in (`{ locale: 'en' }` and
378
+ * `{ locale: 'en', strength: 3 }` are one index); `{ locale: 'simple' }` and
379
+ * no collation stay apart, since they differ on a collection with a default
380
+ * collation, which a definition cannot know.
381
+ */
382
+ function sameDeclaredSignature(a, b) {
383
+ return (
384
+ sameKey(a, b) &&
385
+ deepEqual(a.options.partialFilterExpression, b.options.partialFilterExpression) &&
386
+ deepEqual(
387
+ withCollationDefaults(a.options.collation),
388
+ withCollationDefaults(b.options.collation),
389
+ )
390
+ );
391
+ }
392
+
393
+ /**
394
+ * What the server can change in place, by version — `{ major, minor }` from
395
+ * `buildInfo`, or undefined when unknown (then nothing beyond the always-
396
+ * available `hidden` and TTL change).
397
+ */
398
+ function inPlaceCapabilities(version) {
399
+ const atLeast = (major, minor) =>
400
+ version !== undefined &&
401
+ (version.major > major || (version.major === major && version.minor >= minor));
402
+ return {
403
+ // collMod prepareUnique → unique: a non-unique index becomes unique
404
+ // without being dropped — no window without it, no second scan. The
405
+ // commands exist since 6.0, but a 6.0 server reported the conversion and
406
+ // went on accepting duplicates in our tests; 7.0 is where it is enforced.
407
+ unique: atLeast(7, 0),
408
+ // collMod expireAfterSeconds on a single-field index that has no TTL yet.
409
+ addTtl: atLeast(5, 1),
410
+ };
411
+ }
412
+
413
+ /**
414
+ * Compare a declaration with the live index it is paired with.
415
+ *
416
+ * Returns `{ diffs, inPlace }`: `diffs` lists every option that differs,
417
+ * `inPlace` the subset `collMod` can change without a rebuild — the TTL of an
418
+ * index that already has one, and `hidden`; with `capabilities` (see
419
+ * {@link inPlaceCapabilities}) also making an index unique and adding a TTL.
420
+ * Everything else needs the index dropped and created again.
421
+ */
422
+ function compareIndex(declared, live, defaultCollation, capabilities = {}) {
423
+ const diffs = [];
424
+ const inPlace = {};
425
+ if (!sameKey(declared, live)) diffs.push('key');
426
+ const d = declared.options;
427
+ const l = live.options;
428
+ if (Boolean(d.unique) !== Boolean(l.unique)) {
429
+ diffs.push('unique');
430
+ // Only towards unique: the server converts that way, not back.
431
+ if (d.unique && capabilities.unique) inPlace.unique = true;
432
+ }
433
+ if (Boolean(d.sparse) !== Boolean(l.sparse)) diffs.push('sparse');
434
+ if (Boolean(d.hidden) !== Boolean(l.hidden)) {
435
+ diffs.push('hidden');
436
+ inPlace.hidden = Boolean(d.hidden);
437
+ }
438
+ const ttlDeclared = d.expireAfterSeconds;
439
+ const ttlLive = l.expireAfterSeconds;
440
+ if (ttlDeclared !== undefined || ttlLive !== undefined) {
441
+ if (ttlDeclared === undefined || ttlLive === undefined) {
442
+ diffs.push('expireAfterSeconds');
443
+ const singleField = declared.serverKey.length === 1 && !declared.isText;
444
+ if (ttlLive === undefined && capabilities.addTtl && singleField) {
445
+ inPlace.expireAfterSeconds = Number(ttlDeclared);
446
+ }
447
+ } else if (Number(ttlDeclared) !== Number(ttlLive)) {
448
+ diffs.push('expireAfterSeconds');
449
+ inPlace.expireAfterSeconds = Number(ttlDeclared);
450
+ }
451
+ }
452
+ if (!deepEqual(d.partialFilterExpression, l.partialFilterExpression)) {
453
+ diffs.push('partialFilterExpression');
454
+ }
455
+ if (!collationMatches(d.collation, l.collation, defaultCollation)) diffs.push('collation');
456
+ if (!deepEqual(d.wildcardProjection, l.wildcardProjection)) diffs.push('wildcardProjection');
457
+ if (declared.isText || live.isText) {
458
+ const weights = declared.isText ? textWeights(declared.entries, d.weights) : undefined;
459
+ if (!deepEqual(weights, l.weights)) diffs.push('weights');
460
+ for (const option of ['default_language', 'language_override']) {
461
+ const want = declared.isText ? (d[option] ?? TEXT_DEFAULTS[option]) : undefined;
462
+ if (want !== l[option]) diffs.push(option);
463
+ }
464
+ }
465
+ for (const option of DECLARED_ONLY_OPTIONS) {
466
+ if (d[option] !== undefined && !deepEqual(d[option], l[option])) diffs.push(option);
467
+ }
468
+ const rebuild = diffs.some((diff) => !(diff in inPlace));
469
+ return { diffs, inPlace: rebuild ? {} : inPlace, rebuild };
470
+ }
471
+
472
+ /**
473
+ * A live index as a spec `createIndexes` accepts — to put back an index whose
474
+ * replacement failed to build. The server-managed fields are left out; the
475
+ * driver drops anything else it does not know.
476
+ */
477
+ function restoreSpec(raw) {
478
+ const { v, ns, background, clustered, ...spec } = raw;
479
+ return spec;
480
+ }
481
+
482
+ module.exports = {
483
+ DIRECTIONS,
484
+ inPlaceCapabilities,
485
+ INDEX_KEYS,
486
+ SEMANTIC_OPTIONS,
487
+ compareIndex,
488
+ defaultIndexName,
489
+ indexIssues,
490
+ keyEntries,
491
+ normalizeDeclaredIndex,
492
+ normalizeLiveIndex,
493
+ restoreSpec,
494
+ sameDeclaredSignature,
495
+ sameSignature,
496
+ };