@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.
package/index.d.ts CHANGED
@@ -292,6 +292,30 @@ export interface MigronautConfig {
292
292
  * Default: false
293
293
  */
294
294
  convergeAfterUp?: boolean;
295
+ /**
296
+ * What converge does with declared search indexes on a server without
297
+ * Atlas Search: `'fail'` refuses the run before anything is written,
298
+ * `'skip'` converges everything else and reports them as `skip` rows.
299
+ * Default: 'fail'
300
+ * @experimental New in 2.2
301
+ */
302
+ onSearchUnavailable?: 'fail' | 'skip';
303
+ /**
304
+ * Hold every converge — the after-up one included — until each declared
305
+ * search index is queryable with its declared definition. Search indexes
306
+ * build in the background, so without it a new one is not queryable yet when
307
+ * converge returns. An index that FAILED or went STALE before the run, its
308
+ * definition unchanged, does not hold it — it is warned about instead. The
309
+ * migration lock is released while it waits. Default: false
310
+ * @experimental New in 2.2
311
+ */
312
+ waitForSearchIndexes?: boolean;
313
+ /**
314
+ * How long `waitForSearchIndexes` waits before the converge fails with
315
+ * `phase: 'wait'` (the server goes on building). Default: 600000 (10 minutes)
316
+ * @experimental New in 2.2
317
+ */
318
+ searchIndexWaitTimeoutMs?: number;
295
319
  /** Mongoose instance — required only if your migrations use Mongoose models */
296
320
  mongoose?: MongooseLike;
297
321
  hooks?: MigrationHooks;
@@ -393,9 +417,77 @@ export interface IndexDefinition {
393
417
  export type ValidationLevel = 'off' | 'strict' | 'moderate';
394
418
  export type ValidationAction = 'error' | 'warn' | 'errorAndLog';
395
419
 
420
+ /** The two kinds of Atlas search index */
421
+ export type SearchIndexType = 'search' | 'vectorSearch';
422
+
423
+ /** `mappings` of an Atlas Search definition */
424
+ export interface SearchIndexMappings {
425
+ /** Default: false */
426
+ dynamic?: boolean | { typeSet: string };
427
+ fields?: Record<string, unknown>;
428
+ }
429
+
430
+ /**
431
+ * An Atlas Search index definition, as Atlas defines it. Compared whole, with
432
+ * the documented defaults filled in; anything Atlas adds can be declared too.
433
+ */
434
+ export interface SearchDefinition {
435
+ mappings: SearchIndexMappings;
436
+ /** Default: 'lucene.standard' */
437
+ analyzer?: string;
438
+ /** Default: the analyzer */
439
+ searchAnalyzer?: string;
440
+ analyzers?: Array<Record<string, unknown>>;
441
+ synonyms?: Array<Record<string, unknown>>;
442
+ /** Default: false */
443
+ storedSource?: boolean | { include?: string[]; exclude?: string[] };
444
+ /** Default: 1 */
445
+ numPartitions?: number;
446
+ [option: string]: unknown;
447
+ }
448
+
449
+ /** One field of a Vector Search definition */
450
+ export interface VectorSearchField {
451
+ /** `'autoEmbed'` is Atlas's automated embedding (in preview); one index holds vector or autoEmbed fields, not both */
452
+ type: 'vector' | 'filter' | 'autoEmbed' | (string & {});
453
+ path: string;
454
+ numDimensions?: number;
455
+ similarity?: 'euclidean' | 'cosine' | 'dotProduct';
456
+ /** Default: 'none' ('scalar' for autoEmbed) */
457
+ quantization?: string;
458
+ /** Default: 'hnsw' */
459
+ indexingMethod?: 'hnsw' | 'flat';
460
+ /** Default: { maxEdges: 16, numEdgeCandidates: 100 } */
461
+ hnswOptions?: { maxEdges?: number; numEdgeCandidates?: number };
462
+ /** autoEmbed: the embedding model */
463
+ model?: string;
464
+ /** autoEmbed: 'text' */
465
+ modality?: string;
466
+ [option: string]: unknown;
467
+ }
468
+
469
+ /** A Vector Search index definition */
470
+ export interface VectorSearchDefinition {
471
+ fields: VectorSearchField[];
472
+ [option: string]: unknown;
473
+ }
474
+
475
+ /**
476
+ * One declared Atlas Search or Vector Search index. The name defaults to
477
+ * `'default'` and the type to `'search'`, as on the server. A change of type
478
+ * — or of an autoEmbed field's path, model, size, quantization or modality —
479
+ * cannot be made in place: converge refuses it, and the way is a new index
480
+ * under a new name (converge, then remove the old declaration and converge
481
+ * with prune).
482
+ * @experimental New in 2.2 — the shape may still change in a minor release (named in the CHANGELOG).
483
+ */
484
+ export type SearchIndexDefinition =
485
+ | { name?: string; type?: 'search'; definition: SearchDefinition }
486
+ | { name?: string; type: 'vectorSearch'; definition: VectorSearchDefinition };
487
+
396
488
  /**
397
489
  * A declared collection: the end state `converge()` keeps it in. Leave
398
- * `indexes` or `validator` out to leave that part unmanaged.
490
+ * `indexes`, `searchIndexes` or `validator` out to leave that part unmanaged.
399
491
  * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
400
492
  */
401
493
  export interface CollectionDefinition {
@@ -405,13 +497,23 @@ export interface CollectionDefinition {
405
497
  * unless `prune` is on.
406
498
  */
407
499
  indexes?: IndexDefinition[];
500
+ /**
501
+ * Atlas Search and Vector Search indexes (Atlas, an Atlas CLI local
502
+ * deployment, or MongoDB 8.3+ with mongot). Undeclared live ones are kept
503
+ * unless `prune` is on; leave the key out and they are not managed at all.
504
+ * @experimental New in 2.2
505
+ */
506
+ searchIndexes?: SearchIndexDefinition[];
408
507
  /** A query or `{ $jsonSchema }` document; `null` (or `{}`) for no validator */
409
508
  validator?: Record<string, unknown> | null;
410
509
  /** Default: 'strict'. Only with a validator */
411
510
  validationLevel?: ValidationLevel;
412
511
  /** Default: 'error'. Only with a validator */
413
512
  validationAction?: ValidationAction;
414
- /** Drop live indexes this definition does not declare. Default: the call's `prune`, else false */
513
+ /**
514
+ * Drop live indexes (and search indexes, when `searchIndexes` is declared)
515
+ * this definition does not declare. Default: the call's `prune`, else false
516
+ */
415
517
  prune?: boolean;
416
518
  }
417
519
 
@@ -443,20 +545,33 @@ export interface ConvergeOptions {
443
545
  * CLI: `--rebuild-unique`.
444
546
  */
445
547
  rebuildUnique?: boolean;
548
+ /**
549
+ * Hold the run until every declared search index serves its
550
+ * declaration — failing on a FAILED build of an index this run created or
551
+ * changed, or after `searchIndexWaitTimeoutMs`. The migration lock is released
552
+ * when the wait starts — it only reads. Overrides the config's `waitForSearchIndexes`;
553
+ * not with `dryRun`. CLI: `--wait-search` / `--no-wait-search`.
554
+ * @experimental New in 2.2
555
+ */
556
+ waitForSearchIndexes?: boolean;
446
557
  /** Who asked for this converge — recorded in the converge history */
447
558
  requestedBy?: string;
448
559
  /** Why — recorded in the converge history */
449
560
  reason?: string;
450
561
  }
451
562
 
452
- export type ConvergeTarget = 'collection' | 'validator' | 'index';
563
+ export type ConvergeTarget = 'collection' | 'validator' | 'index' | 'searchIndex';
453
564
 
454
565
  /**
455
566
  * What converge does to one target. `keep` is an undeclared index left alone
456
567
  * (prune off); `conflict` refuses the run — an undeclared index covers the
457
568
  * declared one's key under another name, a unique index would be rebuilt
458
- * without {@link ConvergeOptions.rebuildUnique}, or the collection is a view
459
- * or a time-series collection.
569
+ * without {@link ConvergeOptions.rebuildUnique}, the collection is a view or a
570
+ * time-series collection, a search index would need a change no update can
571
+ * make (its type, an autoEmbed field's model or size), or the server has no
572
+ * Atlas Search; `skip` is a declared search index left alone on a server
573
+ * without Search (`onSearchUnavailable: 'skip'`). A search index is never
574
+ * `recreate`d: `modify` updates it in place.
460
575
  * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
461
576
  */
462
577
  export type ConvergeActionKind =
@@ -466,7 +581,37 @@ export type ConvergeActionKind =
466
581
  | 'drop'
467
582
  | 'keep'
468
583
  | 'unchanged'
469
- | 'conflict';
584
+ | 'conflict'
585
+ | 'skip';
586
+
587
+ /**
588
+ * The status `$listSearchIndexes` reports for a search index — `'UNKNOWN'`
589
+ * when the server reports none
590
+ */
591
+ export type SearchIndexStatus =
592
+ | 'PENDING'
593
+ | 'BUILDING'
594
+ | 'READY'
595
+ | 'FAILED'
596
+ | 'STALE'
597
+ | 'DELETING'
598
+ | 'DOES_NOT_EXIST'
599
+ | 'UNKNOWN'
600
+ | (string & {});
601
+
602
+ /**
603
+ * Where the server is with a search index: it builds in the background, so a
604
+ * created or updated one is not queryable (with its new definition) at once
605
+ * @experimental New in 2.2
606
+ */
607
+ export interface SearchIndexBuild {
608
+ status: SearchIndexStatus;
609
+ queryable: boolean;
610
+ /** The server's message — why a build FAILED, typically */
611
+ message?: string;
612
+ /** A newer definition is being built next to the one served */
613
+ updating?: true;
614
+ }
470
615
 
471
616
  /**
472
617
  * `planned` in a dry run; otherwise `applied`, `failed`, or `skipped` — no
@@ -497,6 +642,21 @@ export interface ConvergeAction {
497
642
  from?: Record<string, unknown>;
498
643
  /** What the row puts there — the declared index or validator — on rows that create or change it */
499
644
  to?: Record<string, unknown>;
645
+ /**
646
+ * A search index row's build state on the server — as read before the run,
647
+ * and after it for a row the run applied
648
+ * @experimental New in 2.2
649
+ */
650
+ build?: SearchIndexBuild;
651
+ /**
652
+ * On a search index row: the options the server reports that the
653
+ * declaration does not set and migronaut knows no default for
654
+ * (`mappings.fields.title.similarity`) — left out of the comparison, so a
655
+ * new server default does not make every converge update the index.
656
+ * Declare one to manage it.
657
+ * @experimental New in 2.2
658
+ */
659
+ ignored?: string[];
500
660
  }
501
661
 
502
662
  /**
@@ -532,6 +692,12 @@ export interface ConvergeHistoryEntry {
532
692
  /** The rows that changed, failed or refused the run — each with its collection and `from` / `to` */
533
693
  actions: Array<ConvergeAction & { collection: string }>;
534
694
  unstable?: ConvergeUnstable[];
695
+ /**
696
+ * What the run saw of Atlas Search, and how a wait for its builds ended —
697
+ * when a definition declares `searchIndexes`
698
+ * @experimental New in 2.2
699
+ */
700
+ search?: ConvergeSearchSummary;
535
701
  }
536
702
 
537
703
  /**
@@ -546,6 +712,42 @@ export interface ConvergeUnstable {
546
712
  reason?: string;
547
713
  }
548
714
 
715
+ /**
716
+ * A declared search index that exists but does not serve its declaration yet
717
+ * @experimental New in 2.2
718
+ */
719
+ export interface SearchIndexNotReady extends SearchIndexBuild {
720
+ collection: string;
721
+ name: string;
722
+ }
723
+
724
+ /**
725
+ * What a converge saw of Atlas Search — present when a definition declares
726
+ * `searchIndexes`
727
+ * @experimental New in 2.2
728
+ */
729
+ export interface ConvergeSearchSummary {
730
+ /** Whether the server has Atlas Search */
731
+ available: boolean;
732
+ /**
733
+ * How that was told: `'listed'` (the server listed search indexes),
734
+ * `'parameter'` (its search index manager setting), `'error'` (it refused
735
+ * a search command), `'version'` (older than 6.0, not asked) or
736
+ * `'assumed'` (it would not say — a refusal at apply time reports it)
737
+ */
738
+ evidence?: 'listed' | 'parameter' | 'error' | 'version' | 'assumed';
739
+ /**
740
+ * Declared search indexes still building, updating, stale or failed. Does
741
+ * not count against `inSync`: a build is the server's work, not a difference.
742
+ */
743
+ notReady: SearchIndexNotReady[];
744
+ /** How a wait for the builds (`waitForSearchIndexes`) ended, when there was one */
745
+ wait?: { outcome: ConvergeWaitOutcome; waitedMs: number };
746
+ }
747
+
748
+ /** How a wait for search index builds ended */
749
+ export type ConvergeWaitOutcome = 'ready' | 'failed' | 'timeout' | 'unreadable' | 'aborted';
750
+
549
751
  /**
550
752
  * Outcome of {@link MigratorKit.converge}
551
753
  * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
@@ -556,11 +758,15 @@ export interface ConvergeResult {
556
758
  changed: number;
557
759
  /**
558
760
  * True when the database matches the declarations: nothing left to do and
559
- * no conflict. Undeclared indexes kept with prune off do not count against it.
761
+ * no conflict. Undeclared indexes kept with prune off, search indexes
762
+ * skipped on a server without Search, and search index builds still under
763
+ * way do not count against it.
560
764
  */
561
765
  inSync: boolean;
562
766
  collections: CollectionConvergeResult[];
563
767
  unstable?: ConvergeUnstable[];
768
+ /** @experimental New in 2.2 */
769
+ search?: ConvergeSearchSummary;
564
770
  }
