@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,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
|
+
};
|