@lossless.org/client 1.2.0 → 1.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.
@@ -15,6 +15,7 @@ import { SmartdataDbCursor } from './classes.cursor.js';
15
15
  import {
16
16
  SmartDataDbDoc,
17
17
  type IIndexOptions,
18
+ type SvDbOptions,
18
19
  type TSmartdataNumericDocumentPath,
19
20
  type TSmartdataIdentityValueType,
20
21
  } from './classes.doc.js';
@@ -28,6 +29,7 @@ import {
28
29
  } from './classes.persistence.js';
29
30
  import { notifyCollectionReconnect } from './classes.collectionlifecycle.js';
30
31
  import {
32
+ leaseOrdinarySmartdataSession,
31
33
  runWithOrdinarySmartdataSession,
32
34
  type TSmartdataOrdinarySession,
33
35
  } from './classes.session.js';
@@ -69,6 +71,14 @@ export interface ICollectionBindingOptions {
69
71
  * `_id === document[field]`.
70
72
  */
71
73
  identityAsDocumentId?: string;
74
+ /**
75
+ * Names of installed indexes this model accepts without declaring them —
76
+ * typically migration-owned indexes SmartData does not express, such as
77
+ * partial or filtered unique indexes. A tolerated index is never created,
78
+ * never dropped and never verified beyond its name, and it no longer makes
79
+ * the collection topology divergent.
80
+ */
81
+ toleratedIndexNames?: ReadonlyArray<string>;
72
82
  }
73
83
 
74
84
  export type TCollectionModelIndexDirection = 1 | -1 | 'text';
@@ -112,6 +122,16 @@ export interface ICollectionModelConfig<TModel extends object = any> {
112
122
  * numeric-field constraint.
113
123
  */
114
124
  numericFields?: ReadonlyArray<TSmartdataNumericDocumentPath<TModel>>;
125
+ /**
126
+ * Persisted top-level fields that only atomic operations may overwrite. Every
127
+ * write that creates a document stores them — `insert()`, `insertMany()`,
128
+ * `insertManyIfAbsent()` and the insert branch of `save()` — and they read
129
+ * like any other declared field, but a `save()` that matches a stored
130
+ * document leaves them untouched, so a stale instance cannot overwrite a
131
+ * value concurrent atomic writers own. An identity field cannot be
132
+ * atomic-only.
133
+ */
134
+ atomicOnlyFields?: ReadonlyArray<keyof TModel & string>;
115
135
  /**
116
136
  * Persisted string identity fields with @unI()-equivalent selector and
117
137
  * immutability semantics. Each field requires an explicit single-field,
@@ -133,6 +153,15 @@ export interface ICollectionModelConfig<TModel extends object = any> {
133
153
  * Stable named MongoDB indexes. Object property order defines key order.
134
154
  */
135
155
  indexes?: ReadonlyArray<ICollectionModelIndex>;
156
+ /**
157
+ * Names of installed indexes this model does not declare but accepts —
158
+ * typically indexes a migration owns, such as partial or filtered unique
159
+ * indexes SmartData does not express. A tolerated index is never created,
160
+ * never dropped and never verified beyond its name, and it no longer makes
161
+ * the collection topology divergent. Every undeclared index outside this
162
+ * list still does.
163
+ */
164
+ toleratedIndexNames?: ReadonlyArray<string>;
136
165
  }
137
166
 
138
167
  export interface INormalizedCollectionModelSchema {
@@ -140,6 +169,8 @@ export interface INormalizedCollectionModelSchema {
140
169
  readonly collectionName: string;
141
170
  readonly persistedFields: readonly string[];
142
171
  readonly numericFields: readonly string[];
172
+ /** Declared persisted fields a matching instance `save()` never overwrites. */
173
+ readonly atomicOnlyFields: readonly string[];
143
174
  readonly identityFields: readonly string[];
144
175
  /** Declared identity field stored as the document `_id`, if any. */
145
176
  readonly identityAsDocumentId?: string;
@@ -154,6 +185,8 @@ export interface INormalizedCollectionModelSchema {
154
185
  >;
155
186
  readonly options: Readonly<IIndexOptions>;
156
187
  }>;
188
+ /** Undeclared installed indexes this model accepts by name, never touches. */
189
+ readonly toleratedIndexNames: readonly string[];
157
190
  readonly fingerprint: string;
158
191
  }
159
192
 
@@ -344,10 +377,12 @@ const normalizeCollectionModelSchema = (
344
377
  'collectionName',
345
378
  'persistedFields',
346
379
  'numericFields',
380
+ 'atomicOnlyFields',
347
381
  'identityFields',
348
382
  'identityAsDocumentId',
349
383
  'searchableFields',
350
384
  'indexes',
385
+ 'toleratedIndexNames',
351
386
  ]);