565
771
 
566
772
  // ─── Logger ───────────────────────────────────────────────────────────────────
@@ -1014,6 +1220,12 @@ export interface LockEvent extends MigronautEventBase {
1014
1220
  ttlMs?: number;
1015
1221
  /** How long acquisition took in ms (on `lock:acquired`) */
1016
1222
  acquireMs?: number;
1223
+ /**
1224
+ * True on a `lock:released` that came before the run ended: a converge gave
1225
+ * the lock up to wait for search index builds, which only reads.
1226
+ * @experimental New in 2.2
1227
+ */
1228
+ early?: true;
1017
1229
  }
1018
1230
 
1019
1231
  /** Who started a converge: the `converge` call itself, or a bulk `up` (`convergeAfterUp`) */
@@ -1053,6 +1265,26 @@ export interface ConvergeActionEvent extends MigronautEventBase {
1053
1265
  /**
1054
1266
  * @experimental New in 2.1 — the shape may still change in a minor release (named in the CHANGELOG).
1055
1267
  */
1268
+ /**
1269
+ * A converge's wait for search index builds (`waitForSearchIndexes`):
1270
+ * `started` once, `progress` every 30 seconds, then how it ended — one of
1271
+ * {@link ConvergeWaitOutcome}.
1272
+ * @experimental New in 2.2
1273
+ */
1274
+ export interface ConvergeWaitEvent extends MigronautEventBase {
1275
+ status: 'started' | 'progress' | ConvergeWaitOutcome;
1276
+ /** How many search indexes the wait is for */
1277
+ searchIndexes: number;
1278
+ /** `started` only — whether the migration lock was released for the wait */
1279
+ lockReleased?: boolean;
1280
+ /** `started` only — the budget (`searchIndexWaitTimeoutMs`) */
1281
+ timeoutMs?: number;
1282
+ /** Every status but `started` */
1283
+ waitedMs?: number;
1284
+ /** `failed` and `timeout` — the indexes that did not get there */
1285
+ notReady?: SearchIndexNotReady[];
1286
+ }
1287
+
1056
1288
  export interface ConvergeEndEvent extends MigronautEventBase {
1057
1289
  trigger: ConvergeTrigger;
1058
1290
  success: boolean;
@@ -1085,12 +1317,17 @@ export interface MigronautEvents {
1085
1317
  'lock:lost': (event: LockEvent) => void;
1086
1318
  'converge:start': (event: ConvergeStartEvent) => void;
1087
1319
  'converge:action': (event: ConvergeActionEvent) => void;
1320
+ 'converge:wait': (event: ConvergeWaitEvent) => void;
1088
1321
  'converge:end': (event: ConvergeEndEvent) => void;
1089
1322
  }
1090
1323
 
1091
1324
  /** One check performed by {@link MigratorKit.audit} */
1092
1325
  export interface AuditCheck {
1093
- /** e.g. 'config', 'connection', 'transactions', 'indexes', 'lock', 'checksums' */
1326
+ /**
1327
+ * e.g. 'config', 'connection', 'transactions', 'indexes', 'lock', 'checksums',
1328
+ * 'pending', 'ordering', 'runtime' — and 'search' when declared collections
1329
+ * hold search indexes
1330
+ */
1094
1331
  name: string;
1095
1332
  status: 'pass' | 'warn' | 'fail';
1096
1333
  detail: string;
@@ -1620,11 +1857,27 @@ export class QueueJobFailedError extends MigronautError {
1620
1857
 
1621
1858
  /**
1622
1859
  * Thrown by {@link MigratorKit.converge} when the database cannot be brought
1623
- * to the declared state. `context.phase` is `'plan'` for a refused plan
1624
- * (`context.conflicts` lists why; nothing was written) or `'apply'` for a
1625
- * failed step (`collection`, `target`, `name`, `action`, `cause`, and
1626
- * `mongoCode`, `hint` and — after a failed rebuild — `restored` when they
1627
- * apply). `context.converge` is the {@link ConvergeResult} so far.
1860
+ * to the declared state. `context.phase` is:
1861
+ * - `'plan'` for a refused plan (`context.conflicts` lists why, with a `hint`
1862
+ * when Atlas Search is missing; nothing was written) — or a search index
1863
+ * list that could not be read before the first write (`collection`,
1864
+ * `target: 'searchIndex'`, `cause`, `mongoCode`, `hint`);
1865
+ * - `'replan'` when a collection changed while the run was under way
1866
+ * (`collection`, `introduced`: the new conflicts or drops; nothing of that
1867
+ * collection was written) — or its search index list could not be read
1868
+ * again (as for `'plan'`);
1869
+ * - `'apply'` for a failed step (`collection`, `target`, `name`, `action`,
1870
+ * `cause`, and `mongoCode`, `hint` and — after a failed rebuild — `restored`
1871
+ * when they apply) — or a search index list that could not be read to check
1872
+ * the steps just applied (as for `'plan'`);
1873
+ * - `'wait'` when `waitForSearchIndexes` gave up: `reason` is `'failed'` (the
1874
+ * build of a search index this run created or changed FAILED) or `'timeout'`,
1875
+ * with `notReady` the indexes not serving their declaration, `waitedMs`,
1876
+ * `timeoutMs` — or `'unreadable'`: a search index list that could not be
1877
+ * read (as for `'plan'`), after up to three network or failover blips in a
1878
+ * row. Everything was applied — only the builds were not finished.
1879
+ *
1880
+ * `context.converge` is the {@link ConvergeResult} so far.
1628
1881
  */
1629
1882
  export class ConvergeFailedError extends MigronautError {
1630
1883
  constructor(message: string, context?: Record<string, unknown>, options?: MigronautErrorOptions);
@@ -163,6 +163,25 @@
163
163
  "type": "boolean",
164
164
  "default": false,
165
165
  "description": "End every bulk `up` (no file, no --to) by converging the declared collections under the same lock"
166
+ },
167
+ "onSearchUnavailable": {
168
+ "enum": [
169
+ "fail",
170
+ "skip"
171
+ ],
172
+ "default": "fail",
173
+ "description": "What converge does with declared search indexes on a server without Atlas Search: refuse the run (fail), or converge everything else and skip them (skip)"
174
+ },
175
+ "waitForSearchIndexes": {
176
+ "type": "boolean",
177
+ "default": false,
178
+ "description": "Hold converge until every declared search index is queryable (search indexes build in the background)"
179
+ },
180
+ "searchIndexWaitTimeoutMs": {
181
+ "type": "integer",
182
+ "minimum": 1,
183
+ "default": 600000,
184
+ "description": "How long waitForSearchIndexes waits, in milliseconds, before the converge fails (the build goes on)"
166
185
  }
167
186
  },
168
187
  "definitions": {
@@ -182,6 +201,11 @@
182
201
  "required": [
183
202
  "validator"
184
203
  ]
204
+ },
205
+ {
206
+ "required": [
207
+ "searchIndexes"
208
+ ]
185
209
  }
186
210
  ],
187
211
  "properties": {
@@ -198,6 +222,13 @@
198
222
  },
199
223
  "description": "Every index besides _id. Omit to leave the collection's indexes unmanaged"
200
224
  },
225
+ "searchIndexes": {
226
+ "type": "array",
227
+ "items": {
228
+ "$ref": "#/definitions/searchIndex"
229
+ },
230
+ "description": "Atlas Search and Vector Search indexes. Omit to leave the collection's search indexes unmanaged (Atlas, an Atlas CLI local deployment, or MongoDB 8.3+ with mongot)"
231
+ },
201
232
  "validator": {
202
233
  "oneOf": [
203
234
  {
@@ -227,7 +258,7 @@
227
258
  },
228
259
  "prune": {
229
260
  "type": "boolean",
230
- "description": "Drop indexes this definition does not declare (otherwise they are kept and reported)"
261
+ "description": "Drop indexes (and search indexes, when declared) this definition does not declare — otherwise they are kept and reported"
231
262
  }
232
263
  }
233
264
  },
@@ -320,6 +351,31 @@
320
351
  "description": "Accepted and ignored — a no-op since MongoDB 4.2"
321
352
  }
