@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/CHANGELOG.md +116 -0
- package/README.md +35 -8
- package/bullmq.d.ts +16 -1
- package/index.d.ts +266 -13
- package/migronaut.schema.json +57 -1
- package/package.json +1 -1
- package/src/bullmq/processor.js +25 -1
- package/src/bullmq/producer.js +17 -14
- package/src/cli/commands/converge.js +38 -10
- package/src/cli/table.js +68 -9
- package/src/core/audit.js +88 -3
- package/src/core/collections.js +50 -26
- package/src/core/config.js +31 -1
- package/src/core/converge-plan.js +260 -57
- package/src/core/converge-search-run.js +440 -0
- package/src/core/converge-search.js +404 -0
- package/src/core/converge.js +347 -190
- package/src/core/index-spec.js +27 -16
- package/src/core/lock.js +50 -12
- package/src/core/migrator.js +47 -14
- package/src/core/options.js +16 -1
- package/src/core/search-index-spec.js +758 -0
- package/src/core/server-info.js +63 -0
- package/src/errors/index.js +9 -5
- package/src/utils/canonical.js +34 -1
- package/src/utils/telemetry.js +18 -1
- package/src/utils/template.js +7 -0
|
@@ -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
|
|
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:
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 = {
|
|
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 (
|
|
298
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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;
|
package/src/core/collections.js
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
|
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),
|
|
207
|
-
*
|
|
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 }
|
package/src/core/config.js
CHANGED
|
@@ -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 (!
|
|
556
|
+
if (!TELEMETRY_KEY_SET.has(key)) (unknown ??= []).push(`telemetry.${key}`);
|
|
527
557
|
}
|
|
528
558
|
}
|
|
529
559
|
if (unknown) {
|