@lossless.org/client 1.1.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.
Files changed (34) hide show
  1. package/.smartconfig.json +1 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/nosqldb/classes.atomicdelete.js +3 -3
  4. package/dist_ts/nosqldb/classes.atomicfindoneandupdate.d.ts +20 -0
  5. package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +45 -2
  6. package/dist_ts/nosqldb/classes.atomicupdate.js +4 -4
  7. package/dist_ts/nosqldb/classes.collection.d.ts +37 -1
  8. package/dist_ts/nosqldb/classes.collection.js +235 -30
  9. package/dist_ts/nosqldb/classes.collectiontopology.d.ts +19 -0
  10. package/dist_ts/nosqldb/classes.collectiontopology.js +29 -3
  11. package/dist_ts/nosqldb/classes.cursor.d.ts +12 -1
  12. package/dist_ts/nosqldb/classes.cursor.js +20 -3
  13. package/dist_ts/nosqldb/classes.doc.d.ts +138 -11
  14. package/dist_ts/nosqldb/classes.doc.js +490 -91
  15. package/dist_ts/nosqldb/classes.exactpersistence.js +10 -2
  16. package/dist_ts/nosqldb/classes.namespaceinspection.js +2 -2
  17. package/dist_ts/nosqldb/classes.persistence.d.ts +6 -0
  18. package/dist_ts/nosqldb/classes.persistence.js +13 -1
  19. package/dist_ts/nosqldb/classes.session.d.ts +15 -1
  20. package/dist_ts/nosqldb/classes.session.js +32 -6
  21. package/package.json +4 -1
  22. package/readme.md +38 -2
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts/nosqldb/classes.atomicdelete.ts +2 -4
  25. package/ts/nosqldb/classes.atomicfindoneandupdate.ts +69 -2
  26. package/ts/nosqldb/classes.atomicupdate.ts +3 -6
  27. package/ts/nosqldb/classes.collection.ts +372 -35
  28. package/ts/nosqldb/classes.collectiontopology.ts +56 -1
  29. package/ts/nosqldb/classes.cursor.ts +22 -2
  30. package/ts/nosqldb/classes.doc.ts +819 -132
  31. package/ts/nosqldb/classes.exactpersistence.ts +9 -1
  32. package/ts/nosqldb/classes.namespaceinspection.ts +1 -1
  33. package/ts/nosqldb/classes.persistence.ts +17 -0
  34. package/ts/nosqldb/classes.session.ts +52 -13
@@ -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';
@@ -23,10 +24,12 @@ import { CollectionFactory } from './classes.collectionfactory.js';
23
24
  import { logger } from './logging.js';
24
25
  import {
25
26
  SmartdataPersistenceError,
27
+ isMongoIndexConflictError,
26
28
  normalizeOrdinaryPersistenceError,
27
29
  } from './classes.persistence.js';
28
30
  import { notifyCollectionReconnect } from './classes.collectionlifecycle.js';
29
31
  import {
32
+ leaseOrdinarySmartdataSession,
30
33
  runWithOrdinarySmartdataSession,
31
34
  type TSmartdataOrdinarySession,
32
35
  } from './classes.session.js';
@@ -34,6 +37,8 @@ import {
34
37
  collectionModelSchemaResolverSymbol,
35
38
  collectionTopologyDbInspectionSymbol,
36
39
  getExpectedCollectionTopologyForSchema,
40
+ getIdentityIndexName,
41
+ isIdentityIndexFor,
37
42
  compareSmartdataTopologyStrings,
38
43
  } from './classes.collectiontopology.js';
39
44
 
@@ -66,6 +71,14 @@ export interface ICollectionBindingOptions {
66
71
  * `_id === document[field]`.
67
72
  */
68
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>;
69
82
  }
70
83
 
71
84
  export type TCollectionModelIndexDirection = 1 | -1 | 'text';
