@alexify/migronaut 2.1.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.
@@ -1,5 +1,13 @@
1
1
  const { deepEqual } = require('../utils/canonical.js');
2
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');
3
11
 
4
12
  /**
5
13
  * The converge planner: one declared collection against its live state, as
@@ -17,17 +25,35 @@ const { compareIndex, normalizeLiveIndex, restoreSpec, sameSignature } = require
17
25
  * - undeclared indexes are kept (and reported) unless `prune` is on;
18
26
  * - a rebuild that drops a unique index to build a unique one back is a
19
27
  * conflict unless `rebuildUnique` is on — see UNIQUE_REBUILD_REASON;
20
- * - `_id_` and a clustered index are the collection's own and never listed.
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.
21
33
  */
22
34
 
23
35
  /** Actions that change the database — what a plan "would do" and a run "did" */
24
36
  const CHANGE_ACTIONS = new Set(['create', 'modify', 'recreate', 'drop']);
25
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
+
26
47
  /** Server defaults for a collection that has a validator but did not say how to apply it */
27
48
  const VALIDATOR_DEFAULTS = { validationLevel: 'strict', validationAction: 'error' };
28
49
 
29
- /** Whether a row drops or rebuilds an index */
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
+ */
30
55
  function isDestructive(action) {
56
+ if (action.target === 'searchIndex') return action.action === 'drop';
31
57
  return action.target === 'index' && (action.action === 'drop' || action.action === 'recreate');
32
58
  }
33
59
 
