@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,11 +1,13 @@
1
1
  const { needsConfirmation } = require('../../core/converge-plan.js');
2
+ const { searchBuildState } = require('../../core/search-index-spec.js');
2
3
  const { ConfigInvalidError, RunAbortedError } = require('../../errors/index.js');
3
4
  const { confirm, defineCommand, EXIT_CODES } = require('../shared.js');
4
5
  const { renderConvergeHistory, renderConvergeTable } = require('../table.js');
5
6
 
6
7
  /**
7
8
  * Every row the operator must confirm, across all collections: a dropped or
8
- * rebuilt index, or a validator change on a collection that holds data.
9
+ * rebuilt index, a dropped search index, or a validator change on a
10
+ * collection that holds data.
9
11
  */
10
12
  function actionsToConfirm(plan) {
11
13
  const found = [];
@@ -36,14 +38,18 @@ function assertNotStopped(stopRequested) {
36
38
  function registerConverge(program) {
37
39
  defineCommand(program, {
38
40
  name: 'converge',
39
- description: 'Bring declared collections (indexes, validators) to their declared state',
41
+ description:
42
+ 'Bring declared collections (indexes, search indexes, validators) to their declared state',
40
43
  options: [
41
44
  ['--dry-run', 'Show what would change without changing anything'],
42
45
  [
43
46
  '--check',
44
47
  `Exit with code ${EXIT_CODES.COLLECTIONS_DRIFT} if anything would change (CI gate; implies --dry-run)`,
45
48
  ],
46
- ['--prune', 'Drop undeclared indexes (in collections whose definition does not decide)'],
49
+ [
50
+ '--prune',
51
+ 'Drop undeclared indexes and search indexes (in collections whose definition does not decide)',
52
+ ],
47
53
  ['--ordered', 'Refuse while any migration is still pending'],
48
54
  ['--reason <text>', 'Why — recorded in the converge history (who: the OS user)'],
49
55
  ['--history', 'Show the converge history instead of converging (read-only)'],
@@ -52,6 +58,11 @@ function registerConverge(program) {
52
58
  '--rebuild-unique',
53
59
  'Allow rebuilding a unique index (drops the constraint until the new one is built)',
54
60
  ],
61
+ [
62
+ '--wait-search',
63
+ 'Wait until every declared search index is queryable (overrides waitForSearchIndexes)',
64
+ ],
65
+ ['--no-wait-search', 'Do not wait for search indexes, whatever waitForSearchIndexes says'],
55
66
  [
56
67
  '-y, --yes',
57
68
  'Drop and rebuild indexes, and change validators, without asking (required with --json)',
@@ -64,6 +75,13 @@ function registerConverge(program) {
64
75
  // ask only when the plan drops or rebuilds an index, then apply — the way
65
76
  // `unlock` reads the lock before asking.
66
77
  run: async (migrator, opts, _positionals, { logger, json, spinner, stopRequested }) => {
78
+ const waitSearch = typeof opts.waitSearch === 'boolean' ? opts.waitSearch : undefined;
79
+ if (waitSearch !== undefined && (opts.history || opts.dryRun || opts.check)) {
80
+ throw new ConfigInvalidError(
81
+ `--${waitSearch ? '' : 'no-'}wait-search applies to a real converge — not to ` +
82
+ `${opts.history ? '--history' : opts.check ? '--check' : '--dry-run'}`,
83
+ );
84
+ }
67
85
  if (opts.history) {
68
86
  return migrator.convergeHistory(
69
87
  opts.limit !== undefined ? { limit: Number(opts.limit) } : {},
@@ -98,16 +116,13 @@ function registerConverge(program) {
98
116
  // applies without one.
99
117
  if (json) {
100
118
  throw new ConfigInvalidError(
101
- `converge would drop or rebuild an index, or change a validator ` +
119
+ `converge would drop or rebuild an index, drop a search index, or change a validator ` +
102
120
  `(${destructive.length} change(s)) — pass --yes to confirm in --json mode`,
103
121
  { destructive },
104
122
  );
105
123
  }
106
124
  logger.info(renderConvergeTable(plan));
107
- const uniqueRebuilds = destructive.filter(
108
- (action) => action.action === 'recreate' && opts.rebuildUnique,
109
- );
110
- if (uniqueRebuilds.length > 0) {
125
+ if (opts.rebuildUnique && destructive.some((action) => action.action === 'recreate')) {
111
126
  logger.warn(
112
127
  '⚠ --rebuild-unique: a rebuilt unique index enforces nothing until it is built ' +
113
128
  'again — a duplicate written in between makes it unbuildable',
@@ -127,6 +142,7 @@ function registerConverge(program) {
127
142
  noLock: opts.noLock,
128
143
  ...prune,
129
144
  ...ordered,
145
+ ...(waitSearch !== undefined ? { waitForSearchIndexes: waitSearch } : {}),
130
146
  ...(opts.reason !== undefined ? { reason: opts.reason } : {}),
131
147
  });
132
148
  } finally {
@@ -147,9 +163,21 @@ function registerConverge(program) {
147
163
  logger.info(renderConvergeTable(result, { all: Boolean(opts.verbose) }));
148
164
  },
149
165
  after: (result, { logger, opts }) => {
150
- if (!opts.check || result === undefined || result.inSync) return;
166
+ if (!opts.check || result === undefined) return;
167
+ // A search index that failed to build serves nothing, whatever its
168
+ // definition says — the gate fails on it too, though converge cannot fix it.
151
169
  // .error writes to stderr, so JSON stdout stays a single clean document.
152
- logger.error('✖ The database differs from the declared collections');
170
+ let failed = 0;
171
+ for (const index of result.search?.notReady ?? []) {
172
+ if (searchBuildState(index) !== 'failed') continue;
173
+ failed += 1;
174
+ logger.error(
175
+ `✖ Search index ${index.collection} "${index.name}" failed to build` +
176
+ `${index.message ? `: ${index.message}` : ''}`,
177
+ );
178
+ }
179
+ if (result.inSync && failed === 0) return;
180
+ if (!result.inSync) logger.error('✖ The database differs from the declared collections');
153
181
  // A dedicated code: a CI gate must tell "out of step" (act: converge)
154
182
  // from "the check itself crashed" (act: page).
155
183
  process.exitCode = EXIT_CODES.COLLECTIONS_DRIFT;
package/src/cli/table.js CHANGED
@@ -1,3 +1,5 @@
1
+ const { TARGET_LABELS, isDestructive } = require('../core/converge-plan.js');
2
+ const { searchBuildState } = require('../core/search-index-spec.js');
1
3
  const { createColors, stripAnsi } = require('../utils/colors.js');
2
4
  const { formatDateTime } = require('../utils/date.js');
3
5
  // Shared with the logger and spinner — cell values come from the changelog and
@@ -270,12 +272,43 @@ function convergeActionCell(colors, action) {
270
272
  }
271
273
  }
272
274
 
273
- /** The detail column: what differs, or why the row is what it is */
275
+ /** Where the server is with a search index, when it is not simply serving it */
276
+ function searchBuildDetail(build) {
277
+ if (build === undefined) return '';
278
+ switch (searchBuildState(build)) {
279
+ case 'serving':
280
+ return '';
281
+ case 'failed':
282
+ return `FAILED${build.message ? `: ${build.message}` : ''}`;
283
+ case 'updating':
284
+ return 'updating';
285
+ case 'stale':
286
+ return 'STALE — not replicating';
287
+ default:
288
+ return build.status;
289
+ }
290
+ }
291
+
292
+ /** Whether a search index row is one worth showing even when nothing changes: not serving yet */
293
+ function searchNotServing(action) {
294
+ return action.target === 'searchIndex' && searchBuildDetail(action.build) !== '';
295
+ }
296
+
297
+ /** The detail column: what differs, or why the row is what it is — and a search index's build */
274
298
  function convergeDetail(action) {
275
299
  if (action.reason === 'name' && action.liveName !== undefined) {
276
300
  return `renamed from "${action.liveName}"`;
277
301
  }
278
- return action.reason ?? '';
302
+ if (action.target !== 'searchIndex') return action.reason ?? '';
303
+ const parts = [];
304
+ const type = action.to?.type ?? action.from?.type;
305
+ if (type === 'vectorSearch' && (action.action === 'create' || action.action === 'modify')) {
306
+ parts.push('vectorSearch');
307
+ }
308
+ if (action.reason) parts.push(action.reason);
309
+ const build = searchBuildDetail(action.build);
310
+ if (build) parts.push(build);
311
+ return parts.join(' · ');
279
312
  }
280
313
 
281
314
  /**
@@ -286,22 +319,32 @@ function convergeDetail(action) {
286
319
  function renderConvergeTable(result, { all = false } = {}) {
287
320
  const colors = palette();
288
321
  const cells = [];
289
- const counts = { change: 0, destructive: 0, conflict: 0, keep: 0, unchanged: 0, touched: 0 };
322
+ const counts = {
323
+ change: 0,
324
+ destructive: 0,
325
+ conflict: 0,
326
+ keep: 0,
327
+ searchKeep: 0,
328
+ skip: 0,
329
+ unchanged: 0,
330
+ touched: 0,
331
+ };
290
332
  for (const collection of result.collections) {
291
333
  let changes = 0;
292
334
  for (const action of collection.actions) {
293
335
  if (action.action === 'unchanged') counts.unchanged += 1;
336
+ else if (action.action === 'keep' && action.target === 'searchIndex') counts.searchKeep += 1;
294
337
  else if (action.action === 'keep') counts.keep += 1;
295
338
  else if (action.action === 'conflict') counts.conflict += 1;
339
+ else if (action.action === 'skip') counts.skip += 1;
296
340
  else changes += 1;
297
- if (action.target === 'index' && (action.action === 'drop' || action.action === 'recreate')) {
298
- counts.destructive += 1;
299
- }
300
- if (action.action === 'unchanged' && !all) continue;
341
+ if (isDestructive(action)) counts.destructive += 1;
342
+ if (action.action === 'unchanged' && !all && !searchNotServing(action)) continue;
343
+ const named = action.target === 'index' || action.target === 'searchIndex';
301
344
  cells.push([
302
345
  truncate(sanitize(collection.name), MAX_NAME_WIDTH),
303
- action.target,
304
- action.target === 'index' ? truncate(sanitize(action.name), MAX_NAME_WIDTH) : '',
346
+ TARGET_LABELS[action.target] ?? action.target,
347
+ named ? truncate(sanitize(action.name), MAX_NAME_WIDTH) : '',
305
348
  convergeActionCell(colors, action.action),
306
349
  truncate(sanitize(convergeDetail(action)), MAX_DETAIL_WIDTH),
307
350
  ]);
@@ -323,6 +366,22 @@ function renderConvergeTable(result, { all = false } = {}) {
323
366
  if (counts.destructive > 0) parts.push(`${counts.destructive} drop/rebuild`);
324
367
  if (counts.conflict > 0) parts.push(colors.red(`${counts.conflict} conflict(s)`));
325
368
  if (counts.keep > 0) parts.push(`${counts.keep} undeclared index(es) kept`);
369
+ if (counts.searchKeep > 0) parts.push(`${counts.searchKeep} undeclared search index(es) kept`);
370
+ if (counts.skip > 0) {
371
+ parts.push(colors.yellow(`${counts.skip} search index(es) skipped — Search unavailable`));
372
+ }
373
+ let building = 0;
374
+ let stale = 0;
375
+ let failed = 0;
376
+ for (const index of result.search?.notReady ?? []) {
377
+ const state = searchBuildState(index);
378
+ if (state === 'failed') failed += 1;
379
+ else if (state === 'stale') stale += 1;
380
+ else building += 1;
381
+ }
382
+ if (building > 0) parts.push(`${building} search index(es) building`);
383
+ if (stale > 0) parts.push(colors.yellow(`${stale} search index(es) stale`));
384
+ if (failed > 0) parts.push(colors.red(`${failed} search index(es) failed`));
326
385
  if (counts.unchanged > 0 && !all) parts.push(`${counts.unchanged} unchanged`);
327
386
  const line = parts.join(' · ');
328
387
  if (cells.length === 0) return line;
package/src/core/audit.js CHANGED
@@ -1,5 +1,9 @@
1
+ const { mapLimit } = require('../utils/concurrency.js');
1
2
  const { errorText } = require('../utils/error.js');
3
+ const { SEARCH_UNAVAILABLE_HINT, listSearchIndexes, probeSearch } = require('./converge-search.js');
2
4
  const { toLockInfo } = require('./lock.js');
5
+ const { normalizeLiveSearchIndex, searchBuildState } = require('./search-index-spec.js');
6
+ const { READ_OPTIONS, readServer } = require('./server-info.js');
3
7
 
4
8
  /** Changelog indexes ensureIndexes() creates — audit warns when any is absent */
5
9
  const EXPECTED_INDEXES = [
@@ -12,14 +16,16 @@ const EXPECTED_INDEXES = [
12
16
 
13
17
  /**
14
18
  * Read-only health check of the setup: configuration, connectivity,
15
- * transaction support, indexes, lock state and checksum drift.
19
+ * transaction support, indexes, lock state, checksum drift — and Atlas Search,
20
+ * when declared collections hold search indexes.
16
21
  *
17
22
  * Fixes nothing — it reports, so an operator can see in one command why a
18
23
  * migration would fail before running one. Every check is independent: a
19
24
  * failure in one is recorded and the rest still run.
20
25
  *
21
26
  * Pure orchestration over capabilities the MigratorKit injects (`deps`), so it
22
- * needs none of the kit's private state.
27
+ * needs none of the kit's private state. `deps.definitions()` (optional) gives
28
+ * the declared collections, for the search check.
23
29
  */
24
30
  async function runAudit(deps) {
25
31
  const checks = [];
@@ -133,7 +139,10 @@ async function runAudit(deps) {
133
139
  record('checksums', 'warn', `Could not read status: ${errorText(error)}`);
134
140
  }
135
141
 
136
- // 7. Runtime. TypeScript migrations need a runtime that can strip types.
142
+ // 7. Search — only where declared collections hold search indexes.
143
+ await auditSearch(deps, db, config, record);
144
+
145
+ // 8. Runtime. TypeScript migrations need a runtime that can strip types.
137
146
  // Feature detection instead of version parsing: it also catches a run
138
147
  // under `--no-experimental-strip-types` on an otherwise capable Node.
139
148
  const nodeVersion = process.versions.node;
@@ -152,6 +161,82 @@ async function runAudit(deps) {
152
161
  return auditReport(checks);
153
162
  }
154
163
 
164
+ /** Search index lists the audit reads at once */
165
+ const AUDIT_READ_CONCURRENCY = 8;
166
+
167
+ /**
168
+ * Whether the server has the Atlas Search that declared search indexes need,
169
+ * and whether any of them failed to build. Records nothing when none is
170
+ * declared — or when the definitions do not load: converge reports that
171
+ * itself, with every issue.
172
+ */
173
+ async function auditSearch(deps, db, config, record) {
174
+ if (typeof deps.definitions !== 'function') return;
175
+ let definitions;
176
+ try {
177
+ definitions = await deps.definitions();
178
+ } catch {
179
+ return;
180
+ }
181
+ // One pass: the collections that declare search indexes, the names each
182
+ // declares, and how many there are in all.
183
+ const declaring = [];
184
+ let declared = 0;
185
+ for (const definition of definitions) {
186
+ if (definition.searchIndexes === undefined) continue;
187
+ const names = new Set();
188
+ for (const index of definition.searchIndexes) names.add(index.name);
189
+ declaring.push({ definition, names });
190
+ declared += definition.searchIndexes.length;
191
+ }
192
+ if (declaring.length === 0) return;
193
+ const counted = `${declared} search index(es) declared in ${declaring.length} collection(s)`;
194
+ try {
195
+ const probe = await probeSearch(db, await readServer(db), {
196
+ collection: declaring[0].definition.name,
197
+ readOptions: READ_OPTIONS,
198
+ });
199
+ if (!probe.available) {
200
+ const skip = config.onSearchUnavailable === 'skip';
201
+ record(
202
+ 'search',
203
+ skip ? 'warn' : 'fail',
204
+ `Not available on this server (${probe.reason}) — ${counted}; ` +
205
+ (skip
206
+ ? "converge skips them (onSearchUnavailable: 'skip')"
207
+ : `converge refuses them: ${SEARCH_UNAVAILABLE_HINT}`),
208
+ );
209
+ return;
210
+ }
211
+ const failed = [];
212
+ const stale = [];
213
+ // One list per collection, a few at a time; reported in declaration order.
214
+ const lists = await mapLimit(declaring, AUDIT_READ_CONCURRENCY, ({ definition }) =>
215
+ listSearchIndexes(db, definition.name, READ_OPTIONS),
216
+ );
217
+ for (const [position, { definition, names }] of declaring.entries()) {
218
+ for (const raw of lists[position]) {
219
+ const index = normalizeLiveSearchIndex(raw);
220
+ if (!names.has(index.name)) continue;
221
+ const state = searchBuildState(index);
222
+ const label = `${definition.name}.${index.name}${index.message ? ` (${index.message})` : ''}`;
223
+ if (state === 'failed') failed.push(label);
224
+ else if (state === 'stale') stale.push(label);
225
+ }
226
+ }
227
+ if (failed.length > 0 || stale.length > 0) {
228
+ const parts = [];
229
+ if (failed.length > 0) parts.push(`failed to build: ${failed.join(', ')}`);
230
+ if (stale.length > 0) parts.push(`stale (not replicating): ${stale.join(', ')}`);
231
+ record('search', 'warn', `Available — ${parts.join('; ')}`);
232
+ } else {
233
+ record('search', 'pass', `Available — ${counted}`);
234
+ }
235
+ } catch (error) {
236
+ record('search', 'warn', `Could not check Atlas Search: ${errorText(error)}`);
237
+ }
238
+ }
239
+
155
240
  /** Roll individual checks up into the report shape (one pass, both counters) */
156
241
  function auditReport(checks) {
157
242
  let failed = 0;
@@ -1,12 +1,13 @@
1
1
  const fs = require('node:fs/promises');
2
2
  const path = require('node:path');
3
3
  const { ConfigInvalidError } = require('../errors/index.js');
4
- const { isPlainObject, regExpIssue, toWire } = require('../utils/canonical.js');
4
+ const { isPlainObject, toWire, unsendable } = require('../utils/canonical.js');
5
5
  const { isCollectionName } = require('../utils/collection-name.js');
6
6
  const { mapLimit } = require('../utils/concurrency.js');
7
7
  const { errorText } = require('../utils/error.js');
8
8
  const { importUserFile, tsLoadMessageOrNull } = require('../utils/loader.js');
9
9
  const { indexIssues, normalizeDeclaredIndex, sameDeclaredSignature } = require('./index-spec.js');
10
+ const { normalizeDeclaredSearchIndex, searchIndexIssues } = require('./search-index-spec.js');
10
11
 
11
12
  /**
12
13
  * Declared collections: validating a definition, normalizing it for the
@@ -23,6 +24,7 @@ const { indexIssues, normalizeDeclaredIndex, sameDeclaredSignature } = require('
23
24
  const DEFINITION_KEYS = [
24
25
  'name',
25
26
  'indexes',
27
+ 'searchIndexes',
26
28
  'validator',
27
29
  'validationLevel',
28
30
  'validationAction',
@@ -40,27 +42,6 @@ function join(base, key) {
40
42
  return base.endsWith(':') ? `${base} ${key}` : `${base}.${key}`;
41
43
  }
42
44
 
43
- /** What makes a validator unsendable — a function, a symbol, a cycle — or null */
44
- function unsendable(value, seen = new Set()) {
45
- if (seen.size === 0) {
46
- const issue = regExpIssue(value);
47
- if (issue) return issue;
48
- }
49
- const type = typeof value;
50
- if (type === 'function') return 'must not contain functions';
51
- if (type === 'symbol') return 'must not contain symbols';
52
- if (value === null || type !== 'object') return null;
53
- if (seen.has(value)) return 'must not contain circular references';
54
- seen.add(value);
55
- const items = Array.isArray(value) ? value : isPlainObject(value) ? Object.values(value) : [];
56
- for (const item of items) {
57
- const reason = unsendable(item, seen);
58
- if (reason) return reason;
59
- }
60
- seen.delete(value);
61
- return null;
62
- }
63
-
64
45
  const isEmptyObject = (value) => isPlainObject(value) && Object.keys(value).length === 0;
65
46
 
66
47
  function indexListIssues(indexes, base, issues) {
@@ -99,6 +80,34 @@ function indexListIssues(indexes, base, issues) {
99
80
  }
100
81
  }
101
82
 
83
+ function searchIndexListIssues(searchIndexes, base, issues) {
84
+ const listPath = join(base, 'searchIndexes');
85
+ if (!Array.isArray(searchIndexes)) {
86
+ issues.push({ path: listPath, message: 'must be an array of search index definitions' });
87
+ return;
88
+ }
89
+ const names = new Map();
90
+ for (const [position, index] of searchIndexes.entries()) {
91
+ const indexPath = `${listPath}[${position}]`;
92
+ const found = searchIndexIssues(index, indexPath);
93
+ issues.push(...found);
94
+ if (found.length > 0) continue;
95
+ const { name } = normalizeDeclaredSearchIndex(index);
96
+ if (names.has(name)) {
97
+ issues.push({
98
+ path: indexPath,
99
+ message:
100
+ `has the same name as searchIndexes[${names.get(name)}] ("${name}")` +
101
+ (index.name === undefined
102
+ ? ' — a search index declared without a name is "default"'
103
+ : ''),
104
+ });
105
+ } else {
106
+ names.set(name, position);
107
+ }
108
+ }
109
+ }
110
+
102
111
  /**
103
112
  * Validate one collection definition, returning `{ path, message }` issues
104
113
  * (empty when valid). `path` prefixes every issue (`collections[2]`, or
@@ -137,13 +146,20 @@ function definitionIssues(definition, { path: base, reserved = [], fallbackName
137
146
  report('name', `"${name}" is one of migronaut's own collections`);
138
147
  }
139
148
 
140
- if (definition.indexes === undefined && definition.validator === undefined) {
149
+ if (
150
+ definition.indexes === undefined &&
151
+ definition.searchIndexes === undefined &&
152
+ definition.validator === undefined
153
+ ) {
141
154
  issues.push({
142
155
  path: self,
143
- message: 'declares neither indexes nor a validator — nothing to manage',
156
+ message: 'declares no indexes, searchIndexes or validator — nothing to manage',
144
157
  });
145
158
  }
146
159
  if (definition.indexes !== undefined) indexListIssues(definition.indexes, base, issues);
160
+ if (definition.searchIndexes !== undefined) {
161
+ searchIndexListIssues(definition.searchIndexes, base, issues);
162
+ }
147
163
 
148
164
  const { validator } = definition;
149
165
  if (validator !== undefined && validator !== null) {
@@ -203,8 +219,9 @@ function collectionsIssues(list, { reserved = [] } = {}) {
203
219
 
204
220
  /**
205
221
  * A valid definition in the planner's shape: indexes normalized (effective
206
- * names, server-form keys, the spec to send), the validator cleaned for the
207
- * wire. `indexes`/`validator` stay `undefined` when not managed.
222
+ * names, server-form keys, the spec to send), search indexes with their
223
+ * default name and type, the validator cleaned for the wire.
224
+ * `indexes`/`searchIndexes`/`validator` stay `undefined` when not managed.
208
225
  */
209
226
  function normalizeDefinition(definition, { name, source } = {}) {
210
227
  const { validator } = definition;
@@ -212,6 +229,13 @@ function normalizeDefinition(definition, { name, source } = {}) {
212
229
  name: definition.name ?? name,
213
230
  source,
214
231
  indexes: definition.indexes?.map((index) => normalizeDeclaredIndex(index)),
232
+ ...(definition.searchIndexes !== undefined
233
+ ? {
234
+ searchIndexes: definition.searchIndexes.map((index) =>
235
+ normalizeDeclaredSearchIndex(index),
236
+ ),
237
+ }
238
+ : {}),
215
239
  validator: validator === undefined || validator === null ? validator : toWire(validator),
216
240
  ...(definition.validationLevel !== undefined
217
241
  ? { validationLevel: definition.validationLevel }
@@ -4,6 +4,9 @@ const { pathToFileURL } = require('node:url');
4
4
  const { ConfigInvalidError } = require('../errors/index.js');
5
5
  const { isCollectionName } = require('../utils/collection-name.js');
6
6
  const { TELEMETRY_KEYS, telemetryIssues } = require('../utils/telemetry.js');
7
+
8
+ /** The keys a `telemetry` object may hold — a Set, read once per key */
9
+ const TELEMETRY_KEY_SET = new Set(TELEMETRY_KEYS);
7
10
  const { applyEnvFile } = require('../utils/env.js');
8
11
  const { errorText } = require('../utils/error.js');
9
12
  const { resolveLogger } = require('../utils/logger.js');
@@ -32,6 +35,9 @@ const DEFAULT_CONFIG = {
32
35
  onOutOfOrder: 'warn',
33
36
  reloadMigrations: false,
34
37
  convergeAfterUp: false,
38
+ onSearchUnavailable: 'fail',
39
+ waitForSearchIndexes: false,
40
+ searchIndexWaitTimeoutMs: 600_000,
35
41
  };
36
42
 
37
43
  /** Candidate config file names, checked in priority order within the cwd */
@@ -150,6 +156,19 @@ const CONFIG_KEYS = [
150
156
  optional: true,
151
157
  },
152
158
  { path: 'convergeAfterUp', check: isBoolean, message: 'must be a boolean', optional: true },
159
+ {
160
+ path: 'onSearchUnavailable',
161
+ check: (value) => value === 'fail' || value === 'skip',
162
+ message: "must be 'fail' or 'skip'",
163
+ optional: true,
164
+ },
165
+ { path: 'waitForSearchIndexes', check: isBoolean, message: 'must be a boolean', optional: true },
166
+ {
167
+ path: 'searchIndexWaitTimeoutMs',
168
+ check: isPositiveInteger,
169
+ message: 'must be a positive integer',
170
+ optional: true,
171
+ },
153
172
  ];
154
173
 
155
174
  /**
@@ -331,6 +350,17 @@ const ENV_KEYS = [
331
350
  { env: 'MIGRONAUT_RELOAD_MIGRATIONS', path: 'reloadMigrations', parse: parseBoolean },
332
351
  { env: 'MIGRONAUT_COLLECTIONS_DIR', path: 'collectionsDir', parse: parseString },
333
352
  { env: 'MIGRONAUT_CONVERGE_AFTER_UP', path: 'convergeAfterUp', parse: parseBoolean },
353
+ {
354
+ env: 'MIGRONAUT_ON_SEARCH_UNAVAILABLE',
355
+ path: 'onSearchUnavailable',
356
+ parse: parseEnum(['fail', 'skip']),
357
+ },
358
+ { env: 'MIGRONAUT_WAIT_FOR_SEARCH_INDEXES', path: 'waitForSearchIndexes', parse: parseBoolean },
359
+ {
360
+ env: 'MIGRONAUT_SEARCH_INDEX_WAIT_TIMEOUT_MS',
361
+ path: 'searchIndexWaitTimeoutMs',
362
+ parse: parsePositiveInteger,
363
+ },
334
364
  ];
335
365
 
336
366
  /** Build a partial config from the MIGRONAUT_* environment variables */
@@ -523,7 +553,7 @@ async function loadConfig(options = {}) {
523
553
  // (`trace`, `metrics`) instead of a tracer and a meter turns it off silently.
524
554
  if (config.telemetry) {
525
555
  for (const key in config.telemetry) {
526
- if (!TELEMETRY_KEYS.includes(key)) (unknown ??= []).push(`telemetry.${key}`);
556
+ if (!TELEMETRY_KEY_SET.has(key)) (unknown ??= []).push(`telemetry.${key}`);
527
557
  }
528
558
  }
529
559
  if (unknown) {