@@ -109,6 +122,16 @@ export interface ICollectionModelConfig<TModel extends object = any> {
109
122
  * numeric-field constraint.
110
123
  */
111
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>;
112
135
  /**
113
136
  * Persisted string identity fields with @unI()-equivalent selector and
114
137
  * immutability semantics. Each field requires an explicit single-field,
@@ -130,6 +153,15 @@ export interface ICollectionModelConfig<TModel extends object = any> {
130
153
  * Stable named MongoDB indexes. Object property order defines key order.
131
154
  */
132
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>;
133
165
  }
134
166
 
135
167
  export interface INormalizedCollectionModelSchema {
@@ -137,6 +169,8 @@ export interface INormalizedCollectionModelSchema {
137
169
  readonly collectionName: string;
138
170
  readonly persistedFields: readonly string[];
139
171
  readonly numericFields: readonly string[];
172
+ /** Declared persisted fields a matching instance `save()` never overwrites. */
173
+ readonly atomicOnlyFields: readonly string[];
140
174
  readonly identityFields: readonly string[];
141
175
  /** Declared identity field stored as the document `_id`, if any. */
142
176
  readonly identityAsDocumentId?: string;
@@ -151,6 +185,8 @@ export interface INormalizedCollectionModelSchema {
151
185
  >;
152
186
  readonly options: Readonly<IIndexOptions>;
153
187
  }>;
188
+ /** Undeclared installed indexes this model accepts by name, never touches. */
189
+ readonly toleratedIndexNames: readonly string[];
154
190
  readonly fingerprint: string;
155
191
  }
156
192
 
@@ -180,6 +216,7 @@ interface ISmartdataDecoratorMetadata {
180
216
  saveableProperties?: string[];
181
217
  uniqueIndexes?: string[];
182
218
  identityValueTypes?: Record<string, TSmartdataIdentityValueType>;
219
+ identityIndexNames?: Record<string, string>;
183
220
  regularIndexes?: Array<{field: string, options: IIndexOptions}>;
184
221
  compoundIndexes?: ICompoundIndexDefinition[];
185
222
  namedIndexes?: INamedIndexDefinition[];
@@ -340,10 +377,12 @@ const normalizeCollectionModelSchema = (
340
377
  'collectionName',
341
378
  'persistedFields',
342
379
  'numericFields',
380
+ 'atomicOnlyFields',
343
381
  'identityFields',
344
382
  'identityAsDocumentId',
345
383
  'searchableFields',
346
384
  'indexes',
385
+ 'toleratedIndexNames',
347
386
  ]);
348
387
  for (const key of Object.keys(configArg)) {
349
388
  if (!allowedConfigKeys.has(key)) {
@@ -432,6 +471,37 @@ const normalizeCollectionModelSchema = (
432
471
  );
433
472
  }
434
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
+ }
435
505
  const identityValueTypes: Record<string, TSmartdataIdentityValueType> = {};
436
506
  for (const field of Object.keys(identityValueTypesArg || {})) {
437
507
  if (!identityFields.includes(field)) {
@@ -594,12 +664,8 @@ const normalizeCollectionModelSchema = (
594
664
  );
595
665
  }
596
666
  for (const identityField of identityFields) {
597
- const hasIdentityIndex = [...indexesByName.values()].some(
598
- (indexArg) =>
599
- indexArg.options.unique === true &&
600
- indexArg.key.length === 1 &&
601
- indexArg.key[0][0] === identityField &&
602
- indexArg.key[0][1] === 1,
667
+ const hasIdentityIndex = [...indexesByName.values()].some((indexArg) =>
668
+ isIdentityIndexFor(indexArg, identityField),
603
669
  );
604
670
  if (identityField === identityAsDocumentId) {
605
671
  // The primary key is the only uniqueness authority for this field. A
@@ -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
  };
@@ -682,22 +791,26 @@ const mergeStringArrays = (...arrays: Array<string[] | undefined>): string[] =>
682
791
  return merged;
683
792
  };
684
793
 
685
- const mergeIdentityValueTypes = (
686
- ...typeMapsArg: Array<
687
- Record<string, TSmartdataIdentityValueType> | undefined
688
- >
689
- ): Record<string, TSmartdataIdentityValueType> => {
690
- const merged: Record<string, TSmartdataIdentityValueType> = {};
691
- for (const typeMap of typeMapsArg) {
692
- for (const [field, valueType] of Object.entries(typeMap || {})) {
794
+ /**
795
+ * Merges a per-identity-field declaration across an inheritance chain.
796
+ * Divergence is refused instead of resolved by declaration order, because
797
+ * either winner would silently change a subclass's stored contract.
798
+ */
799
+ const mergeIdentityDeclarations = <TDeclaration extends string>(
800
+ declarationArg: 'valueType' | 'indexName',
801
+ ...declarationMapsArg: Array<Record<string, TDeclaration> | undefined>
802
+ ): Record<string, TDeclaration> => {
803
+ const merged: Record<string, TDeclaration> = {};
804
+ for (const declarationMap of declarationMapsArg) {
805
+ for (const [field, declaration] of Object.entries(declarationMap || {})) {
693
806
  const existing = merged[field];
694
- if (existing && existing !== valueType) {
807
+ if (existing && existing !== declaration) {
695
808
  throw new SmartdataPersistenceError(
696
809
  'invalid_configuration',
697
- `Identity field "${field}" has divergent inherited valueType declarations.`,
810
+ `Identity field "${field}" has divergent inherited ${declarationArg} declarations.`,
698
811
  );
699
812
  }
700
- merged[field] = valueType;
813
+ merged[field] = declaration;
701
814
  }
702
815
  }
703
816
  return merged;
@@ -795,7 +908,8 @@ const mergeDecoratorMetadata = (
795
908
  merged.uniqueIndexes = mergeStringArrays(
796
909
  ...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'uniqueIndexes')),
797
910
  );
798
- merged.identityValueTypes = mergeIdentityValueTypes(
911
+ merged.identityValueTypes = mergeIdentityDeclarations(
912
+ 'valueType',
799
913
  ...metadataArgs.map((metadataArg) =>
800
914
  getOwnMetadataValue<Record<string, TSmartdataIdentityValueType>>(
801
915
  metadataArg,
@@ -803,6 +917,15 @@ const mergeDecoratorMetadata = (
803
917
  ),
804
918
  ),
805
919
  );
920
+ merged.identityIndexNames = mergeIdentityDeclarations(
921
+ 'indexName',
922
+ ...metadataArgs.map((metadataArg) =>
923
+ getOwnMetadataValue<Record<string, string>>(
924
+ metadataArg,
925
+ 'identityIndexNames',
926
+ ),
927
+ ),
928
+ );
806
929
  merged.searchableFields = mergeStringArrays(
807
930
  ...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'searchableFields')),
808
931
  );
@@ -826,7 +949,22 @@ const mergeDecoratorMetadata = (
826
949
  for (const metadataArg of metadataArgs) {
827
950
  const options = getOwnMetadataValue<Record<string, any>>(metadataArg, '_svDbOptions');
828
951
  if (options) {
829
- 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
+ }
830
968
  }
831
969
  }
832
970
  if (Object.keys(svDbOptions).length > 0) {
@@ -909,10 +1047,44 @@ const definedModelSchemaSymbol = Symbol.for(
909
1047
  '@push.rocks/smartdata.definedCollectionModelSchema',
910
1048
  );
911
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
+
912
1083
  const schemaFromDecoratorMetadata = (
913
1084
  constructorArg: TCollectionModelConstructor<any>,
914
1085
  collectionNameArg: string,
915
1086
  identityAsDocumentIdArg?: string,
1087
+ toleratedIndexNamesArg?: ReadonlyArray<string>,
916
1088
  ): INormalizedCollectionModelSchema => {
917
1089
  const ownMetadata = Object.prototype.hasOwnProperty.call(
918
1090
  constructorArg,
@@ -942,15 +1114,25 @@ const schemaFromDecoratorMetadata = (
942
1114
  const numericFields = Object.entries(metadata._svDbOptions || {})
943
1115
  .filter(([, optionsArg]) => optionsArg?.numeric === true)
944
1116
  .map(([fieldArg]) => fieldArg);
1117
+ const atomicOnlyFields = readDecoratedAtomicOnlyFields(
1118
+ (metadata._svDbOptions as Record<string, SvDbOptions> | undefined) || {},
1119
+ );
945
1120
  const indexes: ICollectionModelIndex[] = [];
946
1121
  for (const uniqueField of metadata.uniqueIndexes || []) {
1122
+ const declaredIndexName = metadata.identityIndexNames?.[uniqueField];
947
1123
  if (uniqueField === identityAsDocumentIdArg) {
948
1124
  // The document _id enforces this identity; a derived unique index would
949
1125
  // duplicate the primary key and require an index build on live data.
1126
+ if (declaredIndexName !== undefined) {
1127
+ throw new SmartdataPersistenceError(
1128
+ 'invalid_configuration',
1129
+ `Identity field "${uniqueField}" owns the document _id and must not name a unique index.`,
1130
+ );
1131
+ }
950
1132
  continue;
951
1133
  }
952
1134
  indexes.push({
953
- name: `${uniqueField}_1`,
1135
+ name: declaredIndexName ?? `${uniqueField}_1`,
954
1136
  key: { [uniqueField]: 1 },
955
1137
  options: { unique: true },
956
1138
  });
@@ -987,12 +1169,16 @@ const schemaFromDecoratorMetadata = (
987
1169
  collectionName: collectionNameArg,
988
1170
  persistedFields,
989
1171
  numericFields,
1172
+ atomicOnlyFields,
990
1173
  identityFields: metadata.uniqueIndexes || [],
991
1174
  ...(identityAsDocumentIdArg !== undefined
992
1175
  ? { identityAsDocumentId: identityAsDocumentIdArg }
993
1176
  : {}),
994
1177
  searchableFields: metadata.searchableFields || [],
995
1178
  indexes,
1179
+ ...(toleratedIndexNamesArg !== undefined
1180
+ ? { toleratedIndexNames: toleratedIndexNamesArg }
1181
+ : {}),
996
1182
  },
997
1183
  true,
998
1184
  metadata.identityValueTypes,
@@ -1117,6 +1303,30 @@ export function defineCollectionModel<
1117
1303
  if (identityAsDocumentId !== undefined) {
1118
1304
  assertIdentityDocumentIdIsOrdinary(modelArg, identityAsDocumentId);
1119
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
+ }
1120
1330
  const normalizedSchema = normalizeCollectionModelSchema({
1121
1331
  ...configArg,
1122
1332
  persistedFields: [
@@ -1127,6 +1337,14 @@ export function defineCollectionModel<
1127
1337
  ...(inheritedSchema?.numericFields || []),
1128
1338
  ...(configArg.numericFields || []),
1129
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
+ ],
1130
1348
  identityFields: [
1131
1349
  ...(inheritedSchema?.identityFields || []),
1132
1350
  ...(configArg.identityFields || []),
@@ -1382,6 +1600,7 @@ export function Collection(
1382
1600
  constructor as TCollectionModelConstructor<any>,
1383
1601
  collectionName,
1384
1602
  identityAsDocumentId,
1603
+ optionsArg?.toleratedIndexNames,
1385
1604
  );
1386
1605
  },
1387
1606
  ) as any;
@@ -1455,6 +1674,8 @@ export function managed<TManager extends IManager>(
1455
1674
  return schemaFromDecoratorMetadata(
1456
1675
  constructor as TCollectionModelConstructor<any>,
1457
1676
  collectionName,
1677
+ undefined,
1678
+ bindingOptions?.toleratedIndexNames,
1458
1679
  );
1459
1680
  },
1460
1681
  );
@@ -1479,6 +1700,79 @@ export function managed<TManager extends IManager>(
1479
1700
  */
1480
1701
  export const Manager = managed;
1481
1702
 
1703
+ /**
1704
+ * The index properties MongoDB weighs besides the key when it decides whether
1705
+ * two indexes collide. An existing index over the declared key that differs in
1706
+ * any of them is not interchangeable with the declaration, so adopting its name
1707
+ * would only collide again.
1708
+ */
1709
+ const indexEquivalenceSignature = (indexArg: {
1710
+ readonly unique?: unknown;
1711
+ readonly sparse?: unknown;
1712
+ readonly expireAfterSeconds?: unknown;
1713
+ readonly partialFilterExpression?: unknown;
1714
+ }): string =>
1715
+ stableValue({
1716
+ unique: indexArg.unique === true,
1717
+ sparse: indexArg.sparse === true,
1718
+ expireAfterSeconds: indexArg.expireAfterSeconds ?? null,
1719
+ partialFilterExpression: indexArg.partialFilterExpression ?? null,
1720
+ });
1721
+
1722
+ /**
1723
+ * MongoDB reports an index collision without naming the index already in the
1724
+ * namespace, yet the remedy depends on it: adopt that name in the declaration
1725
+ * or drop the index. Only the collision path pays for the extra catalog read.
1726
+ */
1727
+ const describeIndexCollision = async (
1728
+ targetCollectionArg: plugins.mongodb.Collection,
1729
+ collectionNameArg: string,
1730
+ expectedArg: INormalizedCollectionModelSchema['indexes'][number],
1731
+ errorArg: unknown,
1732
+ ): Promise<unknown> => {
1733
+ if (!isMongoIndexConflictError(errorArg)) {
1734
+ return errorArg;
1735
+ }
1736
+ let actualIndexes: plugins.mongodb.Document[];
1737
+ try {
1738
+ actualIndexes = await targetCollectionArg.listIndexes().toArray();
1739
+ } catch {
1740
+ // Without the catalog the collision cannot be attributed, and the driver's
1741
+ // own error stays the best evidence the caller can act on.
1742
+ return errorArg;
1743
+ }
1744
+ const equivalentIndex = actualIndexes.find(
1745
+ (actualArg) =>
1746
+ actualArg.name !== expectedArg.name &&
1747
+ stableValue(Object.entries(actualArg.key || {})) ===
1748
+ stableValue(expectedArg.key) &&
1749
+ indexEquivalenceSignature(actualArg) ===
1750
+ indexEquivalenceSignature(expectedArg.options),
1751
+ );
1752
+ if (equivalentIndex) {
1753
+ return new SmartdataPersistenceError(
1754
+ 'invalid_configuration',
1755
+ `Collection "${collectionNameArg}" already carries the equivalent index ` +
1756
+ `"${equivalentIndex.name}", so declared index "${expectedArg.name}" ` +
1757
+ 'cannot be created. Declare the existing name — an identity declares ' +
1758
+ 'it with @unI({ indexName }) — or drop the existing index.',
1759
+ { cause: errorArg },
1760
+ );
1761
+ }
1762
+ if (actualIndexes.some((actualArg) => actualArg.name === expectedArg.name)) {
1763
+ return new SmartdataPersistenceError(
1764
+ 'invalid_configuration',
1765
+ `Collection "${collectionNameArg}" already carries an index named ` +
1766
+ `"${expectedArg.name}" with a different key or options.`,
1767
+ { cause: errorArg },
1768
+ );
1769
+ }
1770
+ // An index over the same key that is not interchangeable with the
1771
+ // declaration cannot be adopted by name, and MongoDB's own error already
1772
+ // reports the options that differ.
1773
+ return errorArg;
1774
+ };
1775
+
1482
1776
  export class SmartdataCollection<T> {
1483
1777
  /**
1484
1778
  * the collection that is used
@@ -1517,8 +1811,7 @@ export class SmartdataCollection<T> {
1517
1811
  {
1518
1812
  ordinaryWrite: ordinaryWriteArg,
1519
1813
  prepared: this.isInitializedForCurrentDatabase(),
1520
- preparationMessage:
1521
- `Initialize collection "${this.collectionName}" before using an owned SmartData session.`,
1814
+ collectionName: this.collectionName,
1522
1815
  },
1523
1816
  operationArg,
1524
1817
  );
@@ -1716,13 +2009,22 @@ export class SmartdataCollection<T> {
1716
2009
  return;
1717
2010
  }
1718
2011
  for (const index of modelSchema.indexes) {
1719
- await targetCollection.createIndex(
1720
- Object.fromEntries(index.key) as plugins.mongodb.IndexSpecification,
1721
- {
1722
- ...index.options,
1723
- name: index.name,
1724
- },
1725
- );
2012
+ try {
2013
+ await targetCollection.createIndex(
2014
+ Object.fromEntries(index.key) as plugins.mongodb.IndexSpecification,
2015
+ {
2016
+ ...index.options,
2017
+ name: index.name,
2018
+ },
2019
+ );
2020
+ } catch (errorArg) {
2021
+ throw await describeIndexCollision(
2022
+ targetCollection,
2023
+ this.collectionName,
2024
+ index,
2025
+ errorArg,
2026
+ );
2027
+ }
1726
2028
  }
1727
2029
  const actualIndexes = await targetCollection.listIndexes().toArray();
1728
2030
  for (const expected of modelSchema.indexes) {
@@ -1849,7 +2151,9 @@ export class SmartdataCollection<T> {
1849
2151
  try {
1850
2152
  await this.mongoDbCollection.createIndex({ [key]: 1 }, {
1851
2153
  unique: true,
1852
- name: `${key}_1`,
2154
+ // A bound model may back its identity with a differently named
2155
+ // index; creating `<field>_1` would duplicate that key.
2156
+ name: getIdentityIndexName(this.modelSchema, key) ?? `${key}_1`,
1853
2157
  });
1854
2158
  this.uniqueIndexes.push(key);
1855
2159
  } catch (err: any) {
@@ -1970,14 +2274,30 @@ export class SmartdataCollection<T> {
1970
2274
  );
1971
2275
  }
1972
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
+ */
1973
2282
  public async getCursor(
1974
2283
  filterObjectArg: any,
1975
2284
  dbDocArg: typeof SmartDataDbDoc,
1976
- opts?: { session?: plugins.mongodb.ClientSession }
2285
+ opts?: { session?: TSmartdataOrdinarySession }
1977
2286
  ): Promise<SmartdataDbCursor<any>> {
1978
- await this.init();
1979
- const cursor = this.mongoDbCollection.find(filterObjectArg, { session: opts?.session });
1980
- 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
+ }
1981
2301
  }
1982
2302
 
1983
2303
  /**
@@ -2333,17 +2653,34 @@ export class SmartdataCollection<T> {
2333
2653
  const identifiableObject = await dbDocArg.createIdentifiableObject();
2334
2654
  this.assertConstrainingIdentifiableObject(identifiableObject, 'update');
2335
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 || []);
2336
2663
  const updateableObject: any = {};
2664
+ const insertOnlyObject: any = {};
2337
2665
  for (const key of Object.keys(saveableObject)) {
2338
2666
  if (identifiableObject[key]) {
2339
2667
  continue;
2340
2668
  }
2669
+ if (atomicOnlyFields.has(key)) {
2670
+ insertOnlyObject[key] = saveableObject[key];
2671
+ continue;
2672
+ }
2341
2673
  updateableObject[key] = saveableObject[key];
2342
2674
  }
2343
2675
  try {
2344
2676
  return await this.mongoDbCollection.updateOne(
2345
2677
  identifiableObject,
2346
- { $set: updateableObject },
2678
+ {
2679
+ $set: updateableObject,
2680
+ ...(Object.keys(insertOnlyObject).length > 0
2681
+ ? { $setOnInsert: insertOnlyObject }
2682
+ : {}),
2683
+ },
2347
2684
  { upsert: true, session: rawSessionArg },
2348
2685
  );
2349
2686
  } catch (errorArg) {