@alexify/migronaut 2.1.0 → 2.3.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 +223 -0
- package/README.md +68 -10
- package/bullmq.d.ts +465 -7
- package/index.d.ts +1272 -18
- package/migronaut.schema.json +150 -2
- package/package.json +8 -2
- package/src/bullmq/background-processor.js +469 -0
- package/src/bullmq/index.js +12 -0
- package/src/bullmq/jobs.js +254 -7
- package/src/bullmq/processor.js +153 -15
- package/src/bullmq/producer.js +202 -27
- package/src/bullmq/service.js +480 -45
- package/src/cli/commands/background.js +500 -0
- package/src/cli/commands/converge.js +38 -10
- package/src/cli/commands/create.js +6 -0
- package/src/cli/exit-codes.js +6 -0
- package/src/cli/index.js +2 -0
- package/src/cli/table.js +68 -9
- package/src/core/audit.js +98 -3
- package/src/core/background-audit.js +139 -0
- package/src/core/background-drift.js +126 -0
- package/src/core/background-dry-run.js +366 -0
- package/src/core/background-engine.js +818 -0
- package/src/core/background-kit.js +425 -0
- package/src/core/background-partition.js +298 -0
- package/src/core/background-runner.js +305 -0
- package/src/core/background-sandbox.js +701 -0
- package/src/core/background-shard.js +542 -0
- package/src/core/background-spec.js +597 -0
- package/src/core/background-store.js +951 -0
- package/src/core/background-throttle.js +269 -0
- package/src/core/background-watch-plan.js +164 -0
- package/src/core/background-watch-store.js +78 -0
- package/src/core/background-watch.js +605 -0
- package/src/core/background.js +1121 -0
- package/src/core/bson-peer.js +23 -0
- package/src/core/changelog.js +32 -0
- package/src/core/collections.js +125 -31
- package/src/core/config.js +133 -13
- package/src/core/converge-plan.js +343 -61
- package/src/core/converge-search-run.js +440 -0
- package/src/core/converge-search.js +404 -0
- package/src/core/converge.js +428 -183
- package/src/core/index-spec.js +27 -16
- package/src/core/lock.js +97 -32
- package/src/core/migrator.js +951 -26
- package/src/core/options.js +32 -1
- package/src/core/run.js +26 -12
- package/src/core/runner.js +1 -1
- package/src/core/search-index-spec.js +758 -0
- package/src/core/server-info.js +70 -0
- package/src/core/shard-info.js +76 -0
- package/src/core/versioning-spec.js +181 -0
- package/src/errors/index.js +97 -5
- package/src/index.js +16 -0
- package/src/utils/canonical.js +34 -1
- package/src/utils/error.js +11 -2
- package/src/utils/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/telemetry.js +125 -1
- package/src/utils/template.js +69 -1
- package/src/versioning/config.js +155 -0
- package/src/versioning/document.js +326 -0
- package/src/versioning/index.js +50 -0
- package/src/versioning/internal.js +279 -0
- package/src/versioning/mongoose.js +151 -0
- package/src/versioning/occ.js +318 -0
- package/src/versioning/registry.js +187 -0
- package/src/versioning/upcaster.js +213 -0
- package/versioning.d.ts +666 -0
- package/versioning.js +1 -0
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
const {
|
|
2
|
+
assign,
|
|
3
|
+
canonical,
|
|
4
|
+
deepEqual,
|
|
5
|
+
isPlainObject,
|
|
6
|
+
toWire,
|
|
7
|
+
unsendable,
|
|
8
|
+
} = require('../utils/canonical.js');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One declared Atlas Search / Vector Search index against one live index:
|
|
12
|
+
* validation, normalization, and the comparison the converge planner builds
|
|
13
|
+
* on. Pure — no database, no I/O — like index-spec.js for regular indexes.
|
|
14
|
+
*
|
|
15
|
+
* A search index definition is Atlas's own document, and Atlas keeps adding
|
|
16
|
+
* to it, so it is validated lightly (the shape that tells the two types apart,
|
|
17
|
+
* nothing about analyzers or field types) and compared whole. The invariant is
|
|
18
|
+
* the one index-spec.js keeps: an index created from a declaration must
|
|
19
|
+
* compare as unchanged against that same declaration once the server reports
|
|
20
|
+
* it — hence the documented defaults, filled in on both sides before
|
|
21
|
+
* comparing, so a definition that leaves one out matches a server that spells
|
|
22
|
+
* it out (and the other way round). An option the server reports that the
|
|
23
|
+
* declaration does not set, and whose default is not among those, is ignored
|
|
24
|
+
* (see {@link tolerateServerOptions}): a newer mongot writing a new default
|
|
25
|
+
* must not make every converge update — and Atlas rebuild — the index.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Every key a search index declaration may carry */
|
|
29
|
+
const SEARCH_INDEX_KEYS = ['name', 'type', 'definition'];
|
|
30
|
+
const SEARCH_INDEX_KEY_SET = new Set(SEARCH_INDEX_KEYS);
|
|
31
|
+
const SEARCH_INDEX_TYPES = ['search', 'vectorSearch'];
|
|
32
|
+
|
|
33
|
+
/** What the server names an index declared without a name, and the type it assumes */
|
|
34
|
+
const DEFAULT_SEARCH_INDEX_NAME = 'default';
|
|
35
|
+
const DEFAULT_SEARCH_INDEX_TYPE = 'search';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Statuses of an index the server is removing: it no longer serves queries,
|
|
39
|
+
* but its name is not free yet — a create under that name can fail until it
|
|
40
|
+
* is gone.
|
|
41
|
+
*/
|
|
42
|
+
const ABSENT_STATUSES = new Set(['DELETING', 'DOES_NOT_EXIST']);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Top-level defaults of an Atlas Search definition, as documented. Filled in
|
|
46
|
+
* on both sides — `searchAnalyzer` defaults to the effective `analyzer`.
|
|
47
|
+
*/
|
|
48
|
+
const SEARCH_DEFAULTS = Object.freeze({
|
|
49
|
+
analyzer: 'lucene.standard',
|
|
50
|
+
storedSource: false,
|
|
51
|
+
numPartitions: 1,
|
|
52
|
+
analyzers: [],
|
|
53
|
+
synonyms: [],
|
|
54
|
+
});
|
|
55
|
+
const MAPPINGS_DEFAULTS = Object.freeze({ dynamic: false, fields: {} });
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Defaults of a field mapping, by type — what `mongot` writes into the
|
|
59
|
+
* definition it reports (a `string` field comes back with `indexOptions`,
|
|
60
|
+
* `store` and `norms`, a `number` with its representation, a `document` with
|
|
61
|
+
* `dynamic`). Types not listed come back as declared.
|
|
62
|
+
*/
|
|
63
|
+
const FIELD_DEFAULTS = Object.freeze({
|
|
64
|
+
string: { indexOptions: 'offsets', store: true, norms: 'include' },
|
|
65
|
+
number: { representation: 'double', indexIntegers: true, indexDoubles: true },
|
|
66
|
+
numberFacet: { representation: 'double', indexIntegers: true, indexDoubles: true },
|
|
67
|
+
autocomplete: { minGrams: 2, maxGrams: 15, foldDiacritics: true, tokenization: 'edgeGram' },
|
|
68
|
+
token: { normalization: 'none' },
|
|
69
|
+
geo: { indexShapes: false },
|
|
70
|
+
document: { dynamic: false, fields: {} },
|
|
71
|
+
embeddedDocuments: { dynamic: false, fields: {} },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** Defaults of a vector field — and of an automated-embedding (`autoEmbed`) one */
|
|
75
|
+
const VECTOR_FIELD_DEFAULTS = Object.freeze({ quantization: 'none', indexingMethod: 'hnsw' });
|
|
76
|
+
const AUTO_EMBED_FIELD_DEFAULTS = Object.freeze({
|
|
77
|
+
numDimensions: 1024,
|
|
78
|
+
quantization: 'scalar',
|
|
79
|
+
indexingMethod: 'hnsw',
|
|
80
|
+
});
|
|
81
|
+
const HNSW_DEFAULTS = Object.freeze({ maxEdges: 16, numEdgeCandidates: 100 });
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What the server cannot change on an automated-embedding field: a new
|
|
85
|
+
* model, size, quantization or modality means new embeddings, so Atlas
|
|
86
|
+
* refuses the update — the documented way is a new index under a new name.
|
|
87
|
+
* (The field's `path` and `type` are immutable too, checked separately.)
|
|
88
|
+
*/
|
|
89
|
+
const AUTO_EMBED_IMMUTABLE = ['model', 'numDimensions', 'quantization', 'modality'];
|
|
90
|
+
|
|
91
|
+
const VECTOR = 'vector';
|
|
92
|
+
const AUTO_EMBED = 'autoEmbed';
|
|
93
|
+
|
|
94
|
+
/** Validate one search index declaration, returning `{ path, message }` issues (empty when valid) */
|
|
95
|
+
function searchIndexIssues(index, path) {
|
|
96
|
+
if (!isPlainObject(index)) {
|
|
97
|
+
return [{ path, message: 'must be a search index object ({ name?, type?, definition })' }];
|
|
98
|
+
}
|
|
99
|
+
const issues = [];
|
|
100
|
+
const report = (key, message) => issues.push({ path: `${path}.${key}`, message });
|
|
101
|
+
for (const key of Object.keys(index)) {
|
|
102
|
+
if (!SEARCH_INDEX_KEY_SET.has(key)) {
|
|
103
|
+
report(key, `is not a search index key (expected one of: ${SEARCH_INDEX_KEYS.join(', ')})`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (index.name !== undefined && (typeof index.name !== 'string' || index.name.length === 0)) {
|
|
107
|
+
report('name', 'must be a non-empty string');
|
|
108
|
+
}
|
|
109
|
+
const typeValid = index.type === undefined || SEARCH_INDEX_TYPES.includes(index.type);
|
|
110
|
+
if (!typeValid) report('type', "must be 'search' or 'vectorSearch'");
|
|
111
|
+
|
|
112
|
+
const { definition } = index;
|
|
113
|
+
if (definition === undefined) {
|
|
114
|
+
report('definition', 'is required');
|
|
115
|
+
return issues;
|
|
116
|
+
}
|
|
117
|
+
if (!isPlainObject(definition)) {
|
|
118
|
+
report('definition', 'must be an object');
|
|
119
|
+
return issues;
|
|
120
|
+
}
|
|
121
|
+
const reason = unsendable(definition);
|
|
122
|
+
if (reason) {
|
|
123
|
+
report('definition', reason);
|
|
124
|
+
return issues;
|
|
125
|
+
}
|
|
126
|
+
if (!typeValid) return issues;
|
|
127
|
+
if ((index.type ?? DEFAULT_SEARCH_INDEX_TYPE) === 'search') {
|
|
128
|
+
if (Array.isArray(definition.fields) && definition.mappings === undefined) {
|
|
129
|
+
report(
|
|
130
|
+
'definition',
|
|
131
|
+
"has top-level fields, which is a vectorSearch definition — set type: 'vectorSearch'",
|
|
132
|
+
);
|
|
133
|
+
} else if (!isPlainObject(definition.mappings)) {
|
|
134
|
+
report('definition.mappings', 'is required — an object ({ dynamic, fields })');
|
|
135
|
+
}
|
|
136
|
+
return issues;
|
|
137
|
+
}
|
|
138
|
+
vectorDefinitionIssues(definition, `${path}.definition`, issues);
|
|
139
|
+
return issues;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function vectorDefinitionIssues(definition, base, issues) {
|
|
143
|
+
if (definition.mappings !== undefined && definition.fields === undefined) {
|
|
144
|
+
issues.push({
|
|
145
|
+
path: base,
|
|
146
|
+
message: "has mappings, which is a search definition — leave type out, or set type: 'search'",
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const { fields } = definition;
|
|
151
|
+
if (!Array.isArray(fields) || fields.length === 0) {
|
|
152
|
+
issues.push({
|
|
153
|
+
path: `${base}.fields`,
|
|
154
|
+
message: 'must be a non-empty array of fields ({ type, path, … })',
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const seen = new Map();
|
|
159
|
+
const kinds = new Set();
|
|
160
|
+
for (const [position, field] of fields.entries()) {
|
|
161
|
+
const fieldPath = `${base}.fields[${position}]`;
|
|
162
|
+
const usable =
|
|
163
|
+
isPlainObject(field) &&
|
|
164
|
+
typeof field.type === 'string' &&
|
|
165
|
+
field.type.length > 0 &&
|
|
166
|
+
typeof field.path === 'string' &&
|
|
167
|
+
field.path.length > 0;
|
|
168
|
+
if (!usable) {
|
|
169
|
+
issues.push({ path: fieldPath, message: 'must be an object with a string type and path' });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (field.type === VECTOR || field.type === AUTO_EMBED) kinds.add(field.type);
|
|
173
|
+
const id = `${field.type}\u0000${field.path}`;
|
|
174
|
+
if (seen.has(id)) {
|
|
175
|
+
issues.push({
|
|
176
|
+
path: fieldPath,
|
|
177
|
+
message: `repeats the ${field.type} field "${field.path}" (fields[${seen.get(id)}])`,
|
|
178
|
+
});
|
|
179
|
+
} else {
|
|
180
|
+
seen.set(id, position);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (kinds.size > 1) {
|
|
184
|
+
issues.push({
|
|
185
|
+
path: `${base}.fields`,
|
|
186
|
+
message: 'mixes vector and autoEmbed fields — an index holds one kind or the other',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The type a definition is written for: a vector search definition is a
|
|
193
|
+
* top-level `fields` list, a search definition has `mappings`. For the live
|
|
194
|
+
* indexes of a server that does not report a type (a self-managed `mongot`).
|
|
195
|
+
*/
|
|
196
|
+
function inferSearchIndexType(definition) {
|
|
197
|
+
return isPlainObject(definition) &&
|
|
198
|
+
Array.isArray(definition.fields) &&
|
|
199
|
+
definition.mappings === undefined
|
|
200
|
+
? 'vectorSearch'
|
|
201
|
+
: DEFAULT_SEARCH_INDEX_TYPE;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A declaration in comparable form — assumes {@link searchIndexIssues} passed */
|
|
205
|
+
function normalizeDeclaredSearchIndex(index) {
|
|
206
|
+
return {
|
|
207
|
+
name: index.name ?? DEFAULT_SEARCH_INDEX_NAME,
|
|
208
|
+
type: index.type ?? DEFAULT_SEARCH_INDEX_TYPE,
|
|
209
|
+
definition: toWire(index.definition),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Whether any mongot is still building a newer definition next to the one it serves */
|
|
214
|
+
function isUpdating(raw, version) {
|
|
215
|
+
if (!Array.isArray(raw.statusDetail)) return false;
|
|
216
|
+
return raw.statusDetail.some((detail) => {
|
|
217
|
+
if (!isPlainObject(detail)) return false;
|
|
218
|
+
if (detail.stagedIndex !== undefined && detail.stagedIndex !== null) return true;
|
|
219
|
+
const served = detail.mainIndex?.definitionVersion?.version;
|
|
220
|
+
return version !== undefined && typeof served === 'number' && served < version;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The longest build message kept — mongot's reasons are a line or two, and a
|
|
226
|
+
* longer one would go whole into every log line, table cell and history entry
|
|
227
|
+
*/
|
|
228
|
+
const MAX_BUILD_MESSAGE_LENGTH = 500;
|
|
229
|
+
|
|
230
|
+
const shortMessage = (message) =>
|
|
231
|
+
message.length > MAX_BUILD_MESSAGE_LENGTH
|
|
232
|
+
? `${message.slice(0, MAX_BUILD_MESSAGE_LENGTH - 1)}…`
|
|
233
|
+
: message;
|
|
234
|
+
|
|
235
|
+
/** A `$listSearchIndexes` document in comparable form */
|
|
236
|
+
function normalizeLiveSearchIndex(raw) {
|
|
237
|
+
const definition = isPlainObject(raw.latestDefinition) ? raw.latestDefinition : {};
|
|
238
|
+
// `latestVersion` is what a self-managed mongot (an Atlas CLI local deployment) reports.
|
|
239
|
+
const version = raw.latestDefinitionVersion?.version ?? raw.latestVersion;
|
|
240
|
+
return {
|
|
241
|
+
name: raw.name,
|
|
242
|
+
type: SEARCH_INDEX_TYPES.includes(raw.type) ? raw.type : inferSearchIndexType(definition),
|
|
243
|
+
definition,
|
|
244
|
+
status: typeof raw.status === 'string' ? raw.status : undefined,
|
|
245
|
+
queryable: raw.queryable === true,
|
|
246
|
+
...(typeof raw.message === 'string' && raw.message !== ''
|
|
247
|
+
? { message: shortMessage(raw.message) }
|
|
248
|
+
: {}),
|
|
249
|
+
...(typeof version === 'number' ? { version } : {}),
|
|
250
|
+
updating: isUpdating(raw, typeof version === 'number' ? version : undefined),
|
|
251
|
+
raw,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Whether the server is removing this index (its name is not free yet) */
|
|
256
|
+
function isBeingRemoved(live) {
|
|
257
|
+
return ABSENT_STATUSES.has(live.status);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* `defaults` under `value`: what `value` leaves out takes the default. Keys
|
|
262
|
+
* are assigned, not set — a `__proto__` key must stay a key.
|
|
263
|
+
*/
|
|
264
|
+
function withDefaults(value, defaults) {
|
|
265
|
+
const out = { ...defaults };
|
|
266
|
+
for (const [key, item] of Object.entries(value)) {
|
|
267
|
+
if (item !== undefined) assign(out, key, item);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function effectiveVectorField(field) {
|
|
273
|
+
if (!isPlainObject(field)) return field;
|
|
274
|
+
const defaults =
|
|
275
|
+
field.type === VECTOR
|
|
276
|
+
? VECTOR_FIELD_DEFAULTS
|
|
277
|
+
: field.type === AUTO_EMBED
|
|
278
|
+
? AUTO_EMBED_FIELD_DEFAULTS
|
|
279
|
+
: undefined;
|
|
280
|
+
if (!defaults) return field;
|
|
281
|
+
const filled = withDefaults(field, defaults);
|
|
282
|
+
if (filled.indexingMethod === 'hnsw') {
|
|
283
|
+
filled.hnswOptions = withDefaults(
|
|
284
|
+
isPlainObject(filled.hnswOptions) ? filled.hnswOptions : {},
|
|
285
|
+
HNSW_DEFAULTS,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return filled;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Fields in one order — they are a set to the server: by type, then path,
|
|
293
|
+
* then content. `transform`, when given, is applied to each field in the same
|
|
294
|
+
* pass that computes its sort key.
|
|
295
|
+
*/
|
|
296
|
+
function sortFields(fields, transform) {
|
|
297
|
+
const keyed = new Array(fields.length);
|
|
298
|
+
for (let position = 0; position < fields.length; position++) {
|
|
299
|
+
const field = transform ? transform(fields[position]) : fields[position];
|
|
300
|
+
keyed[position] = {
|
|
301
|
+
field,
|
|
302
|
+
type: String(field?.type ?? ''),
|
|
303
|
+
path: String(field?.path ?? ''),
|
|
304
|
+
text: JSON.stringify(canonical(field)),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
keyed.sort(
|
|
308
|
+
(a, b) =>
|
|
309
|
+
compareText(a.type, b.type) || compareText(a.path, b.path) || compareText(a.text, b.text),
|
|
310
|
+
);
|
|
311
|
+
for (let position = 0; position < keyed.length; position++) {
|
|
312
|
+
keyed[position] = keyed[position].field;
|
|
313
|
+
}
|
|
314
|
+
return keyed;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const compareText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A field mapping — or a list of them, one field indexed as several types —
|
|
321
|
+
* with its type's defaults filled in, nested `fields` and `multi` analyzers
|
|
322
|
+
* too. A list is a set to the server (it reports the types in its own
|
|
323
|
+
* order), so it is sorted.
|
|
324
|
+
*/
|
|
325
|
+
function effectiveFieldMapping(mapping) {
|
|
326
|
+
if (Array.isArray(mapping)) return sortMappings(mapping, effectiveFieldMapping);
|
|
327
|
+
if (!isPlainObject(mapping)) return mapping;
|
|
328
|
+
const defaults = FIELD_DEFAULTS[mapping.type];
|
|
329
|
+
const filled = defaults ? withDefaults(mapping, defaults) : { ...mapping };
|
|
330
|
+
if (isPlainObject(filled.fields)) filled.fields = effectiveFields(filled.fields);
|
|
331
|
+
if (isPlainObject(filled.multi)) filled.multi = effectiveFields(filled.multi);
|
|
332
|
+
return filled;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The mappings of one field, in one order — the server reports them in its
|
|
337
|
+
* own. `transform`, when given, is applied to each in the pass that keys it.
|
|
338
|
+
*/
|
|
339
|
+
function sortMappings(mappings, transform) {
|
|
340
|
+
const keyed = new Array(mappings.length);
|
|
341
|
+
for (let position = 0; position < mappings.length; position++) {
|
|
342
|
+
const item = transform ? transform(mappings[position]) : mappings[position];
|
|
343
|
+
keyed[position] = { item, text: JSON.stringify(canonical(item)) };
|
|
344
|
+
}
|
|
345
|
+
keyed.sort((a, b) => compareText(a.text, b.text));
|
|
346
|
+
for (let position = 0; position < keyed.length; position++) {
|
|
347
|
+
keyed[position] = keyed[position].item;
|
|
348
|
+
}
|
|
349
|
+
return keyed;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** `{ name: mapping }` with every mapping's defaults filled in */
|
|
353
|
+
function effectiveFields(fields) {
|
|
354
|
+
const out = {};
|
|
355
|
+
for (const [name, mapping] of Object.entries(fields)) {
|
|
356
|
+
assign(out, name, effectiveFieldMapping(mapping));
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* A definition with every documented default filled in — what the server
|
|
363
|
+
* means by it. Only the defaults are filled; anything else stays as written,
|
|
364
|
+
* so a real difference is never hidden.
|
|
365
|
+
*/
|
|
366
|
+
function effectiveDefinition(type, definition) {
|
|
367
|
+
if (!isPlainObject(definition)) return definition;
|
|
368
|
+
if (type === 'vectorSearch') {
|
|
369
|
+
const filled = withDefaults(definition, { storedSource: false });
|
|
370
|
+
if (Array.isArray(filled.fields)) {
|
|
371
|
+
filled.fields = sortFields(filled.fields, effectiveVectorField);
|
|
372
|
+
}
|
|
373
|
+
return filled;
|
|
374
|
+
}
|
|
375
|
+
const filled = withDefaults(definition, SEARCH_DEFAULTS);
|
|
376
|
+
if (filled.searchAnalyzer === undefined) filled.searchAnalyzer = filled.analyzer;
|
|
377
|
+
if (isPlainObject(filled.mappings)) {
|
|
378
|
+
filled.mappings = withDefaults(filled.mappings, MAPPINGS_DEFAULTS);
|
|
379
|
+
if (isPlainObject(filled.mappings.fields)) {
|
|
380
|
+
filled.mappings.fields = effectiveFields(filled.mappings.fields);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return filled;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The embedding fields of an effective vector definition, by path, in one
|
|
388
|
+
* pass: `autoEmbed` the automated-embedding ones, `all` those and the vector
|
|
389
|
+
* ones — to tell a vector field turning into an autoEmbed one.
|
|
390
|
+
*/
|
|
391
|
+
function embeddingFields(definition) {
|
|
392
|
+
const all = new Map();
|
|
393
|
+
const autoEmbed = new Map();
|
|
394
|
+
for (const field of Array.isArray(definition?.fields) ? definition.fields : []) {
|
|
395
|
+
if (!isPlainObject(field)) continue;
|
|
396
|
+
if (field.type === AUTO_EMBED) {
|
|
397
|
+
autoEmbed.set(field.path, field);
|
|
398
|
+
all.set(field.path, field);
|
|
399
|
+
} else if (field.type === VECTOR) {
|
|
400
|
+
all.set(field.path, field);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return { all, autoEmbed };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Whether two maps hold the same keys */
|
|
407
|
+
function sameKeys(a, b) {
|
|
408
|
+
if (a.size !== b.size) return false;
|
|
409
|
+
for (const key of a.keys()) {
|
|
410
|
+
if (!b.has(key)) return false;
|
|
411
|
+
}
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* What changing a vector index from `live` to `declared` would ask of an
|
|
417
|
+
* automated-embedding field that the server refuses to change in place:
|
|
418
|
+
* `path.attribute` entries (`plot.model`, `plot.type`, `path`), or none.
|
|
419
|
+
*/
|
|
420
|
+
function immutableChanges(declared, live) {
|
|
421
|
+
const { all: wantedAll, autoEmbed: wanted } = embeddingFields(declared);
|
|
422
|
+
const { all: haveAll, autoEmbed: have } = embeddingFields(live);
|
|
423
|
+
if (wanted.size === 0 && have.size === 0) return [];
|
|
424
|
+
const changes = [];
|
|
425
|
+
for (const [path, field] of wantedAll) {
|
|
426
|
+
const current = haveAll.get(path);
|
|
427
|
+
if (current && current.type !== field.type && (wanted.has(path) || have.has(path))) {
|
|
428
|
+
changes.push(`${path}.type`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (changes.length > 0) return changes;
|
|
432
|
+
if (have.size > 0 && !sameKeys(wanted, have)) return ['path'];
|
|
433
|
+
for (const [path, field] of wanted) {
|
|
434
|
+
const current = have.get(path);
|
|
435
|
+
if (!current) continue;
|
|
436
|
+
for (const attribute of AUTO_EMBED_IMMUTABLE) {
|
|
437
|
+
if (!deepEqual(field[attribute], current[attribute])) changes.push(`${path}.${attribute}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return changes;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** `base.key`, or `key` at the top */
|
|
444
|
+
const joinPath = (base, key) => (base ? `${base}.${key}` : key);
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* `have` without the keys `want` does not have — the dotted paths of which go
|
|
448
|
+
* to `ignored`. Only where both are option objects.
|
|
449
|
+
*/
|
|
450
|
+
function trimOptions(want, have, path, ignored) {
|
|
451
|
+
if (!isPlainObject(want) || !isPlainObject(have)) return have;
|
|
452
|
+
const out = {};
|
|
453
|
+
for (const [key, value] of Object.entries(have)) {
|
|
454
|
+
if (Object.hasOwn(want, key)) assign(out, key, value);
|
|
455
|
+
else ignored.push(joinPath(path, key));
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* A `{ name: mapping }` map trimmed mapping by mapping. A name only the
|
|
462
|
+
* server has is a field the declaration does not index — a real difference,
|
|
463
|
+
* left in place.
|
|
464
|
+
*/
|
|
465
|
+
function trimFieldMap(want, have, path, ignored) {
|
|
466
|
+
if (!isPlainObject(want) || !isPlainObject(have)) return have;
|
|
467
|
+
const out = {};
|
|
468
|
+
for (const [name, mapping] of Object.entries(have)) {
|
|
469
|
+
const trimmed = Object.hasOwn(want, name)
|
|
470
|
+
? trimFieldMapping(want[name], mapping, joinPath(path, name), ignored)
|
|
471
|
+
: mapping;
|
|
472
|
+
assign(out, name, trimmed);
|
|
473
|
+
}
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* One field mapping trimmed, its nested `fields` and `multi` too — or a list
|
|
479
|
+
* of them, paired by type and put back in order. A mapping of another type
|
|
480
|
+
* is a real difference, left as it is.
|
|
481
|
+
*/
|
|
482
|
+
function trimFieldMapping(want, have, path, ignored) {
|
|
483
|
+
if (Array.isArray(want) && Array.isArray(have)) {
|
|
484
|
+
const byType = new Map();
|
|
485
|
+
for (const candidate of want) {
|
|
486
|
+
if (isPlainObject(candidate) && !byType.has(candidate.type)) {
|
|
487
|
+
byType.set(candidate.type, candidate);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return sortMappings(have, (item) => {
|
|
491
|
+
const match = isPlainObject(item) ? byType.get(item.type) : undefined;
|
|
492
|
+
return match ? trimFieldMapping(match, item, `${path}[${item.type}]`, ignored) : item;
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
if (!isPlainObject(want) || !isPlainObject(have) || want.type !== have.type) return have;
|
|
496
|
+
const out = trimOptions(want, have, path, ignored);
|
|
497
|
+
for (const key of ['fields', 'multi']) {
|
|
498
|
+
if (isPlainObject(out[key])) {
|
|
499
|
+
out[key] = trimFieldMap(want[key], out[key], joinPath(path, key), ignored);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** A vector definition trimmed: its own options, and each field's — paired by type and path */
|
|
506
|
+
function trimVectorDefinition(want, have, ignored) {
|
|
507
|
+
const out = trimOptions(want, have, '', ignored);
|
|
508
|
+
if (!Array.isArray(out.fields) || !Array.isArray(want.fields)) return out;
|
|
509
|
+
const wanted = new Map();
|
|
510
|
+
for (const candidate of want.fields) {
|
|
511
|
+
const id = isPlainObject(candidate) ? `${candidate.type}\u0000${candidate.path}` : undefined;
|
|
512
|
+
if (id !== undefined && !wanted.has(id)) wanted.set(id, candidate);
|
|
513
|
+
}
|
|
514
|
+
out.fields = sortFields(out.fields, (field) => {
|
|
515
|
+
const match = isPlainObject(field) ? wanted.get(`${field.type}\u0000${field.path}`) : undefined;
|
|
516
|
+
if (!match) return field;
|
|
517
|
+
const path = `fields[${field.type}:${field.path}]`;
|
|
518
|
+
const trimmed = trimOptions(match, field, path, ignored);
|
|
519
|
+
if (isPlainObject(trimmed.hnswOptions)) {
|
|
520
|
+
trimmed.hnswOptions = trimOptions(
|
|
521
|
+
match.hnswOptions,
|
|
522
|
+
trimmed.hnswOptions,
|
|
523
|
+
`${path}.hnswOptions`,
|
|
524
|
+
ignored,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
return trimmed;
|
|
528
|
+
});
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** A search definition trimmed: its own options, `mappings`, and every field mapping */
|
|
533
|
+
function trimSearchDefinition(want, have, ignored) {
|
|
534
|
+
const out = trimOptions(want, have, '', ignored);
|
|
535
|
+
if (isPlainObject(out.mappings) && isPlainObject(want.mappings)) {
|
|
536
|
+
const mappings = trimOptions(want.mappings, out.mappings, 'mappings', ignored);
|
|
537
|
+
if (isPlainObject(mappings.fields)) {
|
|
538
|
+
mappings.fields = trimFieldMap(
|
|
539
|
+
want.mappings.fields,
|
|
540
|
+
mappings.fields,
|
|
541
|
+
'mappings.fields',
|
|
542
|
+
ignored,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
out.mappings = mappings;
|
|
546
|
+
}
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The live definition without the options the server reports that the
|
|
552
|
+
* declaration does not set. Both sides have their documented defaults filled
|
|
553
|
+
* in first ({@link effectiveDefinition}), so these are exactly the options
|
|
554
|
+
* whose default migronaut does not know — a newer mongot writing one into
|
|
555
|
+
* every definition it reports. Compared, they would make every converge
|
|
556
|
+
* "update" the index, and Atlas rebuilds a search index on every update.
|
|
557
|
+
*
|
|
558
|
+
* Only option objects are trimmed — the definition, `mappings`, a field
|
|
559
|
+
* mapping (nested `fields` and `multi` included), a vector field and its
|
|
560
|
+
* `hnswOptions`. A field, a mapping type or a vector field only the server
|
|
561
|
+
* has is a real difference, and so is any list (`analyzers`, `synonyms`).
|
|
562
|
+
* The cost: removing such an option from a declaration is not noticed —
|
|
563
|
+
* declare the value wanted instead.
|
|
564
|
+
*
|
|
565
|
+
* Returns `{ have, ignored }`, `ignored` the dotted paths left out.
|
|
566
|
+
*/
|
|
567
|
+
function tolerateServerOptions(type, want, have) {
|
|
568
|
+
const ignored = [];
|
|
569
|
+
if (!isPlainObject(want) || !isPlainObject(have)) return { have, ignored };
|
|
570
|
+
const trimmed =
|
|
571
|
+
type === 'vectorSearch'
|
|
572
|
+
? trimVectorDefinition(want, have, ignored)
|
|
573
|
+
: trimSearchDefinition(want, have, ignored);
|
|
574
|
+
return { have: trimmed, ignored };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** How many differing paths a comparison names — enough to tell what differs */
|
|
578
|
+
const MAX_DIFF_PATHS = 5;
|
|
579
|
+
|
|
580
|
+
/** The keys of `a` and `b`, once each, sorted */
|
|
581
|
+
function unionKeys(a, b) {
|
|
582
|
+
const keys = Object.keys(a);
|
|
583
|
+
for (const key of Object.keys(b)) {
|
|
584
|
+
if (!Object.hasOwn(a, key)) keys.push(key);
|
|
585
|
+
}
|
|
586
|
+
return keys.sort();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Walk `want` against `have` once, recording every path at which they differ
|
|
591
|
+
* into `out`: `top` the top-level keys (all of them), `paths` the first
|
|
592
|
+
* {@link MAX_DIFF_PATHS} dotted paths, depth first, and `total` how many there
|
|
593
|
+
* are. Objects are walked key by key, lists of the same length item by item;
|
|
594
|
+
* anything else is a leaf — the same value is the same, and only where `===`
|
|
595
|
+
* cannot tell is it compared under {@link canonical} (an `Int32` and a number).
|
|
596
|
+
*/
|
|
597
|
+
function walkDiff(want, have, path, top, out) {
|
|
598
|
+
if (want === have) return;
|
|
599
|
+
if (isPlainObject(want) && isPlainObject(have)) {
|
|
600
|
+
for (const key of unionKeys(want, have)) {
|
|
601
|
+
walkDiff(
|
|
602
|
+
Object.hasOwn(want, key) ? want[key] : undefined,
|
|
603
|
+
Object.hasOwn(have, key) ? have[key] : undefined,
|
|
604
|
+
joinPath(path, key),
|
|
605
|
+
top ?? key,
|
|
606
|
+
out,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (Array.isArray(want) && Array.isArray(have) && want.length === have.length) {
|
|
612
|
+
for (let position = 0; position < want.length; position++) {
|
|
613
|
+
walkDiff(want[position], have[position], `${path}[${position}]`, top, out);
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (deepEqual(want, have)) return;
|
|
618
|
+
out.top.add(top);
|
|
619
|
+
out.total += 1;
|
|
620
|
+
if (out.paths.length < MAX_DIFF_PATHS) out.paths.push(path);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Compare a declaration with the live index of the same name.
|
|
625
|
+
*
|
|
626
|
+
* Returns `{ diffs, paths, more, typeChange, immutable, ignored }`: `diffs`
|
|
627
|
+
* the top-level definition keys that differ (`['type']` when the type does),
|
|
628
|
+
* `paths` the first few dotted paths that do (`mappings.fields.title.norms`)
|
|
629
|
+
* and `more` how many others; `typeChange` whether the type differs — which
|
|
630
|
+
* no update can change; `immutable` the automated-embedding attributes an
|
|
631
|
+
* update would have to change (see {@link AUTO_EMBED_IMMUTABLE}); `ignored`
|
|
632
|
+
* the options only the server reports (see {@link tolerateServerOptions}).
|
|
633
|
+
* An empty `diffs` means unchanged.
|
|
634
|
+
*/
|
|
635
|
+
function compareSearchIndex(declared, live) {
|
|
636
|
+
if (declared.type !== live.type) {
|
|
637
|
+
return {
|
|
638
|
+
diffs: ['type'],
|
|
639
|
+
paths: ['type'],
|
|
640
|
+
more: 0,
|
|
641
|
+
typeChange: true,
|
|
642
|
+
immutable: [],
|
|
643
|
+
ignored: [],
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
const want = effectiveDefinition(declared.type, declared.definition);
|
|
647
|
+
const { have, ignored } = tolerateServerOptions(
|
|
648
|
+
declared.type,
|
|
649
|
+
want,
|
|
650
|
+
effectiveDefinition(live.type, live.definition),
|
|
651
|
+
);
|
|
652
|
+
// One walk: the top-level keys that differ, and the paths within them.
|
|
653
|
+
const found = { top: new Set(), paths: [], total: 0 };
|
|
654
|
+
walkDiff(want, have, '', undefined, found);
|
|
655
|
+
const diffs = [...found.top];
|
|
656
|
+
const immutable =
|
|
657
|
+
diffs.length > 0 && declared.type === 'vectorSearch' ? immutableChanges(want, have) : [];
|
|
658
|
+
return {
|
|
659
|
+
diffs,
|
|
660
|
+
paths: found.paths,
|
|
661
|
+
more: found.total - found.paths.length,
|
|
662
|
+
typeChange: false,
|
|
663
|
+
immutable,
|
|
664
|
+
ignored,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Where the server is with a search index — from a live index or a row's
|
|
670
|
+
* build (see {@link searchBuild}) — in one word, for every place that reports
|
|
671
|
+
* it:
|
|
672
|
+
*
|
|
673
|
+
* - `serving`: queryable with its latest definition (READY, or a server that
|
|
674
|
+
* reports no status);
|
|
675
|
+
* - `updating`: queryable, with a newer definition building next to it;
|
|
676
|
+
* - `building`: not queryable yet, or a status other than READY;
|
|
677
|
+
* - `stale`: queryable, but no longer replicating from the collection — its
|
|
678
|
+
* results may be out of date (STALE);
|
|
679
|
+
* - `failed`: the build FAILED — the server does not retry an unchanged
|
|
680
|
+
* definition;
|
|
681
|
+
* - `removing`: being deleted.
|
|
682
|
+
*/
|
|
683
|
+
function searchBuildState(build) {
|
|
684
|
+
const { status } = build;
|
|
685
|
+
if (status === 'FAILED') return 'failed';
|
|
686
|
+
if (status === 'STALE') return 'stale';
|
|
687
|
+
if (ABSENT_STATUSES.has(status)) return 'removing';
|
|
688
|
+
if (build.queryable !== true) return 'building';
|
|
689
|
+
if (build.updating) return 'updating';
|
|
690
|
+
const ready = status === undefined || status === 'READY' || status === 'UNKNOWN';
|
|
691
|
+
return ready ? 'serving' : 'building';
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Whether a live index serves queries with its latest definition (see
|
|
696
|
+
* {@link searchBuildState}) — and, after an update made at `sinceVersion`,
|
|
697
|
+
* with a definition version past it (right after an update the old version
|
|
698
|
+
* can still read READY).
|
|
699
|
+
*/
|
|
700
|
+
function isSearchIndexReady(live, { sinceVersion } = {}) {
|
|
701
|
+
if (searchBuildState(live) !== 'serving') return false;
|
|
702
|
+
if (sinceVersion !== undefined && live.version !== undefined && live.version <= sinceVersion) {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** A live index's build state, for a result row */
|
|
709
|
+
function searchBuild(live) {
|
|
710
|
+
return {
|
|
711
|
+
status: live.status ?? 'UNKNOWN',
|
|
712
|
+
queryable: live.queryable,
|
|
713
|
+
...(live.message !== undefined ? { message: live.message } : {}),
|
|
714
|
+
...(live.updating ? { updating: true } : {}),
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** An index as data for a result row (`from` / `to`) */
|
|
719
|
+
function searchIndexValue(index) {
|
|
720
|
+
return { name: index.name, type: index.type, definition: index.definition };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* What `createSearchIndexes` is sent for a declaration. The type is sent only
|
|
725
|
+
* for a vector index — a search index is the server's default, and a server
|
|
726
|
+
* older than vector search would refuse a field it does not know.
|
|
727
|
+
*/
|
|
728
|
+
function searchIndexSpec(declared) {
|
|
729
|
+
return {
|
|
730
|
+
name: declared.name,
|
|
731
|
+
...(declared.type === 'vectorSearch' ? { type: declared.type } : {}),
|
|
732
|
+
definition: declared.definition,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
module.exports = {
|
|
737
|
+
ABSENT_STATUSES,
|
|
738
|
+
AUTO_EMBED_IMMUTABLE,
|
|
739
|
+
DEFAULT_SEARCH_INDEX_NAME,
|
|
740
|
+
FIELD_DEFAULTS,
|
|
741
|
+
MAX_BUILD_MESSAGE_LENGTH,
|
|
742
|
+
SEARCH_DEFAULTS,
|
|
743
|
+
SEARCH_INDEX_KEYS,
|
|
744
|
+
SEARCH_INDEX_TYPES,
|
|
745
|
+
compareSearchIndex,
|
|
746
|
+
effectiveDefinition,
|
|
747
|
+
inferSearchIndexType,
|
|
748
|
+
isBeingRemoved,
|
|
749
|
+
isSearchIndexReady,
|
|
750
|
+
normalizeDeclaredSearchIndex,
|
|
751
|
+
normalizeLiveSearchIndex,
|
|
752
|
+
searchBuild,
|
|
753
|
+
searchBuildState,
|
|
754
|
+
searchIndexIssues,
|
|
755
|
+
searchIndexSpec,
|
|
756
|
+
searchIndexValue,
|
|
757
|
+
tolerateServerOptions,
|
|
758
|
+
};
|