352
387
  for (const key of Object.keys(configArg)) {
353
388
  if (!allowedConfigKeys.has(key)) {
@@ -436,6 +471,37 @@ const normalizeCollectionModelSchema = (
436
471
  );
437
472
  }
438
473
  }
474
+ if (
475
+ configArg.atomicOnlyFields !== undefined &&
476
+ !Array.isArray(configArg.atomicOnlyFields)
477
+ ) {
478
+ throw new SmartdataPersistenceError(
479
+ 'invalid_configuration',
480
+ 'Collection model atomicOnlyFields must be an array.',
481
+ );
482
+ }
483
+ const atomicOnlyFields: string[] = [];
484
+ for (const field of configArg.atomicOnlyFields || []) {
485
+ requireSafeFieldName(field, 'An atomic-only field');
486
+ if (!persistedFields.includes(field)) {
487
+ throw new SmartdataPersistenceError(
488
+ 'invalid_configuration',
489
+ `Atomic-only field "${field}" is not a declared persisted field.`,
490
+ );
491
+ }
492
+ if (identityFields.includes(field)) {
493
+ // An identity is immutable and addresses the document, so it is written
494
+ // by the insert that creates it and by nothing else afterwards. Making
495
+ // it atomic-only would promise an atomic write that is always refused.
496
+ throw new SmartdataPersistenceError(
497
+ 'invalid_configuration',
498
+ `Identity field "${field}" cannot be atomic-only.`,
499
+ );
500
+ }
501
+ if (!atomicOnlyFields.includes(field)) {
502
+ atomicOnlyFields.push(field);
503
+ }
504
+ }
439
505
  const identityValueTypes: Record<string, TSmartdataIdentityValueType> = {};
440
506
  for (const field of Object.keys(identityValueTypesArg || {})) {
441
507
  if (!identityFields.includes(field)) {
@@ -627,16 +693,55 @@ const normalizeCollectionModelSchema = (
627
693
  );
628
694
  }
629
695
  }
696
+ if (
697
+ configArg.toleratedIndexNames !== undefined &&
698
+ !Array.isArray(configArg.toleratedIndexNames)
699
+ ) {
700
+ throw new SmartdataPersistenceError(
701
+ 'invalid_configuration',
702
+ 'Collection model toleratedIndexNames must be an array.',
703
+ );
704
+ }
705
+ const toleratedIndexNames: string[] = [];
706
+ for (const name of configArg.toleratedIndexNames || []) {
707
+ if (typeof name !== 'string' || name.trim().length === 0 || name.includes('\0')) {
708
+ throw new SmartdataPersistenceError(
709
+ 'invalid_configuration',
710
+ 'Every tolerated index name must be a non-empty safe name.',
711
+ );
712
+ }
713
+ if (name === '_id_') {
714
+ // The primary-key index is verified for every collection; tolerating it
715
+ // would accept a changed _id index in silence.
716
+ throw new SmartdataPersistenceError(
717
+ 'invalid_configuration',
718
+ 'Index name "_id_" cannot be tolerated; SmartData always verifies the primary-key index.',
719
+ );
720
+ }
721
+ if (indexesByName.has(name)) {
722
+ // A declared index is created and verified. Tolerating the same name
723
+ // would state both contracts for one index.
724
+ throw new SmartdataPersistenceError(
725
+ 'invalid_configuration',
726
+ `Index "${name}" cannot be tolerated and declared at the same time.`,
727
+ );
728
+ }
729
+ if (!toleratedIndexNames.includes(name)) {
730
+ toleratedIndexNames.push(name);
731
+ }
732
+ }
630
733
  const normalizedCore = {
631
734
  ordinaryPersistence: ordinaryPolicyArg,
632
735
  collectionName,
633
736
  persistedFields: Object.freeze([...persistedFields]),
634
737
  numericFields: Object.freeze([...numericFields]),
738
+ atomicOnlyFields: Object.freeze([...atomicOnlyFields]),
635
739
  identityFields: Object.freeze([...identityFields]),
636
740
  ...(identityAsDocumentId !== undefined ? { identityAsDocumentId } : {}),
637
741
  identityValueTypes: Object.freeze({ ...identityValueTypes }),
638
742
  searchableFields: Object.freeze([...searchableFields]),
639
743
  indexes: Object.freeze([...indexesByName.values()]),
744
+ toleratedIndexNames: Object.freeze([...toleratedIndexNames]),
640
745
  };
