@lossless.org/client 1.0.0 → 1.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.
@@ -6,7 +6,8 @@ import {
6
6
  type ISmartdataCollectionPreparationOptions,
7
7
  } from './classes.collectionpreparation.js';
8
8
  import {
9
- getOrdinaryPersistencePolicy, validateOrdinaryStoredDocument,
9
+ getOrdinaryPersistencePolicy, hasExactPersistencePolicy,
10
+ validateOrdinaryStoredDocument,
10
11
  type IOrdinaryPersistencePolicy,
11
12
  } from './classes.ordinarypersistence.js';
12
13
  import { SmartdataDb } from './classes.db.js';
@@ -22,6 +23,7 @@ import { CollectionFactory } from './classes.collectionfactory.js';
22
23
  import { logger } from './logging.js';
23
24
  import {
24
25
  SmartdataPersistenceError,
26
+ isMongoIndexConflictError,
25
27
  normalizeOrdinaryPersistenceError,
26
28
  } from './classes.persistence.js';
27
29
  import { notifyCollectionReconnect } from './classes.collectionlifecycle.js';
@@ -33,6 +35,8 @@ import {
33
35
  collectionModelSchemaResolverSymbol,
34
36
  collectionTopologyDbInspectionSymbol,
35
37
  getExpectedCollectionTopologyForSchema,
38
+ getIdentityIndexName,
39
+ isIdentityIndexFor,
36
40
  compareSmartdataTopologyStrings,
37
41
  } from './classes.collectiontopology.js';
38
42
 
@@ -57,6 +61,14 @@ export interface ICollectionBindingOptions {
57
61
  * its legacy collection without changing stored data.
58
62
  */
59
63
  collectionName?: string;
64
+ /**
65
+ * Stores one declared `@unI()` string identity as the document `_id`, making
66
+ * the primary key itself the uniqueness authority. The field keeps its own
67
+ * stored value and receives no separate unique index. Irreversible for an
68
+ * existing collection: every stored document must already satisfy
69
+ * `_id === document[field]`.
70
+ */
71
+ identityAsDocumentId?: string;
60
72
  }
61
73
 
62
74
  export type TCollectionModelIndexDirection = 1 | -1 | 'text';
