@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,686 @@
|
|
|
1
|
+
const { deepEqual } = require('../utils/canonical.js');
|
|
2
|
+
const { compareIndex, normalizeLiveIndex, restoreSpec, sameSignature } = require('./index-spec.js');
|
|
3
|
+
const {
|
|
4
|
+
compareSearchIndex,
|
|
5
|
+
isBeingRemoved,
|
|
6
|
+
normalizeLiveSearchIndex,
|
|
7
|
+
searchBuild,
|
|
8
|
+
searchIndexSpec,
|
|
9
|
+
searchIndexValue,
|
|
10
|
+
} = require('./search-index-spec.js');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The converge planner: one declared collection against its live state, as
|
|
14
|
+
* result rows (what will happen and why) and executable steps (how). Pure, so
|
|
15
|
+
* the whole decision table is unit-tested without a database; converge.js
|
|
16
|
+
* only reads the live state in and carries the steps out.
|
|
17
|
+
*
|
|
18
|
+
* The rules, in short:
|
|
19
|
+
* - declared indexes pair with live ones by name, then compare: unchanged, a
|
|
20
|
+
* `collMod` (TTL, hidden), or a rebuild;
|
|
21
|
+
* - an index the server would refuse because an undeclared one already
|
|
22
|
+
* covers the same key under another name is never resolved by dropping that
|
|
23
|
+
* index unless `prune` is on — an identical one is accepted as is, a
|
|
24
|
+
* different one is a conflict that refuses the run;
|
|
25
|
+
* - undeclared indexes are kept (and reported) unless `prune` is on;
|
|
26
|
+
* - a rebuild that drops a unique index to build a unique one back is a
|
|
27
|
+
* conflict unless `rebuildUnique` is on — see UNIQUE_REBUILD_REASON;
|
|
28
|
+
* - `_id_` and a clustered index are the collection's own and never listed;
|
|
29
|
+
* - a search index is created or updated in place — never dropped to be built
|
|
30
|
+
* again: a search against a missing index returns nothing rather than fail,
|
|
31
|
+
* so a rebuild would be a silent outage. What no update can change (the
|
|
32
|
+
* type, an autoEmbed field's model or size) is a conflict instead.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Actions that change the database — what a plan "would do" and a run "did" */
|
|
36
|
+
const CHANGE_ACTIONS = new Set(['create', 'modify', 'recreate', 'drop']);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Actions of a declared search index that leaves it on the server — the
|
|
40
|
+
* ones whose build is worth reporting, or waiting for
|
|
41
|
+
*/
|
|
42
|
+
const SERVED_ACTIONS = new Set(['create', 'modify', 'unchanged']);
|
|
43
|
+
|
|
44
|
+
/** A row's target as people read it — where the code's name is not plain English */
|
|
45
|
+
const TARGET_LABELS = Object.freeze({ searchIndex: 'search index' });
|
|
46
|
+
|
|
47
|
+
/** Server defaults for a collection that has a validator but did not say how to apply it */
|
|
48
|
+
const VALIDATOR_DEFAULTS = { validationLevel: 'strict', validationAction: 'error' };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether a row drops or rebuilds an index — or drops a search index (a
|
|
52
|
+
* search index is updated in place, never rebuilt: the old one serves until
|
|
53
|
+
* the new definition is built).
|
|
54
|
+
*/
|
|
55
|
+
function isDestructive(action) {
|
|
56
|
+
if (action.target === 'searchIndex') return action.action === 'drop';
|
|
57
|
+
return action.target === 'index' && (action.action === 'drop' || action.action === 'recreate');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a row is one the CLI asks to confirm: a destructive index change,
|
|
62
|
+
* or a validator change on a collection that already holds data — tightening
|
|
63
|
+
* a validator (`validationAction: 'error'`) can start rejecting the
|
|
64
|
+
* application's writes. A validator created with a new collection guards
|
|
65
|
+
* nothing yet.
|
|
66
|
+
*/
|
|
67
|
+
function needsConfirmation(action, collectionActions = []) {
|
|
68
|
+
if (isDestructive(action)) return true;
|
|
69
|
+
if (action.target !== 'validator' || !CHANGE_ACTIONS.has(action.action)) return false;
|
|
70
|
+
const created = collectionActions.some(
|
|
71
|
+
(other) => other.target === 'collection' && other.action === 'create',
|
|
72
|
+
);
|
|
73
|
+
return !created;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* An index as data for a result row (`from` / `to`): plain JSON, the key as
|
|
78
|
+
* an object, without the server-managed `v`/`ns` — what a reader of `--json`
|
|
79
|
+
* or of the converge history needs to see what changed.
|
|
80
|
+
*/
|
|
81
|
+
function indexValue(spec) {
|
|
82
|
+
const { key, v: _v, ns: _ns, background: _background, ...rest } = spec;
|
|
83
|
+
return { key: key instanceof Map ? Object.fromEntries(key) : { ...key }, ...rest };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const isEmptyValidator = (validator) =>
|
|
87
|
+
validator === null || (typeof validator === 'object' && Object.keys(validator).length === 0);
|
|
88
|
+
|
|
89
|
+
/** The validator state a definition asks for: `undefined` = unmanaged, `null` = none */
|
|
90
|
+
function desiredValidator(definition) {
|
|
91
|
+
if (definition.validator === undefined) return undefined;
|
|
92
|
+
if (isEmptyValidator(definition.validator)) return null;
|
|
93
|
+
return {
|
|
94
|
+
validator: definition.validator,
|
|
95
|
+
validationLevel: definition.validationLevel ?? VALIDATOR_DEFAULTS.validationLevel,
|
|
96
|
+
validationAction: definition.validationAction ?? VALIDATOR_DEFAULTS.validationAction,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The validator a collection has (`null` = none). Level and action are omitted until set. */
|
|
101
|
+
function liveValidator(options) {
|
|
102
|
+
const validator = options?.validator;
|
|
103
|
+
if (validator === undefined || validator === null || isEmptyValidator(validator)) return null;
|
|
104
|
+
return {
|
|
105
|
+
validator,
|
|
106
|
+
validationLevel: options.validationLevel ?? VALIDATOR_DEFAULTS.validationLevel,
|
|
107
|
+
validationAction: options.validationAction ?? VALIDATOR_DEFAULTS.validationAction,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function planValidator(name, desired, current, row, steps) {
|
|
112
|
+
if (desired === null) {
|
|
113
|
+
if (current === null) {
|
|
114
|
+
row({ target: 'validator', name, action: 'unchanged' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const action = row({ target: 'validator', name, action: 'drop', from: current });
|
|
118
|
+
steps.push({ op: 'collMod', command: { validator: {} }, actions: [action] });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (current === null) {
|
|
122
|
+
const action = row({ target: 'validator', name, action: 'create', to: desired });
|
|
123
|
+
steps.push({ op: 'collMod', command: { ...desired }, actions: [action] });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const diffs = [];
|
|
127
|
+
if (!deepEqual(desired.validator, current.validator)) diffs.push('validator');
|
|
128
|
+
if (desired.validationLevel !== current.validationLevel) diffs.push('validationLevel');
|
|
129
|
+
if (desired.validationAction !== current.validationAction) diffs.push('validationAction');
|
|
130
|
+
if (diffs.length === 0) {
|
|
131
|
+
row({ target: 'validator', name, action: 'unchanged' });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const action = row({
|
|
135
|
+
target: 'validator',
|
|
136
|
+
name,
|
|
137
|
+
action: 'modify',
|
|
138
|
+
reason: diffs.join(', '),
|
|
139
|
+
from: current,
|
|
140
|
+
to: desired,
|
|
141
|
+
});
|
|
142
|
+
steps.push({ op: 'collMod', command: { ...desired }, actions: [action] });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The reason a rebuild is refused: it drops a unique index and builds a
|
|
147
|
+
* unique one back, so the constraint is gone until the build ends. A write in
|
|
148
|
+
* between can add a duplicate — and then neither the new index nor the old
|
|
149
|
+
* one can be built again, leaving the collection with no unique index at all.
|
|
150
|
+
*/
|
|
151
|
+
const UNIQUE_REBUILD_REASON =
|
|
152
|
+
'rebuilding drops the unique constraint until the new index is built — a duplicate written ' +
|
|
153
|
+
'in between leaves neither index buildable; declare it under a new name (converge, then ' +
|
|
154
|
+
'remove the old declaration and converge with prune), or converge with rebuildUnique ' +
|
|
155
|
+
'(CLI: --rebuild-unique)';
|
|
156
|
+
|
|
157
|
+
/** Whether a rebuild would open a window without a uniqueness the declaration keeps */
|
|
158
|
+
function dropsUniqueConstraint(declared, drops) {
|
|
159
|
+
return declared.options.unique === true && drops.some((index) => index.options.unique === true);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether a live index backs the collection's shard key (its key begins with
|
|
164
|
+
* every shard-key field, in order) — the server refuses to drop it, so prune
|
|
165
|
+
* leaves it alone instead of failing on it last.
|
|
166
|
+
*/
|
|
167
|
+
function backsShardKey(index, shardKey) {
|
|
168
|
+
if (!shardKey) return false;
|
|
169
|
+
let position = 0;
|
|
170
|
+
for (const field of Object.keys(shardKey)) {
|
|
171
|
+
if (index.serverKey[position]?.[0] !== field) return false;
|
|
172
|
+
position += 1;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** `first; second`, or `second` alone when there is no first */
|
|
178
|
+
function joinReasons(first, second) {
|
|
179
|
+
return first ? `${first}; ${second}` : second;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function planIndexes(declaredIndexes, live, { prune, rebuildUnique, capabilities }, row, steps) {
|
|
183
|
+
const defaultCollation = live.options?.collation;
|
|
184
|
+
const liveIndexes = [];
|
|
185
|
+
for (const raw of live.indexes) {
|
|
186
|
+
if (raw.name === '_id_' || raw.clustered === true) continue;
|
|
187
|
+
liveIndexes.push(normalizeLiveIndex(raw));
|
|
188
|
+
}
|
|
189
|
+
const byName = new Map();
|
|
190
|
+
for (const index of liveIndexes) byName.set(index.name, index);
|
|
191
|
+
const declaredNames = new Set();
|
|
192
|
+
for (const declared of declaredIndexes) declaredNames.add(declared.name);
|
|
193
|
+
|
|
194
|
+
// Live indexes already accounted for — paired by name, or claimed as the
|
|
195
|
+
// blocker of a declaration — so none of them is also listed as an extra.
|
|
196
|
+
const consumed = new Set();
|
|
197
|
+
const creates = [];
|
|
198
|
+
const modifies = [];
|
|
199
|
+
const pending = [];
|
|
200
|
+
|
|
201
|
+
// Rows are made in declaration order; a pending one is settled below, once
|
|
202
|
+
// the blockers are known.
|
|
203
|
+
for (const declared of declaredIndexes) {
|
|
204
|
+
const current = byName.get(declared.name);
|
|
205
|
+
if (!current) {
|
|
206
|
+
const action = row({
|
|
207
|
+
target: 'index',
|
|
208
|
+
name: declared.name,
|
|
209
|
+
action: 'create',
|
|
210
|
+
to: indexValue(declared.spec),
|
|
211
|
+
});
|
|
212
|
+
pending.push({ declared, live: undefined, reason: undefined, action });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
consumed.add(current.name);
|
|
216
|
+
const { diffs, inPlace, rebuild } = compareIndex(
|
|
217
|
+
declared,
|
|
218
|
+
current,
|
|
219
|
+
defaultCollation,
|
|
220
|
+
capabilities,
|
|
221
|
+
);
|
|
222
|
+
if (diffs.length === 0) {
|
|
223
|
+
row({ target: 'index', name: declared.name, action: 'unchanged' });
|
|
224
|
+
} else if (!rebuild) {
|
|
225
|
+
const action = row({
|
|
226
|
+
target: 'index',
|
|
227
|
+
name: declared.name,
|
|
228
|
+
action: 'modify',
|
|
229
|
+
reason: diffs.join(', '),
|
|
230
|
+
from: indexValue(current.raw),
|
|
231
|
+
to: indexValue(declared.spec),
|
|
232
|
+
});
|
|
233
|
+
const { unique, ...rest } = inPlace;
|
|
234
|
+
modifies.push(
|
|
235
|
+
unique
|
|
236
|
+
? // Two collMods: prepareUnique (no new duplicates from here on),
|
|
237
|
+
// then unique (checks the existing data) — see converge.js.
|
|
238
|
+
{ op: 'convertUnique', name: declared.name, rest, actions: [action] }
|
|
239
|
+
: {
|
|
240
|
+
op: 'collMod',
|
|
241
|
+
command: { index: { name: declared.name, ...rest } },
|
|
242
|
+
actions: [action],
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
} else {
|
|
246
|
+
const reason = diffs.join(', ');
|
|
247
|
+
const action = row({
|
|
248
|
+
target: 'index',
|
|
249
|
+
name: declared.name,
|
|
250
|
+
action: 'recreate',
|
|
251
|
+
reason,
|
|
252
|
+
from: indexValue(current.raw),
|
|
253
|
+
to: indexValue(declared.spec),
|
|
254
|
+
});
|
|
255
|
+
pending.push({ declared, live: current, reason, action });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const collides = (declared, index) =>
|
|
260
|
+
sameSignature(declared, index, defaultCollation) || (declared.isText && index.isText);
|
|
261
|
+
|
|
262
|
+
// A declaration that the server would refuse next to an undeclared index:
|
|
263
|
+
// same key, filter and collation under another name, or a second text index.
|
|
264
|
+
const undeclared = [];
|
|
265
|
+
for (const index of liveIndexes) {
|
|
266
|
+
if (!declaredNames.has(index.name)) undeclared.push(index);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const rebuilds = [];
|
|
270
|
+
for (const item of pending) {
|
|
271
|
+
const { declared, action } = item;
|
|
272
|
+
const blockers = [];
|
|
273
|
+
for (const index of undeclared) {
|
|
274
|
+
if (!consumed.has(index.name) && collides(declared, index)) blockers.push(index);
|
|
275
|
+
}
|
|
276
|
+
for (const blocker of blockers) consumed.add(blocker.name);
|
|
277
|
+
const drops = item.live ? [item.live] : [];
|
|
278
|
+
|
|
279
|
+
if (blockers.length > 0) {
|
|
280
|
+
const names = blockers.map((blocker) => `"${blocker.name}"`).join(', ');
|
|
281
|
+
const identical =
|
|
282
|
+
!item.live &&
|
|
283
|
+
blockers.length === 1 &&
|
|
284
|
+
compareIndex(declared, blockers[0], defaultCollation).diffs.length === 0;
|
|
285
|
+
action.liveName = blockers[0].name;
|
|
286
|
+
action.from = indexValue(blockers[0].raw);
|
|
287
|
+
if (!prune) {
|
|
288
|
+
if (identical) {
|
|
289
|
+
// The index the declaration describes exists, only under another
|
|
290
|
+
// name. Renaming means a full rebuild (and, for a unique index, a
|
|
291
|
+
// window without the constraint) — not something to do unasked.
|
|
292
|
+
action.action = 'unchanged';
|
|
293
|
+
action.reason = `exists as ${names}`;
|
|
294
|
+
} else {
|
|
295
|
+
action.action = 'conflict';
|
|
296
|
+
action.reason =
|
|
297
|
+
`the undeclared index ${names} covers the same key — declare it under its own ` +
|
|
298
|
+
'name, or converge with prune to replace it';
|
|
299
|
+
}
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
drops.push(...blockers);
|
|
303
|
+
action.action = 'recreate';
|
|
304
|
+
action.reason = identical ? 'name' : joinReasons(item.reason, `replaces ${names}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (drops.length === 0) {
|
|
308
|
+
creates.push({ declared, action });
|
|
309
|
+
} else if (!rebuildUnique && dropsUniqueConstraint(declared, drops)) {
|
|
310
|
+
// Not done unasked: the CLI asks with --rebuild-unique, while the paths
|
|
311
|
+
// nobody watches (after up, a queue job) never get this far on their own.
|
|
312
|
+
action.action = 'conflict';
|
|
313
|
+
action.reason = joinReasons(action.reason, UNIQUE_REBUILD_REASON);
|
|
314
|
+
} else {
|
|
315
|
+
rebuilds.push({ declared, action, drops });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// A create that collides with an index another rebuild is about to drop has
|
|
320
|
+
// to wait for that drop — and two rebuilds that swap keys each wait for the
|
|
321
|
+
// other. Such entangled ones run as one group: every drop, then every create.
|
|
322
|
+
// Each doomed index keeps its rebuild and itself, so no lookup is needed below.
|
|
323
|
+
const doomed = [];
|
|
324
|
+
for (const rebuild of rebuilds) {
|
|
325
|
+
for (const index of rebuild.drops) doomed.push({ owner: rebuild, index });
|
|
326
|
+
}
|
|
327
|
+
const entangled = new Set();
|
|
328
|
+
for (const list of [creates, rebuilds]) {
|
|
329
|
+
for (const item of list) {
|
|
330
|
+
for (const { owner, index } of doomed) {
|
|
331
|
+
if (owner === item || !collides(item.declared, index)) continue;
|
|
332
|
+
entangled.add(item);
|
|
333
|
+
entangled.add(owner);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const waiting = [];
|
|
339
|
+
for (const item of creates) {
|
|
340
|
+
if (entangled.has(item)) {
|
|
341
|
+
waiting.push(item);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
steps.push({ op: 'createIndex', spec: item.declared.spec, actions: [item.action] });
|
|
345
|
+
}
|
|
346
|
+
steps.push(...modifies);
|
|
347
|
+
const group = { op: 'rebuild', drops: [], creates: [], actions: [] };
|
|
348
|
+
for (const list of [rebuilds, waiting]) {
|
|
349
|
+
for (const item of list) {
|
|
350
|
+
const step = entangled.has(item)
|
|
351
|
+
? group
|
|
352
|
+
: { op: 'rebuild', drops: [], creates: [], actions: [] };
|
|
353
|
+
for (const index of item.drops ?? []) {
|
|
354
|
+
step.drops.push({ name: index.name, restore: restoreSpec(index.raw) });
|
|
355
|
+
}
|
|
356
|
+
step.creates.push({ spec: item.declared.spec, action: item.action });
|
|
357
|
+
step.actions.push(item.action);
|
|
358
|
+
if (step !== group) steps.push(step);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (group.actions.length > 0) steps.push(group);
|
|
362
|
+
|
|
363
|
+
// Last, so an index is only ever removed once everything declared exists.
|
|
364
|
+
for (const index of liveIndexes) {
|
|
365
|
+
if (consumed.has(index.name) || declaredNames.has(index.name)) continue;
|
|
366
|
+
if (prune && backsShardKey(index, live.shardKey)) {
|
|
367
|
+
row({
|
|
368
|
+
target: 'index',
|
|
369
|
+
name: index.name,
|
|
370
|
+
action: 'keep',
|
|
371
|
+
reason: 'backs the shard key',
|
|
372
|
+
from: indexValue(index.raw),
|
|
373
|
+
});
|
|
374
|
+
} else if (prune) {
|
|
375
|
+
const action = row({
|
|
376
|
+
target: 'index',
|
|
377
|
+
name: index.name,
|
|
378
|
+
action: 'drop',
|
|
379
|
+
reason: 'not declared',
|
|
380
|
+
from: indexValue(index.raw),
|
|
381
|
+
});
|
|
382
|
+
steps.push({ op: 'dropIndex', name: index.name, actions: [action] });
|
|
383
|
+
} else {
|
|
384
|
+
row({
|
|
385
|
+
target: 'index',
|
|
386
|
+
name: index.name,
|
|
387
|
+
action: 'keep',
|
|
388
|
+
reason: 'not declared',
|
|
389
|
+
from: indexValue(index.raw),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The way out of a change a search index cannot make in place */
|
|
396
|
+
const NEW_NAME_RECIPE =
|
|
397
|
+
'declare it under a new name, converge, then remove the old declaration and converge with prune';
|
|
398
|
+
|
|
399
|
+
/** Why a declared search index is not planned on a server without Atlas Search */
|
|
400
|
+
const SEARCH_UNAVAILABLE_REASON = 'Atlas Search is not available on this server';
|
|
401
|
+
|
|
402
|
+
function typeChangeReason(from, to) {
|
|
403
|
+
return `the type cannot change in place (${from} → ${to}) — ${NEW_NAME_RECIPE}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function autoEmbedReason(changes) {
|
|
407
|
+
return `autoEmbed ${changes.join(', ')} cannot change in place — ${NEW_NAME_RECIPE}`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** `mappings.fields.title.norms, storedSource (+2 more)` */
|
|
411
|
+
function diffReason(paths, more) {
|
|
412
|
+
return `${paths.join(', ')}${more > 0 ? ` (+${more} more)` : ''}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function deletingReason(status) {
|
|
416
|
+
return `is being deleted on the server (${status}) — converge again once it is gone`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Plan the search indexes of one collection. Submissions (creates, then
|
|
421
|
+
* updates) go to `submit`, drops to `drops` — converge.js sends submissions
|
|
422
|
+
* before the regular index builds (the server builds a search index in the
|
|
423
|
+
* background) and drops last of all.
|
|
424
|
+
*/
|
|
425
|
+
function planSearchIndexes(declaredList, live, { prune, search }, row, submit, drops) {
|
|
426
|
+
if (!search.available) {
|
|
427
|
+
// Nothing to compare with: every declaration is refused, or skipped when
|
|
428
|
+
// the configuration says a server without Search is expected.
|
|
429
|
+
for (const declared of declaredList) {
|
|
430
|
+
row({
|
|
431
|
+
target: 'searchIndex',
|
|
432
|
+
name: declared.name,
|
|
433
|
+
action: search.onUnavailable === 'skip' ? 'skip' : 'conflict',
|
|
434
|
+
reason: SEARCH_UNAVAILABLE_REASON,
|
|
435
|
+
to: searchIndexValue(declared),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
// Normalized and indexed by name in one pass; the map's order is the server's.
|
|
441
|
+
const byName = new Map();
|
|
442
|
+
for (const raw of live.searchIndexes ?? []) {
|
|
443
|
+
const index = normalizeLiveSearchIndex(raw);
|
|
444
|
+
byName.set(index.name, index);
|
|
445
|
+
}
|
|
446
|
+
const declaredNames = new Set();
|
|
447
|
+
|
|
448
|
+
const specs = [];
|
|
449
|
+
const created = [];
|
|
450
|
+
const updates = [];
|
|
451
|
+
for (const declared of declaredList) {
|
|
452
|
+
declaredNames.add(declared.name);
|
|
453
|
+
const current = byName.get(declared.name);
|
|
454
|
+
const to = searchIndexValue(declared);
|
|
455
|
+
if (!current) {
|
|
456
|
+
specs.push(searchIndexSpec(declared));
|
|
457
|
+
created.push(row({ target: 'searchIndex', name: declared.name, action: 'create', to }));
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const from = searchIndexValue(current);
|
|
461
|
+
const build = searchBuild(current);
|
|
462
|
+
if (isBeingRemoved(current)) {
|
|
463
|
+
row({
|
|
464
|
+
target: 'searchIndex',
|
|
465
|
+
name: declared.name,
|
|
466
|
+
action: 'conflict',
|
|
467
|
+
reason: deletingReason(current.status),
|
|
468
|
+
build,
|
|
469
|
+
});
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const { diffs, paths, more, typeChange, immutable, ignored } = compareSearchIndex(
|
|
473
|
+
declared,
|
|
474
|
+
current,
|
|
475
|
+
);
|
|
476
|
+
// Options only the server reports, left out of the comparison: named on the row.
|
|
477
|
+
const tolerated = ignored.length > 0 ? { ignored } : {};
|
|
478
|
+
if (diffs.length === 0) {
|
|
479
|
+
row({ target: 'searchIndex', name: declared.name, action: 'unchanged', build, ...tolerated });
|
|
480
|
+
} else if (typeChange || immutable.length > 0) {
|
|
481
|
+
row({
|
|
482
|
+
target: 'searchIndex',
|
|
483
|
+
name: declared.name,
|
|
484
|
+
action: 'conflict',
|
|
485
|
+
reason: typeChange
|
|
486
|
+
? typeChangeReason(current.type, declared.type)
|
|
487
|
+
: autoEmbedReason(immutable),
|
|
488
|
+
from,
|
|
489
|
+
to,
|
|
490
|
+
build,
|
|
491
|
+
...tolerated,
|
|
492
|
+
});
|
|
493
|
+
} else {
|
|
494
|
+
const action = row({
|
|
495
|
+
target: 'searchIndex',
|
|
496
|
+
name: declared.name,
|
|
497
|
+
action: 'modify',
|
|
498
|
+
reason: diffReason(paths, more),
|
|
499
|
+
from,
|
|
500
|
+
to,
|
|
501
|
+
build,
|
|
502
|
+
...tolerated,
|
|
503
|
+
});
|
|
504
|
+
updates.push({
|
|
505
|
+
op: 'updateSearchIndex',
|
|
506
|
+
name: declared.name,
|
|
507
|
+
type: declared.type,
|
|
508
|
+
definition: declared.definition,
|
|
509
|
+
...(current.version !== undefined ? { sinceVersion: current.version } : {}),
|
|
510
|
+
actions: [action],
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (specs.length > 0) submit.push({ op: 'createSearchIndexes', specs, actions: created });
|
|
515
|
+
submit.push(...updates);
|
|
516
|
+
|
|
517
|
+
for (const index of byName.values()) {
|
|
518
|
+
if (declaredNames.has(index.name)) continue;
|
|
519
|
+
const from = searchIndexValue(index);
|
|
520
|
+
const build = searchBuild(index);
|
|
521
|
+
if (isBeingRemoved(index)) {
|
|
522
|
+
// On its way out already — dropping it again would only fail.
|
|
523
|
+
row({
|
|
524
|
+
target: 'searchIndex',
|
|
525
|
+
name: index.name,
|
|
526
|
+
action: 'keep',
|
|
527
|
+
reason: 'being deleted',
|
|
528
|
+
from,
|
|
529
|
+
build,
|
|
530
|
+
});
|
|
531
|
+
} else if (prune) {
|
|
532
|
+
const action = row({
|
|
533
|
+
target: 'searchIndex',
|
|
534
|
+
name: index.name,
|
|
535
|
+
action: 'drop',
|
|
536
|
+
reason: 'not declared',
|
|
537
|
+
from,
|
|
538
|
+
build,
|
|
539
|
+
});
|
|
540
|
+
drops.push({ op: 'dropSearchIndex', name: index.name, actions: [action] });
|
|
541
|
+
} else {
|
|
542
|
+
row({
|
|
543
|
+
target: 'searchIndex',
|
|
544
|
+
name: index.name,
|
|
545
|
+
action: 'keep',
|
|
546
|
+
reason: 'not declared',
|
|
547
|
+
from,
|
|
548
|
+
build,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Plan one collection. `definition` is a normalized definition (see
|
|
556
|
+
* collections.js); `live` is `{ exists, type?, options?, indexes,
|
|
557
|
+
* searchIndexes? }` as converge.js reads it. Returns `{ name, actions, steps }`:
|
|
558
|
+
* `actions` are the result rows (status `'planned'`), `steps` the operations
|
|
559
|
+
* that carry them out, in execution order, each pointing at the rows it
|
|
560
|
+
* settles. `search` is `{ available, onUnavailable }` — whether the server has
|
|
561
|
+
* Atlas Search, and what a declared search index becomes when it does not.
|
|
562
|
+
*/
|
|
563
|
+
/**
|
|
564
|
+
* Fold every run of consecutive `createIndex` steps into one `createIndexes`:
|
|
565
|
+
* the server builds several indexes in a single pass over the collection, so
|
|
566
|
+
* three new indexes on a large collection cost one scan, not three. Only
|
|
567
|
+
* neighbours merge — the order between creates, modifications, rebuilds and
|
|
568
|
+
* drops is kept as planned.
|
|
569
|
+
*/
|
|
570
|
+
function batchCreates(steps) {
|
|
571
|
+
const batched = [];
|
|
572
|
+
for (const step of steps) {
|
|
573
|
+
const last = batched.at(-1);
|
|
574
|
+
if (step.op !== 'createIndex') {
|
|
575
|
+
batched.push(step);
|
|
576
|
+
} else if (last?.op === 'createIndexes') {
|
|
577
|
+
last.specs.push(step.spec);
|
|
578
|
+
last.actions.push(...step.actions);
|
|
579
|
+
} else {
|
|
580
|
+
batched.push({ op: 'createIndexes', specs: [step.spec], actions: [...step.actions] });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return batched;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function planCollection(definition, live, options = {}) {
|
|
587
|
+
const plan = planCollectionSteps(definition, live, options);
|
|
588
|
+
return { ...plan, steps: batchCreates(plan.steps) };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const SEARCH_AVAILABLE = Object.freeze({ available: true, onUnavailable: 'fail' });
|
|
592
|
+
|
|
593
|
+
function planCollectionSteps(
|
|
594
|
+
definition,
|
|
595
|
+
live,
|
|
596
|
+
{ prune = false, rebuildUnique = false, capabilities = {}, search = SEARCH_AVAILABLE } = {},
|
|
597
|
+
) {
|
|
598
|
+
const name = definition.name;
|
|
599
|
+
const actions = [];
|
|
600
|
+
const steps = [];
|
|
601
|
+
const row = (fields) => {
|
|
602
|
+
const action = { ...fields, status: 'planned' };
|
|
603
|
+
actions.push(action);
|
|
604
|
+
return action;
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
if (live.exists && live.type !== undefined && live.type !== 'collection') {
|
|
608
|
+
row({
|
|
609
|
+
target: 'collection',
|
|
610
|
+
name,
|
|
611
|
+
action: 'conflict',
|
|
612
|
+
reason: `is a ${live.type}, not a regular collection`,
|
|
613
|
+
});
|
|
614
|
+
return { name, actions, steps };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const desired = desiredValidator(definition);
|
|
618
|
+
const declaredIndexes = definition.indexes;
|
|
619
|
+
const declaredSearch = definition.searchIndexes;
|
|
620
|
+
const indexSteps = [];
|
|
621
|
+
const searchSubmit = [];
|
|
622
|
+
const searchDrops = [];
|
|
623
|
+
|
|
624
|
+
if (!live.exists) {
|
|
625
|
+
// Nothing worth creating an empty collection for — a definition that only
|
|
626
|
+
// says "no validator" is already true of a collection that does not exist,
|
|
627
|
+
// and so is "no search indexes" (or any, on a server without Search).
|
|
628
|
+
const wantsSearch = search.available && declaredSearch?.length > 0;
|
|
629
|
+
if (desired || declaredIndexes?.length > 0 || wantsSearch) {
|
|
630
|
+
const linked = [row({ target: 'collection', name, action: 'create' })];
|
|
631
|
+
if (desired) {
|
|
632
|
+
linked.push(row({ target: 'validator', name, action: 'create', to: desired }));
|
|
633
|
+
}
|
|
634
|
+
steps.push({
|
|
635
|
+
op: 'createCollection',
|
|
636
|
+
options: desired ? { ...desired } : {},
|
|
637
|
+
actions: linked,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
for (const declared of declaredIndexes ?? []) {
|
|
641
|
+
const action = row({
|
|
642
|
+
target: 'index',
|
|
643
|
+
name: declared.name,
|
|
644
|
+
action: 'create',
|
|
645
|
+
to: indexValue(declared.spec),
|
|
646
|
+
});
|
|
647
|
+
indexSteps.push({ op: 'createIndex', spec: declared.spec, actions: [action] });
|
|
648
|
+
}
|
|
649
|
+
if (declaredSearch !== undefined) {
|
|
650
|
+
const none = { searchIndexes: [] };
|
|
651
|
+
planSearchIndexes(declaredSearch, none, { prune, search }, row, searchSubmit, searchDrops);
|
|
652
|
+
}
|
|
653
|
+
steps.push(...searchSubmit, ...indexSteps);
|
|
654
|
+
return { name, actions, steps };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (desired !== undefined) {
|
|
658
|
+
planValidator(name, desired, liveValidator(live.options), row, steps);
|
|
659
|
+
}
|
|
660
|
+
if (declaredIndexes !== undefined) {
|
|
661
|
+
planIndexes(declaredIndexes, live, { prune, rebuildUnique, capabilities }, row, indexSteps);
|
|
662
|
+
}
|
|
663
|
+
if (declaredSearch !== undefined) {
|
|
664
|
+
planSearchIndexes(declaredSearch, live, { prune, search }, row, searchSubmit, searchDrops);
|
|
665
|
+
}
|
|
666
|
+
// Search submissions return at once and build in the background, so they go
|
|
667
|
+
// before the regular index builds; every drop still comes last.
|
|
668
|
+
steps.push(...searchSubmit, ...indexSteps, ...searchDrops);
|
|
669
|
+
return { name, actions, steps };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
module.exports = {
|
|
673
|
+
CHANGE_ACTIONS,
|
|
674
|
+
SEARCH_UNAVAILABLE_REASON,
|
|
675
|
+
SERVED_ACTIONS,
|
|
676
|
+
TARGET_LABELS,
|
|
677
|
+
UNIQUE_REBUILD_REASON,
|
|
678
|
+
VALIDATOR_DEFAULTS,
|
|
679
|
+
desiredValidator,
|
|
680
|
+
indexValue,
|
|
681
|
+
isDestructive,
|
|
682
|
+
isEmptyValidator,
|
|
683
|
+
needsConfirmation,
|
|
684
|
+
liveValidator,
|
|
685
|
+
planCollection,
|
|
686
|
+
};
|