641
746
  return Object.freeze({
642
747
  ...normalizedCore,
@@ -647,6 +752,7 @@ const normalizeCollectionModelSchema = (
647
752
  collectionName,
648
753
  persistedFields: [...persistedFields].sort(compareSmartdataTopologyStrings),
649
754
  numericFields: [...numericFields].sort(compareSmartdataTopologyStrings),
755
+ atomicOnlyFields: [...atomicOnlyFields].sort(compareSmartdataTopologyStrings),
650
756
  identityFields: [...identityFields].sort(compareSmartdataTopologyStrings),
651
757
  ...(identityAsDocumentId !== undefined ? { identityAsDocumentId } : {}),
652
758
  identityValueTypes,
@@ -656,6 +762,9 @@ const normalizeCollectionModelSchema = (
656
762
  indexes: [...indexesByName.values()].sort((leftArg, rightArg) =>
657
763
  compareSmartdataTopologyStrings(leftArg.name, rightArg.name),
658
764
  ),
765
+ toleratedIndexNames: [...toleratedIndexNames].sort(
766
+ compareSmartdataTopologyStrings,
767
+ ),
659
768
  }),
660
769
  });
661
770
  };
@@ -840,7 +949,22 @@ const mergeDecoratorMetadata = (
840
949
  for (const metadataArg of metadataArgs) {
841
950
  const options = getOwnMetadataValue<Record<string, any>>(metadataArg, '_svDbOptions');
842
951
  if (options) {
843
- Object.assign(svDbOptions, options);
952
+ for (const [field, fieldOptions] of Object.entries(options)) {
953
+ const inherited = svDbOptions[field];
954
+ // A later declaration replaces the inherited options wholesale, which
955
+ // would silently drop an inherited atomic-only contract and let a
956
+ // subclass's save() overwrite an atomically owned value.
957
+ if (
958
+ inherited
959
+ && Boolean(inherited.atomicOnly) !== Boolean(fieldOptions?.atomicOnly)
960
+ ) {
961
+ throw new SmartdataPersistenceError(
962
+ 'invalid_configuration',
963
+ `Persisted field "${field}" has divergent inherited atomicOnly declarations.`,
964
+ );
965
+ }
966
+ svDbOptions[field] = fieldOptions;
967
+ }
844
968
  }
845
969
  }
846
970
  if (Object.keys(svDbOptions).length > 0) {
@@ -923,10 +1047,44 @@ const definedModelSchemaSymbol = Symbol.for(
923
1047
  '@push.rocks/smartdata.definedCollectionModelSchema',
924
1048
  );
925
1049
 
1050
+ /**
1051
+ * Reads a model's merged `@svDb()` field options from decorator metadata.
1052
+ * Unlike the constructor's `_svDbOptions`, this is available before the first
1053
+ * instance exists, so a programmatic declaration can be checked against the
1054
+ * decorators on the same class.
1055
+ */
1056
+ const readDeclaredSvDbOptions = (
1057
+ constructorArg: TCollectionModelConstructor<any>,
1058
+ ): Record<string, SvDbOptions> => {
1059
+ const ownMetadata = Object.prototype.hasOwnProperty.call(
1060
+ constructorArg,
1061
+ (Symbol as any).metadata,
1062
+ )
1063
+ ? ((constructorArg as any)[(Symbol as any).metadata] as
1064
+ | ISmartdataDecoratorMetadata
1065
+ | undefined)
1066
+ : undefined;
1067
+ return (
1068
+ mergeDecoratorMetadata(
1069
+ collectInheritedDecoratorMetadata(constructorArg),
1070
+ ownMetadata,
1071
+ )._svDbOptions as Record<string, SvDbOptions> | undefined
1072
+ ) || {};
1073
+ };
1074
+
1075
+ /** Declared persisted fields a model marks `@svDb({ atomicOnly: true })`. */
1076
+ const readDecoratedAtomicOnlyFields = (
1077
+ svDbOptionsArg: Record<string, SvDbOptions>,
1078
+ ): string[] =>
1079
+ Object.entries(svDbOptionsArg)
1080
+ .filter(([, optionsArg]) => optionsArg?.atomicOnly === true)
1081
+ .map(([fieldArg]) => fieldArg);
1082
+
926
1083
  const schemaFromDecoratorMetadata = (
927
1084
  constructorArg: TCollectionModelConstructor<any>,
928
1085
  collectionNameArg: string,
929
1086
  identityAsDocumentIdArg?: string,
1087
+ toleratedIndexNamesArg?: ReadonlyArray<string>,
930
1088
  ): INormalizedCollectionModelSchema => {
931
1089
  const ownMetadata = Object.prototype.hasOwnProperty.call(
932
1090
  constructorArg,
@@ -956,6 +1114,9 @@ const schemaFromDecoratorMetadata = (
956
1114
  const numericFields = Object.entries(metadata._svDbOptions || {})
957
1115
  .filter(([, optionsArg]) => optionsArg?.numeric === true)
958
1116
  .map(([fieldArg]) => fieldArg);
1117
+ const atomicOnlyFields = readDecoratedAtomicOnlyFields(
1118
+ (metadata._svDbOptions as Record<string, SvDbOptions> | undefined) || {},
1119
+ );
959
1120
  const indexes: ICollectionModelIndex[] = [];
960
1121
  for (const uniqueField of metadata.uniqueIndexes || []) {
961
1122
  const declaredIndexName = metadata.identityIndexNames?.[uniqueField];
@@ -1008,12 +1169,16 @@ const schemaFromDecoratorMetadata = (
1008
1169
  collectionName: collectionNameArg,
1009
1170
  persistedFields,
1010
1171
  numericFields,
1172
+ atomicOnlyFields,
1011
1173
  identityFields: metadata.uniqueIndexes || [],
1012
1174
  ...(identityAsDocumentIdArg !== undefined
1013
1175
  ? { identityAsDocumentId: identityAsDocumentIdArg }
1014
1176
  : {}),
1015
1177
  searchableFields: metadata.searchableFields || [],
1016
1178
  indexes,
1179
+ ...(toleratedIndexNamesArg !== undefined
1180
+ ? { toleratedIndexNames: toleratedIndexNamesArg }
1181
+ : {}),
1017
1182
  },
1018
1183
  true,
1019
1184
  metadata.identityValueTypes,
@@ -1138,6 +1303,30 @@ export function defineCollectionModel<
1138
1303
  if (identityAsDocumentId !== undefined) {
1139
1304
  assertIdentityDocumentIdIsOrdinary(modelArg, identityAsDocumentId);
1140
1305
  }
1306
+ // Where a field states `atomicOnly` explicitly through @svDb(), the
1307
+ // programmatic declaration must say the same, so neither reader of the model
1308
+ // can be misled about which fields instance save() writes. A plain @svDb()
1309
+ // states nothing about it and leaves the decision to this configuration.
1310
+ const declaredSvDbOptions = readDeclaredSvDbOptions(modelArg);
1311
+ const configuredAtomicOnly = new Set<string>([
1312
+ ...(inheritedSchema?.atomicOnlyFields || []),
1313
+ ...(configArg.atomicOnlyFields || []),
1314
+ ]);
1315
+ for (const [field, fieldOptions] of Object.entries(declaredSvDbOptions)) {
1316
+ if (
1317
+ !fieldOptions
1318
+ || !Object.prototype.hasOwnProperty.call(fieldOptions, 'atomicOnly')
1319
+ || fieldOptions.atomicOnly === configuredAtomicOnly.has(field)
1320
+ ) {
1321
+ continue;
1322
+ }
1323
+ throw new SmartdataPersistenceError(
1324
+ 'invalid_configuration',
1325
+ fieldOptions.atomicOnly
1326
+ ? `Persisted field "${field}" is declared atomicOnly by @svDb() but missing from atomicOnlyFields.`
1327
+ : `Atomic-only field "${field}" is declared by @svDb({ atomicOnly: false }).`,
1328
+ );
1329
+ }
1141
1330
  const normalizedSchema = normalizeCollectionModelSchema({
1142
1331
  ...configArg,
1143
1332
  persistedFields: [
@@ -1148,6 +1337,14 @@ export function defineCollectionModel<
1148
1337
  ...(inheritedSchema?.numericFields || []),
1149
1338
  ...(configArg.numericFields || []),
1150
1339
  ] as Array<TSmartdataNumericDocumentPath<TModel>>,
1340
+ atomicOnlyFields: [
1341
+ ...(inheritedSchema?.atomicOnlyFields || []),
1342
+ ...(configArg.atomicOnlyFields || []),
1343
+ ] as Array<keyof TModel & string>,
1344
+ toleratedIndexNames: [
1345
+ ...(inheritedSchema?.toleratedIndexNames || []),
1346
+ ...(configArg.toleratedIndexNames || []),
1347
+ ],
1151
1348
  identityFields: [
1152
1349
  ...(inheritedSchema?.identityFields || []),
1153
1350
  ...(configArg.identityFields || []),
@@ -1403,6 +1600,7 @@ export function Collection(
1403
1600
  constructor as TCollectionModelConstructor<any>,
1404
1601
  collectionName,
1405
1602
  identityAsDocumentId,
1603
+ optionsArg?.toleratedIndexNames,
1406
1604
  );
1407
1605
  },
1408
1606
  ) as any;
@@ -1476,6 +1674,8 @@ export function managed<TManager extends IManager>(
1476
1674
  return schemaFromDecoratorMetadata(
1477
1675
  constructor as TCollectionModelConstructor<any>,
1478
1676
  collectionName,
1677
+ undefined,
1678
+ bindingOptions?.toleratedIndexNames,
1479
1679
  );
1480
1680
  },
1481
1681
  );
@@ -1611,8 +1811,7 @@ export class SmartdataCollection<T> {
1611
1811
  {
1612
1812
  ordinaryWrite: ordinaryWriteArg,
1613
1813
  prepared: this.isInitializedForCurrentDatabase(),
1614
- preparationMessage:
1615
- `Initialize collection "${this.collectionName}" before using an owned SmartData session.`,
1814
+ collectionName: this.collectionName,
1616
1815
  },
1617
1816
  operationArg,
1618
1817
  );
@@ -2075,14 +2274,30 @@ export class SmartdataCollection<T> {
2075
2274
  );
2076
2275
  }
2077
2276
 
2277
+ /**
2278
+ * An owned session stays leased until the returned cursor is closed, because
2279
+ * the cursor outlives this call and the driver forbids a parallel operation
2280
+ * on the same session.
2281
+ */
2078
2282
  public async getCursor(
2079
2283
  filterObjectArg: any,
2080
2284
  dbDocArg: typeof SmartDataDbDoc,
2081
- opts?: { session?: plugins.mongodb.ClientSession }
2285
+ opts?: { session?: TSmartdataOrdinarySession }
2082
2286
  ): Promise<SmartdataDbCursor<any>> {
2083
- await this.init();
2084
- const cursor = this.mongoDbCollection.find(filterObjectArg, { session: opts?.session });
2085
- return new SmartdataDbCursor(cursor, dbDocArg);
2287
+ const lease = leaseOrdinarySmartdataSession(opts?.session, this.smartdataDb, {
2288
+ prepared: this.isInitializedForCurrentDatabase(),
2289
+ collectionName: this.collectionName,
2290
+ });
2291
+ try {
2292
+ await this.init();
2293
+ const cursor = this.mongoDbCollection.find(filterObjectArg, {
2294
+ session: lease.rawSession,
2295
+ });
2296
+ return new SmartdataDbCursor(cursor, dbDocArg, lease.release);
2297
+ } catch (cursorCreationError) {
2298
+ lease.release();
2299
+ throw cursorCreationError;
2300
+ }
2086
2301
  }
2087
2302
 
2088
2303
  /**
@@ -2438,17 +2653,34 @@ export class SmartdataCollection<T> {
2438
2653
  const identifiableObject = await dbDocArg.createIdentifiableObject();
2439
2654
  this.assertConstrainingIdentifiableObject(identifiableObject, 'update');
2440
2655
  const saveableObject = await dbDocArg.createSavableObject() as any;
2656
+ // Atomic-only fields belong to concurrent atomic writers, so this
2657
+ // instance's value never overwrites a stored one. It still seeds them
2658
+ // when this upsert inserts: a document created here must be as
2659
+ // complete as one created by insert(), or a later guard on a field
2660
+ // that was never stored would fail closed forever. $set and
2661
+ // $setOnInsert stay disjoint by construction, so MongoDB accepts both.
2662
+ const atomicOnlyFields = new Set(this.modelSchema?.atomicOnlyFields || []);
2441
2663
  const updateableObject: any = {};
2664
+ const insertOnlyObject: any = {};
2442
2665
  for (const key of Object.keys(saveableObject)) {
2443
2666
  if (identifiableObject[key]) {
2444
2667
  continue;
2445
2668
  }
2669
+ if (atomicOnlyFields.has(key)) {
2670
+ insertOnlyObject[key] = saveableObject[key];
2671
+ continue;
2672
+ }
2446
2673
  updateableObject[key] = saveableObject[key];
2447
2674
  }
2448
2675
  try {
2449
2676
  return await this.mongoDbCollection.updateOne(
2450
2677
  identifiableObject,
2451
- { $set: updateableObject },
2678
+ {
2679
+ $set: updateableObject,
2680
+ ...(Object.keys(insertOnlyObject).length > 0
2681
+ ? { $setOnInsert: insertOnlyObject }
2682
+ : {}),
2683
+ },
2452
2684
  { upsert: true, session: rawSessionArg },
2453
2685
  );
2454
2686
  } catch (errorArg) {
@@ -38,6 +38,8 @@ export interface ISmartdataCollectionTopologyIndex {
38
38
  export interface ISmartdataExpectedCollectionTopology {
39
39
  readonly collectionName: string;
40
40
  readonly indexes: readonly ISmartdataCollectionTopologyIndex[];
41
+ /** Undeclared installed indexes the model accepts by name, never verifies. */
42
+ readonly toleratedIndexNames: readonly string[];
41
43
  }
42
44
 
43
45
  export interface ISmartdataCollectionTopologyInspection {
@@ -45,7 +47,13 @@ export interface ISmartdataCollectionTopologyInspection {
45
47
  readonly status: TSmartdataCollectionTopologyStatus;
46
48
  readonly reasonCodes: readonly TSmartdataCollectionTopologyReasonCode[];
47
49
  readonly expectedIndexes: readonly ISmartdataCollectionTopologyIndex[];
50
+ /** Installed indexes SmartData declares and verifies. */
48
51
  readonly actualIndexes: readonly ISmartdataCollectionTopologyIndex[];
52
+ /**
53
+ * Installed indexes the model tolerates by name. They are observed, never
54
+ * verified, created or dropped, and they never make the topology divergent.
55
+ */
56
+ readonly toleratedIndexes: readonly string[];
49
57
  }
50
58
 
51
59
  type TCollectionModelSchemaResolver = () => INormalizedCollectionModelSchema;
@@ -295,6 +303,9 @@ export const getExpectedCollectionTopologyForSchema = (
295
303
  return Object.freeze({
296
304
  collectionName: schema.collectionName,
297
305
  indexes,
306
+ toleratedIndexNames: Object.freeze(
307
+ [...schema.toleratedIndexNames].sort(compareSmartdataTopologyStrings),
308
+ ),
298
309
  });
299
310
  };
300
311
 
@@ -512,6 +523,7 @@ const createInspection = (
512
523
  statusArg: TSmartdataCollectionTopologyStatus,
513
524
  reasonsArg: ReadonlySet<TSmartdataCollectionTopologyReasonCode>,
514
525
  actualIndexesArg: readonly ISmartdataCollectionTopologyIndex[],
526
+ toleratedIndexesArg: readonly string[] = [],
515
527
  ): ISmartdataCollectionTopologyInspection =>
516
528
  Object.freeze({
517
529
  collectionName: expectedArg.collectionName,
@@ -519,6 +531,9 @@ const createInspection = (
519
531
  reasonCodes: buildReasonCodes(reasonsArg),
520
532
  expectedIndexes: expectedArg.indexes,
521
533
  actualIndexes: sortIndexes(actualIndexesArg),
534
+ toleratedIndexes: Object.freeze(
535
+ [...toleratedIndexesArg].sort(compareSmartdataTopologyStrings),
536
+ ),
522
537
  });
523
538
 
524
539
  export const inspectCollectionTopologyForModel = async (
@@ -599,8 +614,17 @@ export const inspectCollectionTopologySnapshot = (
599
614
  }
600
615
  const normalizedIndexes: ISmartdataCollectionTopologyIndex[] = [];
601
616
  const unsupportedNames = new Set<string>();
617
+ const toleratedNames = new Set(expected.toleratedIndexNames);
618
+ const toleratedIndexes: string[] = [];
602
619
  for (const rawIndex of rawIndexes) {
603
620
  const normalized = normalizeActualIndex(rawIndex);
621
+ if (normalized.name && toleratedNames.has(normalized.name)) {
622
+ // A tolerated index belongs to whoever created it — a migration, an
623
+ // operator — so it is observed by name and nothing about its keys or
624
+ // options is asserted. SmartData neither creates nor drops it.
625
+ toleratedIndexes.push(normalized.name);
626
+ continue;
627
+ }
604
628
  if (!normalized.supported) {
605
629
  reasons.add('unsupported_actual_index');
606
630
  if (normalized.name) {
@@ -660,7 +684,13 @@ export const inspectCollectionTopologySnapshot = (
660
684
  )
661
685
  ? 'compatible'
662
686
  : 'divergent';
663
- return createInspection(expected, status, reasons, normalizedIndexes);
687
+ return createInspection(
688
+ expected,
689
+ status,
690
+ reasons,
691
+ normalizedIndexes,
692
+ toleratedIndexes,
693
+ );
664
694
  } catch {
665
695
  throw new SmartdataPersistenceError(
666
696
  'unsupported_operation',
@@ -10,9 +10,20 @@ export class SmartdataDbCursor<T = any> {
10
10
  // INSTANCE
11
11
  public mongodbCursor: plugins.mongodb.FindCursor<T>;
12
12
  private smartdataDbDoc: typeof SmartDataDbDoc;
13
- constructor(cursorArg: plugins.mongodb.FindCursor<T>, dbDocArg: typeof SmartDataDbDoc) {
13
+ /**
14
+ * Releases an owned SmartData session lease the cursor holds for its whole
15
+ * lifetime. Supplied by the operation that created the cursor; inert for a
16
+ * raw driver session or no session at all.
17
+ */
18
+ private releaseSession: () => void;
19
+ constructor(
20
+ cursorArg: plugins.mongodb.FindCursor<T>,
21
+ dbDocArg: typeof SmartDataDbDoc,
22
+ releaseSessionArg: () => void = () => {},
23
+ ) {
14
24
  this.mongodbCursor = cursorArg;
15
25
  this.smartdataDbDoc = dbDocArg;
26
+ this.releaseSession = releaseSessionArg;
16
27
  }
17
28
 
18
29
  public async next(closeAtEnd = true): Promise<T | null> {
@@ -73,8 +84,17 @@ export class SmartdataDbCursor<T = any> {
73
84
  return convertedResult;
74
85
  }
75
86
 
87
+ /**
88
+ * Closes the driver cursor and releases an owned session lease even when the
89
+ * driver's own cleanup rejects, so a failed close can never strand the
90
+ * session and block every later operation on it.
91
+ */
76
92
  public async close() {
77
- await this.mongodbCursor.close();
93
+ try {
94
+ await this.mongodbCursor.close();
95
+ } finally {
96
+ this.releaseSession();
97
+ }
78
98
  }
79
99
 
80
100
  private async closeAfterError(errorArg: unknown): Promise<never> {