@@ -106,6 +118,13 @@ export interface ICollectionModelConfig<TModel extends object = any> {
106
118
  * ascending unique index in `indexes`.
107
119
  */
108
120
  identityFields?: ReadonlyArray<TStringFieldKey<TModel>>;
121
+ /**
122
+ * Stores one declared string identity as the document `_id`. The primary key
123
+ * becomes the uniqueness authority, so the field must not declare its own
124
+ * unique index. Irreversible for an existing collection: every stored
125
+ * document must already satisfy `_id === document[field]`.
126
+ */
127
+ identityAsDocumentId?: TStringFieldKey<TModel>;
109
128
  /**
110
129
  * Persisted fields used by SmartData's search helpers.
111
130
  */
@@ -122,6 +141,8 @@ export interface INormalizedCollectionModelSchema {
122
141
  readonly persistedFields: readonly string[];
123
142
  readonly numericFields: readonly string[];
124
143
  readonly identityFields: readonly string[];
144
+ /** Declared identity field stored as the document `_id`, if any. */
145
+ readonly identityAsDocumentId?: string;
125
146
  readonly identityValueTypes: Readonly<
126
147
  Record<string, TSmartdataIdentityValueType>
127
148
  >;
@@ -162,6 +183,7 @@ interface ISmartdataDecoratorMetadata {
162
183
  saveableProperties?: string[];
163
184
  uniqueIndexes?: string[];
164
185
  identityValueTypes?: Record<string, TSmartdataIdentityValueType>;
186
+ identityIndexNames?: Record<string, string>;
165
187
  regularIndexes?: Array<{field: string, options: IIndexOptions}>;
166
188
  compoundIndexes?: ICompoundIndexDefinition[];
167
189
  namedIndexes?: INamedIndexDefinition[];
@@ -323,6 +345,7 @@ const normalizeCollectionModelSchema = (
323
345
  'persistedFields',
324
346
  'numericFields',
325
347
  'identityFields',
348
+ 'identityAsDocumentId',
326
349
  'searchableFields',
327
350
  'indexes',
328
351
  ]);
@@ -395,6 +418,24 @@ const normalizeCollectionModelSchema = (
395
418
  identityFields.push(field);
396
419
  }
397
420
  }
421
+ const identityAsDocumentId = configArg.identityAsDocumentId;
422
+ if (identityAsDocumentId !== undefined) {
423
+ if (
424
+ typeof identityAsDocumentId !== 'string' ||
425
+ !identityFields.includes(identityAsDocumentId)
426
+ ) {
427
+ throw new SmartdataPersistenceError(
428
+ 'invalid_configuration',
429
+ 'Collection model identityAsDocumentId must name a declared identity field.',
430
+ );
431
+ }
432
+ if (ordinaryPolicyArg?.idType === 'string') {
433
+ throw new SmartdataPersistenceError(
434
+ 'invalid_configuration',
435
+ `Identity field "${identityAsDocumentId}" cannot own the document _id together with a string ordinary persistence _id.`,
436
+ );
437
+ }
438
+ }
398
439
  const identityValueTypes: Record<string, TSmartdataIdentityValueType> = {};
399
440
  for (const field of Object.keys(identityValueTypesArg || {})) {
400
441
  if (!identityFields.includes(field)) {
@@ -557,13 +598,28 @@ const normalizeCollectionModelSchema = (
557
598
  );
558
599
  }
559
600
  for (const identityField of identityFields) {
560
- const hasIdentityIndex = [...indexesByName.values()].some(
561
- (indexArg) =>
562
- indexArg.options.unique === true &&
563
- indexArg.key.length === 1 &&
564
- indexArg.key[0][0] === identityField &&
565
- indexArg.key[0][1] === 1,
601
+ const hasIdentityIndex = [...indexesByName.values()].some((indexArg) =>
602
+ isIdentityIndexFor(indexArg, identityField),
566
603
  );
604
+ if (identityField === identityAsDocumentId) {
605
+ // The primary key is the only uniqueness authority for this field. A
606
+ // second unique index would have to be built on a live collection and
607
+ // could disagree with _id, so the declaration is refused rather than
608
+ // silently ignored.
609
+ if (hasIdentityIndex) {
610
+ throw new SmartdataPersistenceError(
611
+ 'invalid_configuration',
612
+ `Identity field "${identityField}" owns the document _id and must not declare its own unique index.`,
613
+ );
614
+ }
615
+ if (identityValueTypes[identityField] !== 'string') {
616
+ throw new SmartdataPersistenceError(
617
+ 'invalid_configuration',
618
+ `Identity field "${identityField}" must be a string identity to own the document _id.`,
619
+ );
620
+ }
621
+ continue;
622
+ }
567
623
  if (!hasIdentityIndex) {
568
624
  throw new SmartdataPersistenceError(
569
625
  'invalid_configuration',
@@ -577,6 +633,7 @@ const normalizeCollectionModelSchema = (
577
633
  persistedFields: Object.freeze([...persistedFields]),
578
634
  numericFields: Object.freeze([...numericFields]),
579
635
  identityFields: Object.freeze([...identityFields]),
636
+ ...(identityAsDocumentId !== undefined ? { identityAsDocumentId } : {}),
580
637
  identityValueTypes: Object.freeze({ ...identityValueTypes }),
581
638
  searchableFields: Object.freeze([...searchableFields]),
582
639
  indexes: Object.freeze([...indexesByName.values()]),
@@ -591,6 +648,7 @@ const normalizeCollectionModelSchema = (
591
648
  persistedFields: [...persistedFields].sort(compareSmartdataTopologyStrings),
592
649
  numericFields: [...numericFields].sort(compareSmartdataTopologyStrings),
593
650
  identityFields: [...identityFields].sort(compareSmartdataTopologyStrings),
651
+ ...(identityAsDocumentId !== undefined ? { identityAsDocumentId } : {}),
594
652
  identityValueTypes,
595
653
  searchableFields: [...searchableFields].sort(
596
654
  compareSmartdataTopologyStrings,
@@ -624,22 +682,26 @@ const mergeStringArrays = (...arrays: Array<string[] | undefined>): string[] =>
624
682
  return merged;
625
683
  };
626
684
 
627
- const mergeIdentityValueTypes = (
628
- ...typeMapsArg: Array<
629
- Record<string, TSmartdataIdentityValueType> | undefined
630
- >
631
- ): Record<string, TSmartdataIdentityValueType> => {
632
- const merged: Record<string, TSmartdataIdentityValueType> = {};
633
- for (const typeMap of typeMapsArg) {
634
- for (const [field, valueType] of Object.entries(typeMap || {})) {
685
+ /**
686
+ * Merges a per-identity-field declaration across an inheritance chain.
687
+ * Divergence is refused instead of resolved by declaration order, because
688
+ * either winner would silently change a subclass's stored contract.
689
+ */
690
+ const mergeIdentityDeclarations = <TDeclaration extends string>(
691
+ declarationArg: 'valueType' | 'indexName',
692
+ ...declarationMapsArg: Array<Record<string, TDeclaration> | undefined>
693
+ ): Record<string, TDeclaration> => {
694
+ const merged: Record<string, TDeclaration> = {};
695
+ for (const declarationMap of declarationMapsArg) {
696
+ for (const [field, declaration] of Object.entries(declarationMap || {})) {
635
697
  const existing = merged[field];
636
- if (existing && existing !== valueType) {
698
+ if (existing && existing !== declaration) {
637
699
  throw new SmartdataPersistenceError(
638
700
  'invalid_configuration',
639
- `Identity field "${field}" has divergent inherited valueType declarations.`,
701
+ `Identity field "${field}" has divergent inherited ${declarationArg} declarations.`,
640
702
  );
641
703
  }
642
- merged[field] = valueType;
704
+ merged[field] = declaration;
643
705
  }
644
706
  }
645
707
  return merged;
@@ -737,7 +799,8 @@ const mergeDecoratorMetadata = (
737
799
  merged.uniqueIndexes = mergeStringArrays(
738
800
  ...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'uniqueIndexes')),
739
801
  );
740
- merged.identityValueTypes = mergeIdentityValueTypes(
802
+ merged.identityValueTypes = mergeIdentityDeclarations(
803
+ 'valueType',
741
804
  ...metadataArgs.map((metadataArg) =>
742
805
  getOwnMetadataValue<Record<string, TSmartdataIdentityValueType>>(
743
806
  metadataArg,
@@ -745,6 +808,15 @@ const mergeDecoratorMetadata = (
745
808
  ),
746
809
  ),
747
810
  );
811
+ merged.identityIndexNames = mergeIdentityDeclarations(
812
+ 'indexName',
813
+ ...metadataArgs.map((metadataArg) =>
814
+ getOwnMetadataValue<Record<string, string>>(
815
+ metadataArg,
816
+ 'identityIndexNames',
817
+ ),
818
+ ),
819
+ );
748
820
  merged.searchableFields = mergeStringArrays(
749
821
  ...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'searchableFields')),
750
822
  );
@@ -854,6 +926,7 @@ const definedModelSchemaSymbol = Symbol.for(
854
926
  const schemaFromDecoratorMetadata = (
855
927
  constructorArg: TCollectionModelConstructor<any>,
856
928
  collectionNameArg: string,
929
+ identityAsDocumentIdArg?: string,
857
930
  ): INormalizedCollectionModelSchema => {
858
931
  const ownMetadata = Object.prototype.hasOwnProperty.call(
859
932
  constructorArg,
@@ -885,8 +958,20 @@ const schemaFromDecoratorMetadata = (
885
958
  .map(([fieldArg]) => fieldArg);
886
959
  const indexes: ICollectionModelIndex[] = [];
887
960
  for (const uniqueField of metadata.uniqueIndexes || []) {
961
+ const declaredIndexName = metadata.identityIndexNames?.[uniqueField];
962
+ if (uniqueField === identityAsDocumentIdArg) {
963
+ // The document _id enforces this identity; a derived unique index would
964
+ // duplicate the primary key and require an index build on live data.
965
+ if (declaredIndexName !== undefined) {
966
+ throw new SmartdataPersistenceError(
967
+ 'invalid_configuration',
968
+ `Identity field "${uniqueField}" owns the document _id and must not name a unique index.`,
969
+ );
970
+ }
971
+ continue;
972
+ }
888
973
  indexes.push({
889
- name: `${uniqueField}_1`,
974
+ name: declaredIndexName ?? `${uniqueField}_1`,
890
975
  key: { [uniqueField]: 1 },
891
976
  options: { unique: true },
892
977
  });
@@ -924,6 +1009,9 @@ const schemaFromDecoratorMetadata = (
924
1009
  persistedFields,
925
1010
  numericFields,
926
1011
  identityFields: metadata.uniqueIndexes || [],
1012
+ ...(identityAsDocumentIdArg !== undefined
1013
+ ? { identityAsDocumentId: identityAsDocumentIdArg }
1014
+ : {}),
927
1015
  searchableFields: metadata.searchableFields || [],
928
1016
  indexes,
929
1017
  },
@@ -933,6 +1021,35 @@ const schemaFromDecoratorMetadata = (
933
1021
  );
934
1022
  };
935
1023
 
1024
+ /**
1025
+ * Exact persistence owns the stored `_id` as a MongoDB ObjectId, so a model
1026
+ * cannot also hand the primary key to a declared identity field.
1027
+ */
1028
+ const assertIdentityDocumentIdIsOrdinary = (
1029
+ modelArg: TCollectionModelConstructor<any>,
1030
+ identityAsDocumentIdArg: string,
1031
+ ): void => {
1032
+ if (hasExactPersistencePolicy(modelArg)) {
1033
+ throw new SmartdataPersistenceError(
1034
+ 'invalid_configuration',
1035
+ `Identity field "${identityAsDocumentIdArg}" cannot own the document _id for an exact-persistence model.`,
1036
+ );
1037
+ }
1038
+ };
1039
+
1040
+ /**
1041
+ * Returns the declared identity field a model stores as its document `_id`.
1042
+ * Reading the bound schema resolver never resolves a database.
1043
+ */
1044
+ export const getIdentityDocumentIdField = (modelArg: any): string | undefined => {
1045
+ const resolver = modelArg?.[collectionModelSchemaResolverSymbol] as
1046
+ | (() => INormalizedCollectionModelSchema)
1047
+ | undefined;
1048
+ return typeof resolver === 'function'
1049
+ ? resolver().identityAsDocumentId
1050
+ : undefined;
1051
+ };
1052
+
936
1053
  const installCollectionBinding = <
937
1054
  TModel extends SmartDataDbDoc<any, any>,
938
1055
  >(
@@ -1016,6 +1133,11 @@ export function defineCollectionModel<
1016
1133
  }
1017
1134
  | undefined;
1018
1135
  const inheritedSchema = inheritedConstructor?.[definedModelSchemaSymbol];
1136
+ const identityAsDocumentId =
1137
+ configArg.identityAsDocumentId ?? inheritedSchema?.identityAsDocumentId;
1138
+ if (identityAsDocumentId !== undefined) {
1139
+ assertIdentityDocumentIdIsOrdinary(modelArg, identityAsDocumentId);
1140
+ }
1019
1141
  const normalizedSchema = normalizeCollectionModelSchema({
1020
1142
  ...configArg,
1021
1143
  persistedFields: [
@@ -1030,6 +1152,9 @@ export function defineCollectionModel<
1030
1152
  ...(inheritedSchema?.identityFields || []),
1031
1153
  ...(configArg.identityFields || []),
1032
1154
  ] as Array<keyof TModel & string>,
1155
+ ...(identityAsDocumentId !== undefined
1156
+ ? { identityAsDocumentId: identityAsDocumentId as TStringFieldKey<TModel> }
1157
+ : {}),
1033
1158
  searchableFields: [
1034
1159
  ...(inheritedSchema?.searchableFields || []),
1035
1160
  ...(configArg.searchableFields || []),
@@ -1254,13 +1379,30 @@ export function Collection(
1254
1379
  const collectionName = normalizeCollectionName(
1255
1380
  optionsArg?.collectionName ?? constructor.name,
1256
1381
  );
1382
+ const identityAsDocumentId = optionsArg?.identityAsDocumentId;
1383
+ if (
1384
+ identityAsDocumentId !== undefined
1385
+ && (typeof identityAsDocumentId !== 'string' || identityAsDocumentId.length === 0)
1386
+ ) {
1387
+ throw new SmartdataPersistenceError(
1388
+ 'invalid_configuration',
1389
+ 'Collection identityAsDocumentId must name a declared @unI() identity field.',
1390
+ );
1391
+ }
1257
1392
  return installCollectionBinding(
1258
1393
  constructor as TCollectionModelConstructor<any>,
1259
1394
  () => (dbArg instanceof SmartdataDb ? dbArg : dbArg()),
1260
1395
  () => {
1396
+ if (identityAsDocumentId !== undefined) {
1397
+ assertIdentityDocumentIdIsOrdinary(
1398
+ constructor as TCollectionModelConstructor<any>,
1399
+ identityAsDocumentId,
1400
+ );
1401
+ }
1261
1402
  return schemaFromDecoratorMetadata(
1262
1403
  constructor as TCollectionModelConstructor<any>,
1263
1404
  collectionName,
1405
+ identityAsDocumentId,
1264
1406
  );
1265
1407
  },
1266
1408
  ) as any;
@@ -1358,6 +1500,79 @@ export function managed<TManager extends IManager>(
1358
1500
  */
1359
1501
  export const Manager = managed;
1360
1502
 
1503
+ /**
1504
+ * The index properties MongoDB weighs besides the key when it decides whether
1505
+ * two indexes collide. An existing index over the declared key that differs in
1506
+ * any of them is not interchangeable with the declaration, so adopting its name
1507
+ * would only collide again.
1508
+ */
1509
+ const indexEquivalenceSignature = (indexArg: {
1510
+ readonly unique?: unknown;
1511
+ readonly sparse?: unknown;
1512
+ readonly expireAfterSeconds?: unknown;
1513
+ readonly partialFilterExpression?: unknown;
1514
+ }): string =>
1515
+ stableValue({
1516
+ unique: indexArg.unique === true,
1517
+ sparse: indexArg.sparse === true,
1518
+ expireAfterSeconds: indexArg.expireAfterSeconds ?? null,
1519
+ partialFilterExpression: indexArg.partialFilterExpression ?? null,
1520
+ });
1521
+
1522
+ /**
1523
+ * MongoDB reports an index collision without naming the index already in the
1524
+ * namespace, yet the remedy depends on it: adopt that name in the declaration
1525
+ * or drop the index. Only the collision path pays for the extra catalog read.
1526
+ */
1527
+ const describeIndexCollision = async (
1528
+ targetCollectionArg: plugins.mongodb.Collection,
1529
+ collectionNameArg: string,
1530
+ expectedArg: INormalizedCollectionModelSchema['indexes'][number],
1531
+ errorArg: unknown,
1532
+ ): Promise<unknown> => {
1533
+ if (!isMongoIndexConflictError(errorArg)) {
1534
+ return errorArg;
1535
+ }
1536
+ let actualIndexes: plugins.mongodb.Document[];
1537
+ try {
1538
+ actualIndexes = await targetCollectionArg.listIndexes().toArray();
1539
+ } catch {
1540
+ // Without the catalog the collision cannot be attributed, and the driver's
1541
+ // own error stays the best evidence the caller can act on.
1542
+ return errorArg;
1543
+ }
1544
+ const equivalentIndex = actualIndexes.find(
1545
+ (actualArg) =>
1546
+ actualArg.name !== expectedArg.name &&
1547
+ stableValue(Object.entries(actualArg.key || {})) ===
1548
+ stableValue(expectedArg.key) &&
1549
+ indexEquivalenceSignature(actualArg) ===
1550
+ indexEquivalenceSignature(expectedArg.options),
1551
+ );
1552
+ if (equivalentIndex) {
1553
+ return new SmartdataPersistenceError(
1554
+ 'invalid_configuration',
1555
+ `Collection "${collectionNameArg}" already carries the equivalent index ` +
1556
+ `"${equivalentIndex.name}", so declared index "${expectedArg.name}" ` +
1557
+ 'cannot be created. Declare the existing name — an identity declares ' +
1558
+ 'it with @unI({ indexName }) — or drop the existing index.',
1559
+ { cause: errorArg },
1560
+ );
1561
+ }
1562
+ if (actualIndexes.some((actualArg) => actualArg.name === expectedArg.name)) {
1563
+ return new SmartdataPersistenceError(
1564
+ 'invalid_configuration',
1565
+ `Collection "${collectionNameArg}" already carries an index named ` +
1566
+ `"${expectedArg.name}" with a different key or options.`,
1567
+ { cause: errorArg },
1568
+ );
1569
+ }
1570
+ // An index over the same key that is not interchangeable with the
1571
+ // declaration cannot be adopted by name, and MongoDB's own error already
1572
+ // reports the options that differ.
1573
+ return errorArg;
1574
+ };
1575
+
1361
1576
  export class SmartdataCollection<T> {
1362
1577
  /**
1363
1578
  * the collection that is used
@@ -1595,13 +1810,22 @@ export class SmartdataCollection<T> {
1595
1810
  return;
1596
1811
  }
1597
1812
  for (const index of modelSchema.indexes) {
1598
- await targetCollection.createIndex(
1599
- Object.fromEntries(index.key) as plugins.mongodb.IndexSpecification,
1600
- {
1601
- ...index.options,
1602
- name: index.name,
1603
- },
1604
- );
1813
+ try {
1814
+ await targetCollection.createIndex(
1815
+ Object.fromEntries(index.key) as plugins.mongodb.IndexSpecification,
1816
+ {
1817
+ ...index.options,
1818
+ name: index.name,
1819
+ },
1820
+ );
1821
+ } catch (errorArg) {
1822
+ throw await describeIndexCollision(
1823
+ targetCollection,
1824
+ this.collectionName,
1825
+ index,
1826
+ errorArg,
1827
+ );
1828
+ }
1605
1829
  }
1606
1830
  const actualIndexes = await targetCollection.listIndexes().toArray();
1607
1831
  for (const expected of modelSchema.indexes) {
@@ -1719,11 +1943,18 @@ export class SmartdataCollection<T> {
1719
1943
  */
1720
1944
  public async markUniqueIndexes(keyArrayArg: string[] = []) {
1721
1945
  for (const key of keyArrayArg) {
1946
+ if (key === this.modelSchema?.identityAsDocumentId) {
1947
+ // The primary key already enforces this identity; building a second
1948
+ // unique index on a live collection is exactly what the option avoids.
1949
+ continue;
1950
+ }
1722
1951
  if (!this.uniqueIndexes.includes(key)) {
1723
1952
  try {
1724
1953
  await this.mongoDbCollection.createIndex({ [key]: 1 }, {
1725
1954
  unique: true,
1726
- name: `${key}_1`,
1955
+ // A bound model may back its identity with a differently named
1956
+ // index; creating `<field>_1` would duplicate that key.
1957
+ name: getIdentityIndexName(this.modelSchema, key) ?? `${key}_1`,
1727
1958
  });
1728
1959
  this.uniqueIndexes.push(key);
1729
1960
  } catch (err: any) {
@@ -1959,6 +2190,7 @@ export class SmartdataCollection<T> {
1959
2190
  * create an object in the database
1960
2191
  */
1961
2192
  private prepareOrdinaryInsert(documentArg: any): any {
2193
+ documentArg = this.applyIdentityDocumentId(documentArg);
1962
2194
  const policy = this.modelSchema?.ordinaryPersistence;
1963
2195
  if (!policy) return documentArg;
1964
2196
  if (policy.idType === 'objectId' && !Object.prototype.hasOwnProperty.call(documentArg, '_id')) {
@@ -1967,11 +2199,46 @@ export class SmartdataCollection<T> {
1967
2199
  return validateOrdinaryStoredDocument(documentArg, policy, this.modelSchema!.persistedFields);
1968
2200
  }
1969
2201
 
2202
+ /**
2203
+ * Keys a stored document by its declared identity when the model opted into
2204
+ * `identityAsDocumentId`. The identity keeps its own stored field, but reads
2205
+ * address `_id`, so a document written before the option was declared is
2206
+ * reachable only if its `_id` already equals that identity.
2207
+ */
2208
+ private applyIdentityDocumentId(documentArg: any): any {
2209
+ const identityField = this.modelSchema?.identityAsDocumentId;
2210
+ if (!identityField) return documentArg;
2211
+ const identityValue = documentArg?.[identityField];
2212
+ if (typeof identityValue !== 'string' || identityValue.trim().length === 0) {
2213
+ throw new SmartdataPersistenceError(
2214
+ 'invalid_argument',
2215
+ `Identity field "${identityField}" must contain a non-empty string identity value to key the document.`,
2216
+ );
2217
+ }
2218
+ if (
2219
+ Object.prototype.hasOwnProperty.call(documentArg, '_id')
2220
+ && documentArg._id !== identityValue
2221
+ ) {
2222
+ throw new SmartdataPersistenceError(
2223
+ 'invalid_argument',
2224
+ `Identity field "${identityField}" owns the document _id and cannot be stored with a divergent _id.`,
2225
+ );
2226
+ }
2227
+ return { ...documentArg, _id: identityValue };
2228
+ }
2229
+
2230
+ /** True when stored documents need SmartData-owned preparation before a write. */
2231
+ private get preparesStoredDocuments(): boolean {
2232
+ return Boolean(
2233
+ this.modelSchema?.ordinaryPersistence || this.modelSchema?.identityAsDocumentId,
2234
+ );
2235
+ }
2236
+
1970
2237
  public async insert(
1971
2238
  dbDocArg: T & SmartDataDbDoc<T, unknown>,
1972
2239
  opts?: { session?: TSmartdataOrdinarySession }
1973
2240
  ): Promise<any> {
1974
- const preparedObject = this.modelSchema?.ordinaryPersistence
2241
+ const preparedObject = this.preparesStoredDocuments
1975
2242
  ? this.prepareOrdinaryInsert(await dbDocArg.createSavableObject()) : undefined;
1976
2243
  return this.runWithOrdinarySession(
1977
2244
  opts?.session,
@@ -2049,7 +2316,7 @@ export class SmartdataCollection<T> {
2049
2316
  `insertMany requires a non-empty document batch for collection "${this.collectionName}".`,
2050
2317
  );
2051
2318
  }
2052
- const preparedObjects: any[] | undefined = this.modelSchema?.ordinaryPersistence ? [] : undefined;
2319
+ const preparedObjects: any[] | undefined = this.preparesStoredDocuments ? [] : undefined;
2053
2320
  if (preparedObjects) {
2054
2321
  for (const dbDocArg of dbDocsArg) {
2055
2322
  preparedObjects.push(this.prepareOrdinaryInsert(await dbDocArg.createSavableObject()));
@@ -2118,14 +2385,23 @@ export class SmartdataCollection<T> {
2118
2385
  const first = documentsArg[0];
2119
2386
  await this.markUniqueIndexes(first.uniqueIndexes);
2120
2387
  await this.createRegularIndexes(first.regularIndexes || []);
2388
+ // A document keyed by its identity is addressed through _id, and the
2389
+ // seeded body must not repeat the immutable primary key: MongoDB derives
2390
+ // it from the filter's equality condition on the insert branch.
2391
+ const documentIdIdentity = this.modelSchema?.identityAsDocumentId === identityFieldArg;
2121
2392
  try {
2122
- const result = await this.mongoDbCollection.bulkWrite(prepared.map((document) => ({
2123
- updateOne: {
2124
- filter: { [identityFieldArg]: document[identityFieldArg] },
2125
- update: { $setOnInsert: document },
2126
- upsert: true,
2127
- },
2128
- })), { ordered: false, session: rawSessionArg, timeoutMS: optsArg.timeoutMS });
2393
+ const result = await this.mongoDbCollection.bulkWrite(prepared.map((document) => {
2394
+ const { _id: storedId, ...body } = document as Record<string, unknown>;
2395
+ return {
2396
+ updateOne: {
2397
+ filter: documentIdIdentity
2398
+ ? { _id: storedId as plugins.mongodb.Condition<plugins.mongodb.ObjectId> }
2399
+ : { [identityFieldArg]: document[identityFieldArg] },
2400
+ update: { $setOnInsert: documentIdIdentity ? body : document },
2401
+ upsert: true,
2402
+ },
2403
+ };
2404
+ }), { ordered: false, session: rawSessionArg, timeoutMS: optsArg.timeoutMS });
2129
2405
  if (result.upsertedCount + result.matchedCount !== prepared.length) {
2130
2406
  throw new SmartdataPersistenceError('unsupported_operation',
2131
2407
  'insertManyIfAbsent requires an acknowledged result for every identity.');
@@ -298,6 +298,31 @@ export const getExpectedCollectionTopologyForSchema = (
298
298
  });
299
299
  };
300
300
 
301
+ /**
302
+ * @internal A single-field ascending unique index is what makes a declared
303
+ * identity globally constraining, whatever the index is called.
304
+ */
305
+ export const isIdentityIndexFor = (
306
+ indexArg: INormalizedCollectionModelSchema['indexes'][number],
307
+ fieldArg: string,
308
+ ): boolean =>
309
+ indexArg.options.unique === true &&
310
+ indexArg.key.length === 1 &&
311
+ indexArg.key[0][0] === fieldArg &&
312
+ indexArg.key[0][1] === 1;
313
+
314
+ /**
315
+ * @internal Name of the unique index that backs an identity field in a bound
316
+ * schema. Index-creation fallbacks resolve it here so that a model naming its
317
+ * own backing index never gets a second unique index over the same key.
318
+ */
319
+ export const getIdentityIndexName = (
320
+ schemaArg: INormalizedCollectionModelSchema | undefined,
321
+ fieldArg: string,
322
+ ): string | undefined =>
323
+ schemaArg?.indexes.find((indexArg) => isIdentityIndexFor(indexArg, fieldArg))
324
+ ?.name;
325
+
301
326
  const normalizeActualIndex = (
302
327
  rawIndexArg: unknown,
303
328
  ): IActualIndexNormalization => {