@@ -140,9 +166,17 @@ function dropsUniqueConstraint(declared, drops) {
140
166
  */
141
167
  function backsShardKey(index, shardKey) {
142
168
  if (!shardKey) return false;
143
- const shardFields = Object.keys(shardKey);
144
- const fields = index.serverKey.map(([field]) => field);
145
- return shardFields.every((field, position) => fields[position] === field);
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;
146
180
  }
147
181
 
148
182
  function planIndexes(declaredIndexes, live, { prune, rebuildUnique, capabilities }, row, steps) {
@@ -267,9 +301,7 @@ function planIndexes(declaredIndexes, live, { prune, rebuildUnique, capabilities
267
301
  }
268
302
  drops.push(...blockers);
269
303
  action.action = 'recreate';
270
- action.reason = identical
271
- ? 'name'
272
- : [item.reason, `replaces ${names}`].filter(Boolean).join('; ');
304
+ action.reason = identical ? 'name' : joinReasons(item.reason, `replaces ${names}`);
273
305
  }
274
306
 
275
307
  if (drops.length === 0) {
@@ -278,7 +310,7 @@ function planIndexes(declaredIndexes, live, { prune, rebuildUnique, capabilities
278
310
  // Not done unasked: the CLI asks with --rebuild-unique, while the paths
279
311
  // nobody watches (after up, a queue job) never get this far on their own.
280
312
  action.action = 'conflict';
281
- action.reason = [action.reason, UNIQUE_REBUILD_REASON].filter(Boolean).join('; ');
313
+ action.reason = joinReasons(action.reason, UNIQUE_REBUILD_REASON);
282
314
  } else {
283
315
  rebuilds.push({ declared, action, drops });
284
316
  }
@@ -287,16 +319,16 @@ function planIndexes(declaredIndexes, live, { prune, rebuildUnique, capabilities
287
319
  // A create that collides with an index another rebuild is about to drop has
288
320
  // to wait for that drop — and two rebuilds that swap keys each wait for the
289
321
  // other. Such entangled ones run as one group: every drop, then every create.
290
- const doomed = new Map();
322
+ // Each doomed index keeps its rebuild and itself, so no lookup is needed below.
323
+ const doomed = [];
291
324
  for (const rebuild of rebuilds) {
292
- for (const index of rebuild.drops) doomed.set(index.name, rebuild);
325
+ for (const index of rebuild.drops) doomed.push({ owner: rebuild, index });
293
326
  }
294
327
  const entangled = new Set();
295
- for (const item of [...creates, ...rebuilds]) {
296
- for (const [name, owner] of doomed) {
297
- if (owner === item) continue;
298
- const index = owner.drops.find((drop) => drop.name === name);
299
- if (collides(item.declared, index)) {
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;
300
332
  entangled.add(item);
301
333
  entangled.add(owner);
302
334
  }
@@ -313,16 +345,18 @@ function planIndexes(declaredIndexes, live, { prune, rebuildUnique, capabilities
313
345
  }
314
346
  steps.push(...modifies);
315
347
  const group = { op: 'rebuild', drops: [], creates: [], actions: [] };
316
- for (const item of [...rebuilds, ...waiting]) {
317
- const step = entangled.has(item)
318
- ? group
319
- : { op: 'rebuild', drops: [], creates: [], actions: [] };
320
- for (const index of item.drops ?? []) {
321
- step.drops.push({ name: index.name, restore: restoreSpec(index.raw) });
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);
322
359
  }
323
- step.creates.push({ spec: item.declared.spec, action: item.action });
324
- step.actions.push(item.action);
325
- if (step !== group) steps.push(step);
326
360
  }
327
361
  if (group.actions.length > 0) steps.push(group);
328
362
 
@@ -358,12 +392,173 @@ function planIndexes(declaredIndexes, live, { prune, rebuildUnique, capabilities
358
392
  }
359
393
  }
360
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
+
361
554
  /**
362
555
  * Plan one collection. `definition` is a normalized definition (see
363
- * collections.js); `live` is `{ exists, type?, options?, indexes }` as
364
- * converge.js reads it. Returns `{ name, actions, steps }`: `actions` are the
365
- * result rows (status `'planned'`), `steps` the operations that carry them
366
- * out, in execution order, each pointing at the rows it settles.
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.
367
562
  */
368
563
  /**
369
564
  * Fold every run of consecutive `createIndex` steps into one `createIndexes`:
@@ -393,10 +588,12 @@ function planCollection(definition, live, options = {}) {
393
588
  return { ...plan, steps: batchCreates(plan.steps) };
394
589
  }
395
590
 
591
+ const SEARCH_AVAILABLE = Object.freeze({ available: true, onUnavailable: 'fail' });
592
+
396
593
  function planCollectionSteps(
397
594
  definition,
398
595
  live,
399
- { prune = false, rebuildUnique = false, capabilities = {} } = {},
596
+ { prune = false, rebuildUnique = false, capabilities = {}, search = SEARCH_AVAILABLE } = {},
400
597
  ) {
401
598
  const name = definition.name;
402
599
  const actions = [];
@@ -419,16 +616,27 @@ function planCollectionSteps(
419
616
 
420
617
  const desired = desiredValidator(definition);
421
618
  const declaredIndexes = definition.indexes;
619
+ const declaredSearch = definition.searchIndexes;
620
+ const indexSteps = [];
621
+ const searchSubmit = [];
622
+ const searchDrops = [];
422
623
 
423
624
  if (!live.exists) {
424
625
  // Nothing worth creating an empty collection for — a definition that only
425
- // says "no validator" is already true of a collection that does not exist.
426
- if (!desired && !(declaredIndexes?.length > 0)) return { name, actions, steps };
427
- const linked = [row({ target: 'collection', name, action: 'create' })];
428
- if (desired) {
429
- linked.push(row({ target: 'validator', name, action: 'create', to: desired }));
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
+ });
430
639
  }
431
- steps.push({ op: 'createCollection', options: desired ? { ...desired } : {}, actions: linked });
432
640
  for (const declared of declaredIndexes ?? []) {
433
641
  const action = row({
434
642
  target: 'index',
@@ -436,8 +644,13 @@ function planCollectionSteps(
436
644
  action: 'create',
437
645
  to: indexValue(declared.spec),
438
646
  });
439
- steps.push({ op: 'createIndex', spec: declared.spec, actions: [action] });
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);
440
652
  }
653
+ steps.push(...searchSubmit, ...indexSteps);
441
654
  return { name, actions, steps };
442
655
  }
443
656
 
@@ -445,31 +658,22 @@ function planCollectionSteps(
445
658
  planValidator(name, desired, liveValidator(live.options), row, steps);
446
659
  }
447
660
  if (declaredIndexes !== undefined) {
448
- planIndexes(declaredIndexes, live, { prune, rebuildUnique, capabilities }, row, steps);
661
+ planIndexes(declaredIndexes, live, { prune, rebuildUnique, capabilities }, row, indexSteps);
449
662
  }
450
- return { name, actions, steps };
451
- }
452
-
453
- /** Counts over planned (or executed) collections */
454
- function summarize(collections) {
455
- let changes = 0;
456
- let applied = 0;
457
- let conflicts = 0;
458
- let destructive = 0;
459
- for (const collection of collections) {
460
- for (const action of collection.actions) {
461
- if (action.action === 'conflict') conflicts += 1;
462
- if (!CHANGE_ACTIONS.has(action.action)) continue;
463
- changes += 1;
464
- if (action.status === 'applied') applied += 1;
465
- if (isDestructive(action)) destructive += 1;
466
- }
663
+ if (declaredSearch !== undefined) {
664
+ planSearchIndexes(declaredSearch, live, { prune, search }, row, searchSubmit, searchDrops);
467
665
  }
468
- return { changes, applied, conflicts, destructive };
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 };
469
670
  }
470
671
 
471
672
  module.exports = {
472
673
  CHANGE_ACTIONS,
674
+ SEARCH_UNAVAILABLE_REASON,
675
+ SERVED_ACTIONS,
676
+ TARGET_LABELS,
473
677
  UNIQUE_REBUILD_REASON,
474
678
  VALIDATOR_DEFAULTS,
475
679
  desiredValidator,
@@ -479,5 +683,4 @@ module.exports = {
479
683
  needsConfirmation,
480
684
  liveValidator,
481
685
  planCollection,
482
- summarize,
483
686
  };