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.
Files changed (71) hide show
  1. package/README.md +245 -3
  2. package/dist/definitions/MongoConnection.d.ts.map +1 -1
  3. package/dist/definitions/MongoCursor.d.ts.map +1 -1
  4. package/dist/definitions/MongoQueryCollection.d.ts.map +1 -1
  5. package/dist/definitions/MongoQuerySingleItem.d.ts.map +1 -1
  6. package/dist/definitions/MongoRegistry.d.ts +20 -0
  7. package/dist/definitions/MongoRegistry.d.ts.map +1 -0
  8. package/dist/definitions/MongoRegistry.test.d.ts +2 -0
  9. package/dist/definitions/MongoRegistry.test.d.ts.map +1 -0
  10. package/dist/definitions/MongoRegistryStore.d.ts +7 -0
  11. package/dist/definitions/MongoRegistryStore.d.ts.map +1 -0
  12. package/dist/definitions/MongoStore.d.ts +9 -1
  13. package/dist/definitions/MongoStore.d.ts.map +1 -1
  14. package/dist/definitions/MongoStoreIndexes.test.d.ts +2 -0
  15. package/dist/definitions/MongoStoreIndexes.test.d.ts.map +1 -0
  16. package/dist/definitions/index.d.ts +8 -1
  17. package/dist/definitions/index.d.ts.map +1 -1
  18. package/dist/definitions/indexes/applyIndexPlan.d.ts +11 -0
  19. package/dist/definitions/indexes/applyIndexPlan.d.ts.map +1 -0
  20. package/dist/definitions/indexes/buildIndexName.d.ts +3 -0
  21. package/dist/definitions/indexes/buildIndexName.d.ts.map +1 -0
  22. package/dist/definitions/indexes/buildIndexName.test.d.ts +2 -0
  23. package/dist/definitions/indexes/buildIndexName.test.d.ts.map +1 -0
  24. package/dist/definitions/indexes/deepEqualUnordered.d.ts +2 -0
  25. package/dist/definitions/indexes/deepEqualUnordered.d.ts.map +1 -0
  26. package/dist/definitions/indexes/diffIndexes.d.ts +13 -0
  27. package/dist/definitions/indexes/diffIndexes.d.ts.map +1 -0
  28. package/dist/definitions/indexes/diffIndexes.test.d.ts +2 -0
  29. package/dist/definitions/indexes/diffIndexes.test.d.ts.map +1 -0
  30. package/dist/definitions/indexes/formatIndexPlan.d.ts +5 -0
  31. package/dist/definitions/indexes/formatIndexPlan.d.ts.map +1 -0
  32. package/dist/definitions/indexes/formatIndexPlan.test.d.ts +2 -0
  33. package/dist/definitions/indexes/formatIndexPlan.test.d.ts.map +1 -0
  34. package/dist/definitions/indexes/isIndexConflictError.d.ts +4 -0
  35. package/dist/definitions/indexes/isIndexConflictError.d.ts.map +1 -0
  36. package/dist/definitions/indexes/normalizeIndex.d.ts +34 -0
  37. package/dist/definitions/indexes/normalizeIndex.d.ts.map +1 -0
  38. package/dist/definitions/indexes/normalizeIndex.test.d.ts +2 -0
  39. package/dist/definitions/indexes/normalizeIndex.test.d.ts.map +1 -0
  40. package/dist/definitions/indexes/runIndexesCli.d.ts +11 -0
  41. package/dist/definitions/indexes/runIndexesCli.d.ts.map +1 -0
  42. package/dist/definitions/indexes/runIndexesCli.test.d.ts +2 -0
  43. package/dist/definitions/indexes/runIndexesCli.test.d.ts.map +1 -0
  44. package/dist/definitions/indexes/syncIndexes.integration.test.d.ts +2 -0
  45. package/dist/definitions/indexes/syncIndexes.integration.test.d.ts.map +1 -0
  46. package/dist/definitions/indexes/types.d.ts +72 -0
  47. package/dist/definitions/indexes/types.d.ts.map +1 -0
  48. package/dist/index-node.mjs +626 -10
  49. package/dist/index-node.mjs.map +1 -1
  50. package/package.json +10 -7
  51. package/src/MongoRegistry.test.ts +136 -0
  52. package/src/MongoRegistry.ts +108 -0
  53. package/src/MongoRegistryStore.ts +11 -0
  54. package/src/MongoStore.ts +63 -1
  55. package/src/MongoStoreIndexes.test.ts +259 -0
  56. package/src/index.ts +26 -1
  57. package/src/indexes/applyIndexPlan.ts +149 -0
  58. package/src/indexes/buildIndexName.test.ts +34 -0
  59. package/src/indexes/buildIndexName.ts +8 -0
  60. package/src/indexes/deepEqualUnordered.ts +36 -0
  61. package/src/indexes/diffIndexes.test.ts +290 -0
  62. package/src/indexes/diffIndexes.ts +234 -0
  63. package/src/indexes/formatIndexPlan.test.ts +105 -0
  64. package/src/indexes/formatIndexPlan.ts +71 -0
  65. package/src/indexes/isIndexConflictError.ts +20 -0
  66. package/src/indexes/normalizeIndex.test.ts +154 -0
  67. package/src/indexes/normalizeIndex.ts +191 -0
  68. package/src/indexes/runIndexesCli.test.ts +119 -0
  69. package/src/indexes/runIndexesCli.ts +74 -0
  70. package/src/indexes/syncIndexes.integration.test.ts +182 -0
  71. package/src/indexes/types.ts +115 -0
@@ -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
- export { MongoConnection, MongoStore, createMongoSubscribeStore };
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