322
353
  }
354
+ },
355
+ "searchIndex": {
356
+ "type": "object",
357
+ "required": [
358
+ "definition"
359
+ ],
360
+ "additionalProperties": false,
361
+ "properties": {
362
+ "name": {
363
+ "type": "string",
364
+ "minLength": 1,
365
+ "description": "Index name. Defaults to \"default\", as on the server"
366
+ },
367
+ "type": {
368
+ "enum": [
369
+ "search",
370
+ "vectorSearch"
371
+ ],
372
+ "description": "Defaults to 'search'. Cannot change in place — declare a new index under a new name instead"
373
+ },
374
+ "definition": {
375
+ "type": "object",
376
+ "description": "The index definition, as Atlas defines it: { mappings, analyzer, … } for search, { fields: [...] } for vectorSearch"
377
+ }
378
+ }
323
379
  }
324
380
  }
325
381
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexify/migronaut",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Elegant, fast, fully-typed, zero-dependency MongoDB migrations for Node.js — adopts an existing migrate-mongo changelog in one command",
5
5
  "license": "MIT",
6
6
  "author": "Alex Dolid <dolid.sasha@gmail.com>",
@@ -253,12 +253,35 @@ function createMigrationProcessor(options = {}) {
253
253
  },
254
254
  'converge:action': (event) => {
255
255
  if (!current) return;
256
- const target = event.target === 'index' ? `index ${event.name}` : event.target;
256
+ const target =
257
+ event.target === 'index'
258
+ ? `index ${event.name}`
259
+ : event.target === 'searchIndex'
260
+ ? `search index ${event.name}`
261
+ : event.target;
257
262
  const what = `${event.action} ${target} on ${event.collection}`;
258
263
  if (event.status === 'started') log(current, `… ${what}`);
259
264
  else if (event.status === 'applied') log(current, `✔ ${what} [${event.durationMs ?? 0}ms]`);
260
265
  else log(current, `✖ ${what}: ${event.error ?? 'failed'}`);
261
266
  },
