liwi-mongo 13.1.0 → 13.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/README.md +245 -3
- package/dist/definitions/MongoConnection.d.ts.map +1 -1
- package/dist/definitions/MongoCursor.d.ts.map +1 -1
- package/dist/definitions/MongoQueryCollection.d.ts.map +1 -1
- package/dist/definitions/MongoQuerySingleItem.d.ts.map +1 -1
- package/dist/definitions/MongoRegistry.d.ts +20 -0
- package/dist/definitions/MongoRegistry.d.ts.map +1 -0
- package/dist/definitions/MongoRegistry.test.d.ts +2 -0
- package/dist/definitions/MongoRegistry.test.d.ts.map +1 -0
- package/dist/definitions/MongoRegistryStore.d.ts +7 -0
- package/dist/definitions/MongoRegistryStore.d.ts.map +1 -0
- package/dist/definitions/MongoStore.d.ts +9 -1
- package/dist/definitions/MongoStore.d.ts.map +1 -1
- package/dist/definitions/MongoStoreIndexes.test.d.ts +2 -0
- package/dist/definitions/MongoStoreIndexes.test.d.ts.map +1 -0
- package/dist/definitions/index.d.ts +8 -1
- package/dist/definitions/index.d.ts.map +1 -1
- package/dist/definitions/indexes/applyIndexPlan.d.ts +11 -0
- package/dist/definitions/indexes/applyIndexPlan.d.ts.map +1 -0
- package/dist/definitions/indexes/buildIndexName.d.ts +3 -0
- package/dist/definitions/indexes/buildIndexName.d.ts.map +1 -0
- package/dist/definitions/indexes/buildIndexName.test.d.ts +2 -0
- package/dist/definitions/indexes/buildIndexName.test.d.ts.map +1 -0
- package/dist/definitions/indexes/deepEqualUnordered.d.ts +2 -0
- package/dist/definitions/indexes/deepEqualUnordered.d.ts.map +1 -0
- package/dist/definitions/indexes/diffIndexes.d.ts +13 -0
- package/dist/definitions/indexes/diffIndexes.d.ts.map +1 -0
- package/dist/definitions/indexes/diffIndexes.test.d.ts +2 -0
- package/dist/definitions/indexes/diffIndexes.test.d.ts.map +1 -0
- package/dist/definitions/indexes/formatIndexPlan.d.ts +5 -0
- package/dist/definitions/indexes/formatIndexPlan.d.ts.map +1 -0
- package/dist/definitions/indexes/formatIndexPlan.test.d.ts +2 -0
- package/dist/definitions/indexes/formatIndexPlan.test.d.ts.map +1 -0
- package/dist/definitions/indexes/isIndexConflictError.d.ts +4 -0
- package/dist/definitions/indexes/isIndexConflictError.d.ts.map +1 -0
- package/dist/definitions/indexes/normalizeIndex.d.ts +34 -0
- package/dist/definitions/indexes/normalizeIndex.d.ts.map +1 -0
- package/dist/definitions/indexes/normalizeIndex.test.d.ts +2 -0
- package/dist/definitions/indexes/normalizeIndex.test.d.ts.map +1 -0
- package/dist/definitions/indexes/runIndexesCli.d.ts +11 -0
- package/dist/definitions/indexes/runIndexesCli.d.ts.map +1 -0
- package/dist/definitions/indexes/runIndexesCli.test.d.ts +2 -0
- package/dist/definitions/indexes/runIndexesCli.test.d.ts.map +1 -0
- package/dist/definitions/indexes/syncIndexes.integration.test.d.ts +2 -0
- package/dist/definitions/indexes/syncIndexes.integration.test.d.ts.map +1 -0
- package/dist/definitions/indexes/types.d.ts +72 -0
- package/dist/definitions/indexes/types.d.ts.map +1 -0
- package/dist/index-node.mjs +626 -10
- package/dist/index-node.mjs.map +1 -1
- package/package.json +10 -7
- package/src/MongoRegistry.test.ts +136 -0
- package/src/MongoRegistry.ts +108 -0
- package/src/MongoRegistryStore.ts +11 -0
- package/src/MongoStore.ts +63 -1
- package/src/MongoStoreIndexes.test.ts +259 -0
- package/src/index.ts +26 -1
- package/src/indexes/applyIndexPlan.ts +149 -0
- package/src/indexes/buildIndexName.test.ts +34 -0
- package/src/indexes/buildIndexName.ts +8 -0
- package/src/indexes/deepEqualUnordered.ts +36 -0
- package/src/indexes/diffIndexes.test.ts +290 -0
- package/src/indexes/diffIndexes.ts +234 -0
- package/src/indexes/formatIndexPlan.test.ts +105 -0
- package/src/indexes/formatIndexPlan.ts +71 -0
- package/src/indexes/isIndexConflictError.ts +20 -0
- package/src/indexes/normalizeIndex.test.ts +154 -0
- package/src/indexes/normalizeIndex.ts +191 -0
- package/src/indexes/runIndexesCli.test.ts +119 -0
- package/src/indexes/runIndexesCli.ts +74 -0
- package/src/indexes/syncIndexes.integration.test.ts +182 -0
- package/src/indexes/types.ts +115 -0
package/dist/index-node.mjs
CHANGED
|
@@ -301,15 +301,428 @@ class MongoQuerySingleItem extends AbstractSubscribableStoreQuery {
|
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
const indexOptionsConflict = 85;
|
|
305
|
+
const indexKeySpecsConflict = 86;
|
|
306
|
+
const indexNotFound = 27;
|
|
307
|
+
const namespaceNotFound = 26;
|
|
308
|
+
const getErrorCode = (error) => typeof error === "object" && error !== null ? error.code : void 0;
|
|
309
|
+
const isIndexConflictError = (error) => {
|
|
310
|
+
const code = getErrorCode(error);
|
|
311
|
+
return code === indexOptionsConflict || code === indexKeySpecsConflict;
|
|
312
|
+
};
|
|
313
|
+
const isIndexNotFoundError = (error) => getErrorCode(error) === indexNotFound;
|
|
314
|
+
const isNamespaceNotFoundError = (error) => getErrorCode(error) === namespaceNotFound;
|
|
315
|
+
|
|
316
|
+
const logger$2 = new Logger("liwi:mongo:indexes");
|
|
317
|
+
const idIndexName$1 = "_id_";
|
|
318
|
+
const serializeKey = (key) => JSON.stringify(key instanceof Map ? [...key.entries()] : Object.entries(key));
|
|
319
|
+
const findConflictingIndex = (existingIndexes, description) => {
|
|
320
|
+
const byName = existingIndexes.find(({ name }) => name === description.name);
|
|
321
|
+
if (byName) return byName;
|
|
322
|
+
const serializedKey = serializeKey(description.key);
|
|
323
|
+
return existingIndexes.find(
|
|
324
|
+
({ key, name }) => name !== idIndexName$1 && serializeKey(key) === serializedKey
|
|
325
|
+
);
|
|
326
|
+
};
|
|
327
|
+
const createIndexWithConflictRecovery = async ({
|
|
328
|
+
collection,
|
|
329
|
+
collectionName,
|
|
330
|
+
description
|
|
331
|
+
}) => {
|
|
332
|
+
try {
|
|
333
|
+
await collection.createIndexes([description]);
|
|
334
|
+
return;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (!isIndexConflictError(error)) throw error;
|
|
337
|
+
logger$2.warn("index conflict, recovering", {
|
|
338
|
+
collectionName,
|
|
339
|
+
name: description.name,
|
|
340
|
+
error
|
|
341
|
+
});
|
|
342
|
+
const existingIndexes = await collection.listIndexes().toArray();
|
|
343
|
+
const conflicting = findConflictingIndex(existingIndexes, description);
|
|
344
|
+
if (!conflicting?.name) throw error;
|
|
345
|
+
await collection.dropIndex(conflicting.name);
|
|
346
|
+
}
|
|
347
|
+
await collection.createIndexes([description]);
|
|
348
|
+
};
|
|
349
|
+
const applyIndexPlan = async ({
|
|
350
|
+
plan,
|
|
351
|
+
collection,
|
|
352
|
+
db,
|
|
353
|
+
dryRun
|
|
354
|
+
}) => {
|
|
355
|
+
const { collectionName } = plan;
|
|
356
|
+
const result = {
|
|
357
|
+
collectionName,
|
|
358
|
+
plan,
|
|
359
|
+
dryRun,
|
|
360
|
+
created: [],
|
|
361
|
+
dropped: [],
|
|
362
|
+
modified: []
|
|
363
|
+
};
|
|
364
|
+
if (dryRun) return result;
|
|
365
|
+
const namesToDrop = [...plan.toDrop, ...plan.toRecreate].map(({ name }) => name).filter((name) => name !== idIndexName$1);
|
|
366
|
+
for (const name of namesToDrop) {
|
|
367
|
+
try {
|
|
368
|
+
await collection.dropIndex(name);
|
|
369
|
+
logger$2.info("dropIndex", { collectionName, name });
|
|
370
|
+
result.dropped.push(name);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (!isIndexNotFoundError(error)) throw error;
|
|
373
|
+
logger$2.warn("index already dropped", { collectionName, name });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
for (const { name, changes } of plan.toCollMod) {
|
|
377
|
+
await db.command({
|
|
378
|
+
collMod: collectionName,
|
|
379
|
+
index: { name, ...changes }
|
|
380
|
+
});
|
|
381
|
+
logger$2.info("collMod", { collectionName, name, changes });
|
|
382
|
+
result.modified.push(name);
|
|
383
|
+
}
|
|
384
|
+
const descriptions = [...plan.toCreate, ...plan.toRecreate].map(
|
|
385
|
+
({ index }) => index
|
|
386
|
+
);
|
|
387
|
+
if (descriptions.length > 0) {
|
|
388
|
+
try {
|
|
389
|
+
await collection.createIndexes(descriptions);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
logger$2.warn("createIndexes failed, retrying one by one", {
|
|
392
|
+
collectionName,
|
|
393
|
+
error
|
|
394
|
+
});
|
|
395
|
+
for (const description of descriptions) {
|
|
396
|
+
await createIndexWithConflictRecovery({
|
|
397
|
+
collection,
|
|
398
|
+
collectionName,
|
|
399
|
+
description
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
result.created.push(...descriptions.map(({ name }) => name));
|
|
404
|
+
logger$2.info("createIndexes", { collectionName, names: result.created });
|
|
405
|
+
}
|
|
406
|
+
logger$2.info("syncIndexes done", {
|
|
407
|
+
collectionName,
|
|
408
|
+
created: result.created,
|
|
409
|
+
dropped: result.dropped,
|
|
410
|
+
modified: result.modified
|
|
411
|
+
});
|
|
412
|
+
return result;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const isPlainRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof RegExp);
|
|
416
|
+
const deepEqualUnordered = (a, b) => {
|
|
417
|
+
if (a === b) return true;
|
|
418
|
+
if (a instanceof Date && b instanceof Date) {
|
|
419
|
+
return a.getTime() === b.getTime();
|
|
420
|
+
}
|
|
421
|
+
if (a instanceof RegExp && b instanceof RegExp) {
|
|
422
|
+
return a.source === b.source && a.flags === b.flags;
|
|
423
|
+
}
|
|
424
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
425
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
return a.every((item, index) => deepEqualUnordered(item, b[index]));
|
|
429
|
+
}
|
|
430
|
+
if (!isPlainRecord(a) || !isPlainRecord(b)) return false;
|
|
431
|
+
const keysA = Object.keys(a);
|
|
432
|
+
const keysB = Object.keys(b);
|
|
433
|
+
if (keysA.length !== keysB.length) return false;
|
|
434
|
+
return keysA.every(
|
|
435
|
+
(key) => Object.hasOwn(b, key) && deepEqualUnordered(a[key], b[key])
|
|
436
|
+
);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const buildIndexName = (key) => Object.entries(key).map(([field, direction]) => `${field}_${direction}`).join("_");
|
|
440
|
+
|
|
441
|
+
const textDefaultLanguage = "english";
|
|
442
|
+
const textLanguageOverride = "language";
|
|
443
|
+
const buildDescription = (index, name) => {
|
|
444
|
+
const description = {
|
|
445
|
+
key: index.key,
|
|
446
|
+
name
|
|
447
|
+
};
|
|
448
|
+
if (index.unique !== void 0) description.unique = index.unique;
|
|
449
|
+
if (index.sparse !== void 0) description.sparse = index.sparse;
|
|
450
|
+
if (index.hidden !== void 0) description.hidden = index.hidden;
|
|
451
|
+
if (index.expireAfterSeconds !== void 0) {
|
|
452
|
+
description.expireAfterSeconds = index.expireAfterSeconds;
|
|
453
|
+
}
|
|
454
|
+
if (index.partialFilterExpression) {
|
|
455
|
+
description.partialFilterExpression = index.partialFilterExpression;
|
|
456
|
+
}
|
|
457
|
+
if (index.collation) description.collation = index.collation;
|
|
458
|
+
if (index.weights) description.weights = index.weights;
|
|
459
|
+
if (index.default_language) {
|
|
460
|
+
description.default_language = index.default_language;
|
|
461
|
+
}
|
|
462
|
+
if (index.language_override) {
|
|
463
|
+
description.language_override = index.language_override;
|
|
464
|
+
}
|
|
465
|
+
if (index.wildcardProjection) {
|
|
466
|
+
description.wildcardProjection = index.wildcardProjection;
|
|
467
|
+
}
|
|
468
|
+
return description;
|
|
469
|
+
};
|
|
470
|
+
const normalizeDeclaredIndex = (index) => {
|
|
471
|
+
const key = index.key;
|
|
472
|
+
const entries = Object.entries(key);
|
|
473
|
+
if (entries.length === 0) {
|
|
474
|
+
throw new Error("Invalid index: key must have at least one field");
|
|
475
|
+
}
|
|
476
|
+
const textFields = entries.filter(([, direction]) => direction === "text").map(([field]) => field);
|
|
477
|
+
const isText = textFields.length > 0;
|
|
478
|
+
const name = index.name ?? buildIndexName(key);
|
|
479
|
+
const isIdOnly = entries.length === 1 && entries[0][0] === "_id";
|
|
480
|
+
if (name === "_id_" || isIdOnly) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
"Invalid index: _id is always indexed by mongo and cannot be declared"
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
const weights = isText ? {
|
|
486
|
+
...Object.fromEntries(textFields.map((field) => [field, 1])),
|
|
487
|
+
...index.weights
|
|
488
|
+
} : void 0;
|
|
489
|
+
return {
|
|
490
|
+
name,
|
|
491
|
+
key,
|
|
492
|
+
keyEntries: entries.filter(([, direction]) => direction !== "text"),
|
|
493
|
+
isText,
|
|
494
|
+
options: {
|
|
495
|
+
unique: index.unique ?? false,
|
|
496
|
+
sparse: index.sparse ?? false,
|
|
497
|
+
hidden: index.hidden ?? false,
|
|
498
|
+
expireAfterSeconds: index.expireAfterSeconds,
|
|
499
|
+
partialFilterExpression: index.partialFilterExpression,
|
|
500
|
+
collation: index.collation,
|
|
501
|
+
weights,
|
|
502
|
+
defaultLanguage: isText ? index.default_language ?? textDefaultLanguage : void 0,
|
|
503
|
+
languageOverride: isText ? index.language_override ?? textLanguageOverride : void 0,
|
|
504
|
+
wildcardProjection: index.wildcardProjection
|
|
505
|
+
},
|
|
506
|
+
description: buildDescription(index, name)
|
|
507
|
+
};
|
|
508
|
+
};
|
|
509
|
+
const normalizeExistingIndex = (existing) => {
|
|
510
|
+
const key = existing.key;
|
|
511
|
+
const entries = Object.entries(key);
|
|
512
|
+
const isText = entries.some(([field]) => field === "_fts");
|
|
513
|
+
return {
|
|
514
|
+
name: existing.name ?? buildIndexName(key),
|
|
515
|
+
key,
|
|
516
|
+
keyEntries: entries.filter(
|
|
517
|
+
([field]) => field !== "_fts" && field !== "_ftsx"
|
|
518
|
+
),
|
|
519
|
+
isText,
|
|
520
|
+
options: {
|
|
521
|
+
unique: existing.unique ?? false,
|
|
522
|
+
sparse: existing.sparse ?? false,
|
|
523
|
+
hidden: existing.hidden ?? false,
|
|
524
|
+
expireAfterSeconds: existing.expireAfterSeconds,
|
|
525
|
+
partialFilterExpression: existing.partialFilterExpression,
|
|
526
|
+
collation: existing.collation,
|
|
527
|
+
weights: isText ? existing.weights : void 0,
|
|
528
|
+
defaultLanguage: isText ? existing.default_language ?? textDefaultLanguage : void 0,
|
|
529
|
+
languageOverride: isText ? existing.language_override ?? textLanguageOverride : void 0,
|
|
530
|
+
wildcardProjection: existing.wildcardProjection
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
};
|
|
534
|
+
const normalizeDeclaredIndexes = ({
|
|
535
|
+
collectionName,
|
|
536
|
+
indexes
|
|
537
|
+
}) => {
|
|
538
|
+
const normalized = indexes.map((index) => normalizeDeclaredIndex(index));
|
|
539
|
+
const names = /* @__PURE__ */ new Set();
|
|
540
|
+
normalized.forEach(({ name }) => {
|
|
541
|
+
if (names.has(name)) {
|
|
542
|
+
throw new Error(
|
|
543
|
+
`Duplicate index name "${name}" declared on collection "${collectionName}"`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
names.add(name);
|
|
547
|
+
});
|
|
548
|
+
const textIndexes = normalized.filter(({ isText }) => isText);
|
|
549
|
+
if (textIndexes.length > 1) {
|
|
550
|
+
throw new Error(
|
|
551
|
+
`Only one text index is allowed per collection, "${collectionName}" declares ${textIndexes.length}`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
return normalized;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const idIndexName = "_id_";
|
|
558
|
+
const isSameKey = (declared, existing) => declared.isText === existing.isText && declared.keyEntries.length === existing.keyEntries.length && declared.keyEntries.every(
|
|
559
|
+
([field, direction], index) => existing.keyEntries[index][0] === field && existing.keyEntries[index][1] === direction
|
|
560
|
+
);
|
|
561
|
+
const isSameCollation = (declared, existing) => {
|
|
562
|
+
if (!declared) return !existing;
|
|
563
|
+
if (!existing) return false;
|
|
564
|
+
return Object.entries(declared).every(
|
|
565
|
+
([key, value]) => deepEqualUnordered(value, existing[key])
|
|
566
|
+
);
|
|
567
|
+
};
|
|
568
|
+
const diffOptions = (declared, existing) => {
|
|
569
|
+
const differences = [];
|
|
570
|
+
const compare = (field, declaredValue, existingValue, equals = deepEqualUnordered) => {
|
|
571
|
+
if (!equals(declaredValue, existingValue)) {
|
|
572
|
+
differences.push({
|
|
573
|
+
field,
|
|
574
|
+
declared: declaredValue,
|
|
575
|
+
existing: existingValue
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
compare("unique", declared.options.unique, existing.options.unique);
|
|
580
|
+
compare("sparse", declared.options.sparse, existing.options.sparse);
|
|
581
|
+
compare("hidden", declared.options.hidden, existing.options.hidden);
|
|
582
|
+
compare(
|
|
583
|
+
"expireAfterSeconds",
|
|
584
|
+
declared.options.expireAfterSeconds,
|
|
585
|
+
existing.options.expireAfterSeconds
|
|
586
|
+
);
|
|
587
|
+
compare(
|
|
588
|
+
"partialFilterExpression",
|
|
589
|
+
declared.options.partialFilterExpression,
|
|
590
|
+
existing.options.partialFilterExpression
|
|
591
|
+
);
|
|
592
|
+
compare(
|
|
593
|
+
"collation",
|
|
594
|
+
declared.options.collation,
|
|
595
|
+
existing.options.collation,
|
|
596
|
+
(a, b) => isSameCollation(
|
|
597
|
+
a,
|
|
598
|
+
b
|
|
599
|
+
)
|
|
600
|
+
);
|
|
601
|
+
compare("weights", declared.options.weights, existing.options.weights);
|
|
602
|
+
compare(
|
|
603
|
+
"default_language",
|
|
604
|
+
declared.options.defaultLanguage,
|
|
605
|
+
existing.options.defaultLanguage
|
|
606
|
+
);
|
|
607
|
+
compare(
|
|
608
|
+
"language_override",
|
|
609
|
+
declared.options.languageOverride,
|
|
610
|
+
existing.options.languageOverride
|
|
611
|
+
);
|
|
612
|
+
compare(
|
|
613
|
+
"wildcardProjection",
|
|
614
|
+
declared.options.wildcardProjection,
|
|
615
|
+
existing.options.wildcardProjection
|
|
616
|
+
);
|
|
617
|
+
return differences;
|
|
618
|
+
};
|
|
619
|
+
const isCollModApplicable = (differences) => differences.length > 0 && differences.every(
|
|
620
|
+
(difference) => difference.field === "hidden" || difference.field === "expireAfterSeconds" && difference.declared !== void 0 && difference.existing !== void 0
|
|
621
|
+
);
|
|
622
|
+
const buildCollModChanges = (differences) => {
|
|
623
|
+
const changes = {};
|
|
624
|
+
differences.forEach((difference) => {
|
|
625
|
+
if (difference.field === "hidden") {
|
|
626
|
+
changes.hidden = difference.declared;
|
|
627
|
+
} else if (difference.field === "expireAfterSeconds") {
|
|
628
|
+
changes.expireAfterSeconds = difference.declared;
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
return changes;
|
|
632
|
+
};
|
|
633
|
+
const diffIndexes = ({
|
|
634
|
+
collectionName,
|
|
635
|
+
declaredIndexes,
|
|
636
|
+
existingIndexes,
|
|
637
|
+
dropUndeclaredIndexes
|
|
638
|
+
}) => {
|
|
639
|
+
const declared = normalizeDeclaredIndexes({
|
|
640
|
+
collectionName,
|
|
641
|
+
indexes: declaredIndexes
|
|
642
|
+
});
|
|
643
|
+
const remainingExisting = /* @__PURE__ */ new Map();
|
|
644
|
+
existingIndexes.forEach((raw) => {
|
|
645
|
+
const normalized = normalizeExistingIndex(raw);
|
|
646
|
+
if (normalized.name === idIndexName) return;
|
|
647
|
+
remainingExisting.set(normalized.name, { normalized, raw });
|
|
648
|
+
});
|
|
649
|
+
const plan = {
|
|
650
|
+
collectionName,
|
|
651
|
+
toCreate: [],
|
|
652
|
+
toRecreate: [],
|
|
653
|
+
toCollMod: [],
|
|
654
|
+
toDrop: [],
|
|
655
|
+
unchanged: [],
|
|
656
|
+
undeclaredKept: []
|
|
657
|
+
};
|
|
658
|
+
declared.forEach((declaredIndex) => {
|
|
659
|
+
const existing = remainingExisting.get(declaredIndex.name);
|
|
660
|
+
if (!existing) {
|
|
661
|
+
plan.toCreate.push({
|
|
662
|
+
name: declaredIndex.name,
|
|
663
|
+
index: declaredIndex.description
|
|
664
|
+
});
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
remainingExisting.delete(declaredIndex.name);
|
|
668
|
+
if (!isSameKey(declaredIndex, existing.normalized)) {
|
|
669
|
+
plan.toRecreate.push({
|
|
670
|
+
name: declaredIndex.name,
|
|
671
|
+
index: declaredIndex.description,
|
|
672
|
+
existing: existing.raw,
|
|
673
|
+
differences: [
|
|
674
|
+
{
|
|
675
|
+
field: "key",
|
|
676
|
+
declared: declaredIndex.key,
|
|
677
|
+
existing: existing.normalized.key
|
|
678
|
+
}
|
|
679
|
+
]
|
|
680
|
+
});
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const differences = diffOptions(declaredIndex, existing.normalized);
|
|
684
|
+
if (differences.length === 0) {
|
|
685
|
+
plan.unchanged.push(declaredIndex.name);
|
|
686
|
+
} else if (isCollModApplicable(differences)) {
|
|
687
|
+
plan.toCollMod.push({
|
|
688
|
+
name: declaredIndex.name,
|
|
689
|
+
changes: buildCollModChanges(differences),
|
|
690
|
+
differences
|
|
691
|
+
});
|
|
692
|
+
} else {
|
|
693
|
+
plan.toRecreate.push({
|
|
694
|
+
name: declaredIndex.name,
|
|
695
|
+
index: declaredIndex.description,
|
|
696
|
+
existing: existing.raw,
|
|
697
|
+
differences
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
remainingExisting.forEach(({ raw }, name) => {
|
|
702
|
+
if (dropUndeclaredIndexes) {
|
|
703
|
+
plan.toDrop.push({ name, existing: raw });
|
|
704
|
+
} else {
|
|
705
|
+
plan.undeclaredKept.push(name);
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
return plan;
|
|
709
|
+
};
|
|
710
|
+
const isMongoIndexPlanEmpty = (plan) => plan.toCreate.length === 0 && plan.toRecreate.length === 0 && plan.toCollMod.length === 0 && plan.toDrop.length === 0;
|
|
711
|
+
|
|
304
712
|
class MongoStore {
|
|
305
713
|
keyPath = "_id";
|
|
306
714
|
connection;
|
|
715
|
+
collectionName;
|
|
716
|
+
declaredIndexes;
|
|
307
717
|
_collection;
|
|
308
|
-
constructor(connection, collectionName) {
|
|
718
|
+
constructor(connection, collectionName, { indexes = [] } = {}) {
|
|
309
719
|
this.connection = connection;
|
|
310
720
|
if (!collectionName) {
|
|
311
721
|
throw new Error(`Invalid collectionName: "${collectionName}"`);
|
|
312
722
|
}
|
|
723
|
+
this.collectionName = collectionName;
|
|
724
|
+
this.declaredIndexes = indexes;
|
|
725
|
+
normalizeDeclaredIndexes({ collectionName, indexes });
|
|
313
726
|
this._collection = connection.getConnection().then(
|
|
314
727
|
(client) => {
|
|
315
728
|
this._collection = client.db().collection(collectionName);
|
|
@@ -327,6 +740,32 @@ class MongoStore {
|
|
|
327
740
|
}
|
|
328
741
|
return Promise.resolve(this._collection);
|
|
329
742
|
}
|
|
743
|
+
async planIndexes({
|
|
744
|
+
dropUndeclaredIndexes = true
|
|
745
|
+
} = {}) {
|
|
746
|
+
const collection = await this.collection;
|
|
747
|
+
const existingIndexes = await collection.listIndexes().toArray().catch((error) => {
|
|
748
|
+
if (isNamespaceNotFoundError(error)) return [];
|
|
749
|
+
throw error;
|
|
750
|
+
});
|
|
751
|
+
return diffIndexes({
|
|
752
|
+
collectionName: this.collectionName,
|
|
753
|
+
declaredIndexes: this.declaredIndexes,
|
|
754
|
+
existingIndexes,
|
|
755
|
+
dropUndeclaredIndexes
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
async syncIndexes(options = {}) {
|
|
759
|
+
const plan = await this.planIndexes(options);
|
|
760
|
+
const collection = await this.collection;
|
|
761
|
+
const client = await this.connection.getConnection();
|
|
762
|
+
return applyIndexPlan({
|
|
763
|
+
plan,
|
|
764
|
+
collection,
|
|
765
|
+
db: client.db(),
|
|
766
|
+
dryRun: options.dryRun ?? false
|
|
767
|
+
});
|
|
768
|
+
}
|
|
330
769
|
createQuerySingleItem({
|
|
331
770
|
transformer,
|
|
332
771
|
...options
|
|
@@ -469,7 +908,7 @@ class MongoStore {
|
|
|
469
908
|
}
|
|
470
909
|
}
|
|
471
910
|
|
|
472
|
-
const logger = new Logger("liwi:mongo:MongoConnection");
|
|
911
|
+
const logger$1 = new Logger("liwi:mongo:MongoConnection");
|
|
473
912
|
class MongoConnection extends AbstractConnection {
|
|
474
913
|
_connection;
|
|
475
914
|
_connecting;
|
|
@@ -492,37 +931,37 @@ class MongoConnection extends AbstractConnection {
|
|
|
492
931
|
this.connect(connectionString, connectionStringRedacted);
|
|
493
932
|
}
|
|
494
933
|
connect(connectionString, connectionStringRedacted) {
|
|
495
|
-
logger.info("connecting", { connectionStringRedacted });
|
|
934
|
+
logger$1.info("connecting", { connectionStringRedacted });
|
|
496
935
|
const connectPromise = mongodb.MongoClient.connect(connectionString).then((connection) => {
|
|
497
|
-
logger.info("connected", { connectionStringRedacted });
|
|
936
|
+
logger$1.info("connected", { connectionStringRedacted });
|
|
498
937
|
connection.on("close", () => {
|
|
499
|
-
logger.warn("close", { connectionStringRedacted });
|
|
938
|
+
logger$1.warn("close", { connectionStringRedacted });
|
|
500
939
|
this.connectionFailed = true;
|
|
501
940
|
this.getConnection = () => {
|
|
502
941
|
throw new Error("MongoDB connection closed");
|
|
503
942
|
};
|
|
504
943
|
});
|
|
505
944
|
connection.on("timeout", () => {
|
|
506
|
-
logger.warn("timeout", { connectionStringRedacted });
|
|
945
|
+
logger$1.warn("timeout", { connectionStringRedacted });
|
|
507
946
|
this.connectionFailed = true;
|
|
508
947
|
this.getConnection = () => {
|
|
509
948
|
throw new Error("MongoDB connection timeout");
|
|
510
949
|
};
|
|
511
950
|
});
|
|
512
951
|
connection.on("reconnect", () => {
|
|
513
|
-
logger.warn("reconnect", { connectionStringRedacted });
|
|
952
|
+
logger$1.warn("reconnect", { connectionStringRedacted });
|
|
514
953
|
this.connectionFailed = false;
|
|
515
954
|
this.getConnection = () => Promise.resolve(this._connection);
|
|
516
955
|
});
|
|
517
956
|
connection.on("error", (err) => {
|
|
518
|
-
logger.warn("error", { connectionStringRedacted, err });
|
|
957
|
+
logger$1.warn("error", { connectionStringRedacted, err });
|
|
519
958
|
});
|
|
520
959
|
this._connection = connection;
|
|
521
960
|
this._connecting = void 0;
|
|
522
961
|
this.getConnection = () => Promise.resolve(this._connection);
|
|
523
962
|
return connection;
|
|
524
963
|
}).catch((error) => {
|
|
525
|
-
logger.info("not connected", { connectionStringRedacted });
|
|
964
|
+
logger$1.info("not connected", { connectionStringRedacted });
|
|
526
965
|
console.error(error.message || error);
|
|
527
966
|
process.nextTick(() => {
|
|
528
967
|
process.exit(1);
|
|
@@ -551,5 +990,182 @@ function createMongoSubscribeStore(mongoStore) {
|
|
|
551
990
|
return new SubscribeStore(mongoStore);
|
|
552
991
|
}
|
|
553
992
|
|
|
554
|
-
|
|
993
|
+
const logger = new Logger("liwi:mongo:MongoRegistry");
|
|
994
|
+
class MongoRegistry {
|
|
995
|
+
_stores;
|
|
996
|
+
dropUndeclaredIndexes;
|
|
997
|
+
constructor(stores = [], { dropUndeclaredIndexes = true } = {}) {
|
|
998
|
+
this._stores = [];
|
|
999
|
+
this.dropUndeclaredIndexes = dropUndeclaredIndexes;
|
|
1000
|
+
stores.forEach((store) => this.add(store));
|
|
1001
|
+
}
|
|
1002
|
+
get stores() {
|
|
1003
|
+
return this._stores;
|
|
1004
|
+
}
|
|
1005
|
+
add(store) {
|
|
1006
|
+
if (this.getStore(store.collectionName)) {
|
|
1007
|
+
logger.warn("collection already registered", {
|
|
1008
|
+
collectionName: store.collectionName
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
this._stores.push(store);
|
|
1012
|
+
return this;
|
|
1013
|
+
}
|
|
1014
|
+
remove(store) {
|
|
1015
|
+
const index = this._stores.indexOf(store);
|
|
1016
|
+
if (index !== -1) this._stores.splice(index, 1);
|
|
1017
|
+
return this;
|
|
1018
|
+
}
|
|
1019
|
+
getStore(collectionName) {
|
|
1020
|
+
return this._stores.find(
|
|
1021
|
+
(store) => store.collectionName === collectionName
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
resolveOptions(options = {}) {
|
|
1025
|
+
return {
|
|
1026
|
+
...options,
|
|
1027
|
+
dropUndeclaredIndexes: options.dropUndeclaredIndexes ?? this.dropUndeclaredIndexes
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
sortedStores() {
|
|
1031
|
+
return this._stores.toSorted(
|
|
1032
|
+
(storeA, storeB) => storeA.collectionName.localeCompare(storeB.collectionName)
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
async runSequentially(operation, run) {
|
|
1036
|
+
const results = [];
|
|
1037
|
+
const errors = [];
|
|
1038
|
+
for (const store of this.sortedStores()) {
|
|
1039
|
+
try {
|
|
1040
|
+
results.push(await run(store));
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
logger.error(operation, {
|
|
1043
|
+
collectionName: store.collectionName,
|
|
1044
|
+
error
|
|
1045
|
+
});
|
|
1046
|
+
errors.push(error);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
if (errors.length > 0) {
|
|
1050
|
+
throw new AggregateError(errors, `${operation} failed`);
|
|
1051
|
+
}
|
|
1052
|
+
return results;
|
|
1053
|
+
}
|
|
1054
|
+
planIndexes(options) {
|
|
1055
|
+
const resolvedOptions = this.resolveOptions(options);
|
|
1056
|
+
return this.runSequentially(
|
|
1057
|
+
"planIndexes",
|
|
1058
|
+
(store) => store.planIndexes(resolvedOptions)
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
syncIndexes(options) {
|
|
1062
|
+
const resolvedOptions = this.resolveOptions(options);
|
|
1063
|
+
return this.runSequentially(
|
|
1064
|
+
"syncIndexes",
|
|
1065
|
+
(store) => store.syncIndexes(resolvedOptions)
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const formatDifference = ({
|
|
1071
|
+
field,
|
|
1072
|
+
declared,
|
|
1073
|
+
existing
|
|
1074
|
+
}) => `${field}: ${JSON.stringify(existing) ?? "undefined"} -> ${JSON.stringify(declared) ?? "undefined"}`;
|
|
1075
|
+
const formatDifferences = (differences) => differences.map((difference) => formatDifference(difference)).join(", ");
|
|
1076
|
+
const buildLines = (plan) => [
|
|
1077
|
+
...plan.toCreate.map(({ name, index }) => ({
|
|
1078
|
+
action: "+ create",
|
|
1079
|
+
name,
|
|
1080
|
+
detail: JSON.stringify(index.key)
|
|
1081
|
+
})),
|
|
1082
|
+
...plan.toRecreate.map(({ name, differences }) => ({
|
|
1083
|
+
action: "~ recreate",
|
|
1084
|
+
name,
|
|
1085
|
+
detail: formatDifferences(differences)
|
|
1086
|
+
})),
|
|
1087
|
+
...plan.toCollMod.map(({ name, differences }) => ({
|
|
1088
|
+
action: "! modify",
|
|
1089
|
+
name,
|
|
1090
|
+
detail: formatDifferences(differences)
|
|
1091
|
+
})),
|
|
1092
|
+
...plan.toDrop.map(({ name }) => ({ action: "- drop", name, detail: "" })),
|
|
1093
|
+
...plan.undeclaredKept.map((name) => ({
|
|
1094
|
+
action: "? kept",
|
|
1095
|
+
name,
|
|
1096
|
+
detail: "undeclared, not dropped"
|
|
1097
|
+
}))
|
|
1098
|
+
];
|
|
1099
|
+
const formatIndexPlan = (plan) => {
|
|
1100
|
+
const lines = buildLines(plan);
|
|
1101
|
+
const unchangedCount = plan.unchanged.length;
|
|
1102
|
+
if (lines.length === 0) {
|
|
1103
|
+
return `${plan.collectionName}
|
|
1104
|
+
= ${unchangedCount} unchanged, nothing to do`;
|
|
1105
|
+
}
|
|
1106
|
+
const actionWidth = Math.max(...lines.map(({ action }) => action.length));
|
|
1107
|
+
const nameWidth = Math.max(...lines.map(({ name }) => name.length));
|
|
1108
|
+
const formattedLines = lines.map(
|
|
1109
|
+
({ action, name, detail }) => ` ${action.padEnd(actionWidth)} ${detail ? name.padEnd(nameWidth) : name}${detail ? ` ${detail}` : ""}`
|
|
1110
|
+
);
|
|
1111
|
+
if (unchangedCount > 0) {
|
|
1112
|
+
formattedLines.push(` = ${unchangedCount} unchanged`);
|
|
1113
|
+
}
|
|
1114
|
+
return [plan.collectionName, ...formattedLines].join("\n");
|
|
1115
|
+
};
|
|
1116
|
+
const formatIndexPlans = (plans) => plans.map((plan) => formatIndexPlan(plan)).join("\n");
|
|
1117
|
+
|
|
1118
|
+
const usage = `Usage: [plan|sync] [options]
|
|
1119
|
+
|
|
1120
|
+
plan print the plan, changes nothing (default)
|
|
1121
|
+
sync apply the plan
|
|
1122
|
+
|
|
1123
|
+
--check with plan, exit 1 when the plan is not empty
|
|
1124
|
+
--dry-run with sync, compute everything but apply nothing
|
|
1125
|
+
--keep-undeclared do not drop indexes that are not declared`;
|
|
1126
|
+
const formatSyncResult = ({
|
|
1127
|
+
collectionName,
|
|
1128
|
+
created,
|
|
1129
|
+
modified,
|
|
1130
|
+
dropped
|
|
1131
|
+
}) => `${collectionName}: ${created.length} created, ${modified.length} modified, ${dropped.length} dropped`;
|
|
1132
|
+
const runIndexesCli = async ({
|
|
1133
|
+
registry,
|
|
1134
|
+
argv = process.argv.slice(2),
|
|
1135
|
+
connection,
|
|
1136
|
+
log = console.log,
|
|
1137
|
+
logError = console.error
|
|
1138
|
+
}) => {
|
|
1139
|
+
const command = argv.find((arg) => !arg.startsWith("--")) ?? "plan";
|
|
1140
|
+
const options = {
|
|
1141
|
+
...argv.includes("--keep-undeclared") ? { dropUndeclaredIndexes: false } : {},
|
|
1142
|
+
dryRun: argv.includes("--dry-run")
|
|
1143
|
+
};
|
|
1144
|
+
try {
|
|
1145
|
+
if (command === "plan") {
|
|
1146
|
+
const plans = await registry.planIndexes(options);
|
|
1147
|
+
log(formatIndexPlans(plans));
|
|
1148
|
+
const hasChanges = !plans.every((plan) => isMongoIndexPlanEmpty(plan));
|
|
1149
|
+
return argv.includes("--check") && hasChanges ? 1 : 0;
|
|
1150
|
+
}
|
|
1151
|
+
if (command === "sync") {
|
|
1152
|
+
const results = await registry.syncIndexes(options);
|
|
1153
|
+
log(formatIndexPlans(results.map(({ plan }) => plan)));
|
|
1154
|
+
log(
|
|
1155
|
+
results.map(
|
|
1156
|
+
(result) => `${formatSyncResult(result)}${result.dryRun ? " (dry run, nothing applied)" : ""}`
|
|
1157
|
+
).join("\n")
|
|
1158
|
+
);
|
|
1159
|
+
return 0;
|
|
1160
|
+
}
|
|
1161
|
+
logError(`Unknown command "${command}".
|
|
1162
|
+
|
|
1163
|
+
${usage}`);
|
|
1164
|
+
return 2;
|
|
1165
|
+
} finally {
|
|
1166
|
+
await connection?.close();
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
export { MongoConnection, MongoRegistry, MongoStore, buildIndexName, createMongoSubscribeStore, diffIndexes, formatIndexPlan, formatIndexPlans, isMongoIndexPlanEmpty, runIndexesCli };
|
|
555
1171
|
//# sourceMappingURL=index-node.mjs.map
|