267
+ 'converge:wait': (event) => {
268
+ if (!current) return;
269
+ if (event.status === 'started' || event.status === 'progress') {
270
+ const waited = event.status === 'started' ? 0 : event.waitedMs;
271
+ log(
272
+ current,
273
+ event.status === 'started'
274
+ ? `… Waiting for ${event.searchIndexes} search index(es) to become queryable` +
275
+ (event.lockReleased ? ' — the migration lock is released meanwhile' : '')
276
+ : `… Still waiting for search indexes [${Math.round(waited / 1000)}s]`,
277
+ );
278
+ progress(current, 'search-wait', { searchIndexes: event.searchIndexes, waitedMs: waited });
279
+ } else if (event.status === 'ready') {
280
+ log(current, `✔ Search index(es) queryable [${event.waitedMs}ms]`);
281
+ } else {
282
+ log(current, `✖ Wait for search indexes ended: ${event.status} [${event.waitedMs}ms]`);
283
+ }
284
+ },
262
285
  'converge:end': (event) => {
263
286
  if (current && event.success) log(current, `✔ Converged ${event.changed} change(s)`);
264
287
  },
@@ -362,6 +385,7 @@ function createMigrationProcessor(options = {}) {
362
385
  inSync: result.inSync,
363
386
  collections: result.collections,
364
387
  ...(result.unstable ? { unstable: result.unstable } : {}),
388
+ ...(result.search ? { search: result.search } : {}),
365
389
  ...(ctx.runId ? { runId: ctx.runId } : {}),
366
390
  lockWaitMs: waitedMs,
367
391
  });
@@ -252,10 +252,13 @@ async function planDownJobs(kit, options = {}) {
252
252
  * that will do the work. All false on a queue that cannot read jobs back.
253
253
  */
254
254
  async function foreignJobs(queue, ids, groupId) {
255
- if (typeof queue.getJob !== 'function') return ids.map(() => false);
256
- // A first deploy can enqueue hundreds of jobs: read them back a few at a time.
257
- const stored = await mapLimit(ids, LOOKUP_CONCURRENCY, (id) => queue.getJob(id));
258
- return stored.map((job) => Boolean(job && job.data?.groupId !== groupId));
255
+ if (typeof queue.getJob !== 'function') return new Array(ids.length).fill(false);
256
+ // A first deploy can enqueue hundreds of jobs: read them back a few at a time,
257
+ // each answered as it arrives.
258
+ return mapLimit(ids, LOOKUP_CONCURRENCY, async (id) => {
259
+ const job = await queue.getJob(id);
260
+ return Boolean(job && job.data?.groupId !== groupId);
261
+ });
259
262
  }
260
263
 
261
264
  /**
@@ -299,16 +302,16 @@ async function enqueueGroup(queue, kit, plan, { queueEvents, getQueueEvents } =
299
302
  expected: specs.length,
300
303
  });
301
304
  }
302
- jobs = plan.migrations.map((migration, index) => ({
303
- id: String(added[index].id),
304
- migration,
305
- index,
306
- }));
307
- const foreign = await foreignJobs(
308
- queue,
309
- added.map((job) => String(job.id)),
310
- groupId,
311
- );
305
+ // The ids of every job added, and the migration ones as jobs — one pass.
306
+ const ids = new Array(added.length);
307
+ jobs = new Array(plan.migrations.length);
308
+ for (const [index, job] of added.entries()) {
309
+ ids[index] = String(job.id);
310
+ if (index < jobs.length) {
311
+ jobs[index] = { id: ids[index], migration: plan.migrations[index], index };
312
+ }
313
+ }
314
+ const foreign = await foreignJobs(queue, ids, groupId);
312
315
  for (const job of jobs) {
313
316
  if (foreign[job.index]) deduplicated.push(job.migration);
314
317
  }