@lossless.org/client 0.1.1 → 1.1.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.
@@ -52,6 +52,73 @@ export const executeAtomicUpdate = async <T>(
52
52
  );
53
53
  };
54
54
 
55
+ /**
56
+ * Executes a bounded batch of model-validated atomic upserts against MongoDB.
57
+ *
58
+ * This helper is package-internal. `SmartDataDbDoc.atomicUpsertMany()` owns the
59
+ * public API and must validate and normalize every filter and update before
60
+ * calling this raw persistence boundary. Each entry applies atomically; the
61
+ * batch as a whole is not isolated unless the caller supplies a transaction
62
+ * session.
63
+ */
64
+ export const executeAtomicUpsertMany = async <T>(
65
+ collectionArg: SmartdataCollection<T>,
66
+ operationsArg: ReadonlyArray<{
67
+ filter: plugins.mongodb.Filter<plugins.mongodb.Document>;
68
+ update: plugins.mongodb.UpdateFilter<plugins.mongodb.Document>;
69
+ }>,
70
+ optsArg?: {
71
+ session?: TSmartdataOrdinarySession;
72
+ timeoutMS?: number;
73
+ },
74
+ ): Promise<{
75
+ acknowledged: boolean;
76
+ matchedCount: number;
77
+ modifiedCount: number;
78
+ upsertedCount: number;
79
+ }> => {
80
+ return runWithOrdinarySmartdataSession(
81
+ optsArg?.session,
82
+ collectionArg.smartdataDb,
83
+ {
84
+ ordinaryWrite: true,
85
+ prepared: collectionArg.isInitializedForCurrentDatabase(),
86
+ preparationMessage:
87
+ `Initialize collection "${collectionArg.collectionName}" before using an owned SmartData session.`,
88
+ },
89
+ async (rawSessionArg) => {
90
+ await collectionArg.init();
91
+ try {
92
+ const result = await collectionArg.mongoDbCollection.bulkWrite(
93
+ operationsArg.map((operationArg) => ({
94
+ updateOne: {
95
+ filter: operationArg.filter,
96
+ update: operationArg.update,
97
+ upsert: true,
98
+ },
99
+ })),
100
+ {
101
+ ordered: false,
102
+ session: rawSessionArg,
103
+ timeoutMS: optsArg?.timeoutMS,
104
+ },
105
+ );
106
+ return {
107
+ acknowledged: result.isOk(),
108
+ matchedCount: result.matchedCount,
109
+ modifiedCount: result.modifiedCount,
110
+ upsertedCount: result.upsertedCount,
111
+ };
112
+ } catch (errorArg) {
113
+ return normalizeOrdinaryPersistenceError(
114
+ errorArg,
115
+ `Atomic upsert-many conflicts with a unique index in collection "${collectionArg.collectionName}".`,
116
+ );
117
+ }
118
+ },
119
+ );
120
+ };
121
+
55
122
  /**
56
123
  * Executes a model-validated atomic update-many against MongoDB.
57
124
  *
@@ -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';
@@ -57,6 +58,14 @@ export interface ICollectionBindingOptions {
57
58
  * its legacy collection without changing stored data.
58
59
  */
59
60
  collectionName?: string;
61
+ /**
62
+ * Stores one declared `@unI()` string identity as the document `_id`, making
63
+ * the primary key itself the uniqueness authority. The field keeps its own
64
+ * stored value and receives no separate unique index. Irreversible for an
65
+ * existing collection: every stored document must already satisfy
66
+ * `_id === document[field]`.
67
+ */
68
+ identityAsDocumentId?: string;
60
69
  }
61
70
 
62
71
  export type TCollectionModelIndexDirection = 1 | -1 | 'text';
@@ -106,6 +115,13 @@ export interface ICollectionModelConfig<TModel extends object = any> {
106
115
  * ascending unique index in `indexes`.
107
116
  */
108
117
  identityFields?: ReadonlyArray<TStringFieldKey<TModel>>;
118
+ /**
119
+ * Stores one declared string identity as the document `_id`. The primary key
120
+ * becomes the uniqueness authority, so the field must not declare its own
121
+ * unique index. Irreversible for an existing collection: every stored
122
+ * document must already satisfy `_id === document[field]`.
123
+ */
124
+ identityAsDocumentId?: TStringFieldKey<TModel>;
109
125
  /**
110
126
  * Persisted fields used by SmartData's search helpers.
111
127
  */
@@ -122,6 +138,8 @@ export interface INormalizedCollectionModelSchema {
122
138
  readonly persistedFields: readonly string[];
123
139
  readonly numericFields: readonly string[];
124
140
  readonly identityFields: readonly string[];
141
+ /** Declared identity field stored as the document `_id`, if any. */
142
+ readonly identityAsDocumentId?: string;
125
143
  readonly identityValueTypes: Readonly<
126
144
  Record<string, TSmartdataIdentityValueType>
127
145
  >;
@@ -323,6 +341,7 @@ const normalizeCollectionModelSchema = (
323
341
  'persistedFields',
324
342
  'numericFields',
325
343
  'identityFields',
344
+ 'identityAsDocumentId',
326
345
  'searchableFields',
327
346
  'indexes',
328
347
  ]);
@@ -395,6 +414,24 @@ const normalizeCollectionModelSchema = (
395
414
  identityFields.push(field);
396
415
  }
397
416
  }
417
+ const identityAsDocumentId = configArg.identityAsDocumentId;
418
+ if (identityAsDocumentId !== undefined) {
419
+ if (
420
+ typeof identityAsDocumentId !== 'string' ||
421
+ !identityFields.includes(identityAsDocumentId)
422
+ ) {
423
+ throw new SmartdataPersistenceError(
424
+ 'invalid_configuration',
425
+ 'Collection model identityAsDocumentId must name a declared identity field.',
426
+ );
427
+ }
428
+ if (ordinaryPolicyArg?.idType === 'string') {
429
+ throw new SmartdataPersistenceError(
430
+ 'invalid_configuration',
431
+ `Identity field "${identityAsDocumentId}" cannot own the document _id together with a string ordinary persistence _id.`,
432
+ );
433
+ }
434
+ }
398
435
  const identityValueTypes: Record<string, TSmartdataIdentityValueType> = {};
399
436
  for (const field of Object.keys(identityValueTypesArg || {})) {
400
437
  if (!identityFields.includes(field)) {
@@ -564,6 +601,25 @@ const normalizeCollectionModelSchema = (
564
601
  indexArg.key[0][0] === identityField &&
565
602
  indexArg.key[0][1] === 1,
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,
@@ -854,6 +912,7 @@ const definedModelSchemaSymbol = Symbol.for(
854
912
  const schemaFromDecoratorMetadata = (
855
913
  constructorArg: TCollectionModelConstructor<any>,
856
914
  collectionNameArg: string,
915
+ identityAsDocumentIdArg?: string,
857
916
  ): INormalizedCollectionModelSchema => {
858
917
  const ownMetadata = Object.prototype.hasOwnProperty.call(
859
918
  constructorArg,
@@ -885,6 +944,11 @@ const schemaFromDecoratorMetadata = (
885
944
  .map(([fieldArg]) => fieldArg);
886
945
  const indexes: ICollectionModelIndex[] = [];
887
946
  for (const uniqueField of metadata.uniqueIndexes || []) {
947
+ if (uniqueField === identityAsDocumentIdArg) {
948
+ // The document _id enforces this identity; a derived unique index would
949
+ // duplicate the primary key and require an index build on live data.
950
+ continue;
951
+ }
888
952
  indexes.push({
889
953
  name: `${uniqueField}_1`,
890
954
  key: { [uniqueField]: 1 },
@@ -924,6 +988,9 @@ const schemaFromDecoratorMetadata = (
924
988
  persistedFields,
925
989
  numericFields,
926
990
  identityFields: metadata.uniqueIndexes || [],
991
+ ...(identityAsDocumentIdArg !== undefined
992
+ ? { identityAsDocumentId: identityAsDocumentIdArg }
993
+ : {}),
927
994
  searchableFields: metadata.searchableFields || [],
928
995
  indexes,
929
996
  },
@@ -933,6 +1000,35 @@ const schemaFromDecoratorMetadata = (
933
1000
  );
934
1001
  };
935
1002
 
1003
+ /**
1004
+ * Exact persistence owns the stored `_id` as a MongoDB ObjectId, so a model
1005
+ * cannot also hand the primary key to a declared identity field.
1006
+ */
1007
+ const assertIdentityDocumentIdIsOrdinary = (
1008
+ modelArg: TCollectionModelConstructor<any>,
1009
+ identityAsDocumentIdArg: string,
1010
+ ): void => {
1011
+ if (hasExactPersistencePolicy(modelArg)) {
1012
+ throw new SmartdataPersistenceError(
1013
+ 'invalid_configuration',
1014
+ `Identity field "${identityAsDocumentIdArg}" cannot own the document _id for an exact-persistence model.`,
1015
+ );
1016
+ }
1017
+ };
1018
+
1019
+ /**
1020
+ * Returns the declared identity field a model stores as its document `_id`.
1021
+ * Reading the bound schema resolver never resolves a database.
1022
+ */
1023
+ export const getIdentityDocumentIdField = (modelArg: any): string | undefined => {
1024
+ const resolver = modelArg?.[collectionModelSchemaResolverSymbol] as
1025
+ | (() => INormalizedCollectionModelSchema)
1026
+ | undefined;
1027
+ return typeof resolver === 'function'
1028
+ ? resolver().identityAsDocumentId
1029
+ : undefined;
1030
+ };
1031
+
936
1032
  const installCollectionBinding = <
937
1033
  TModel extends SmartDataDbDoc<any, any>,
938
1034
  >(
@@ -1016,6 +1112,11 @@ export function defineCollectionModel<
1016
1112
  }
1017
1113
  | undefined;
1018
1114
  const inheritedSchema = inheritedConstructor?.[definedModelSchemaSymbol];
1115
+ const identityAsDocumentId =
1116
+ configArg.identityAsDocumentId ?? inheritedSchema?.identityAsDocumentId;
1117
+ if (identityAsDocumentId !== undefined) {
1118
+ assertIdentityDocumentIdIsOrdinary(modelArg, identityAsDocumentId);
1119
+ }
1019
1120
  const normalizedSchema = normalizeCollectionModelSchema({
1020
1121
  ...configArg,
1021
1122
  persistedFields: [
@@ -1030,6 +1131,9 @@ export function defineCollectionModel<
1030
1131
  ...(inheritedSchema?.identityFields || []),
1031
1132
  ...(configArg.identityFields || []),
1032
1133
  ] as Array<keyof TModel & string>,
1134
+ ...(identityAsDocumentId !== undefined
1135
+ ? { identityAsDocumentId: identityAsDocumentId as TStringFieldKey<TModel> }
1136
+ : {}),
1033
1137
  searchableFields: [
1034
1138
  ...(inheritedSchema?.searchableFields || []),
1035
1139
  ...(configArg.searchableFields || []),
@@ -1254,13 +1358,30 @@ export function Collection(
1254
1358
  const collectionName = normalizeCollectionName(
1255
1359
  optionsArg?.collectionName ?? constructor.name,
1256
1360
  );
1361
+ const identityAsDocumentId = optionsArg?.identityAsDocumentId;
1362
+ if (
1363
+ identityAsDocumentId !== undefined
1364
+ && (typeof identityAsDocumentId !== 'string' || identityAsDocumentId.length === 0)
1365
+ ) {
1366
+ throw new SmartdataPersistenceError(
1367
+ 'invalid_configuration',
1368
+ 'Collection identityAsDocumentId must name a declared @unI() identity field.',
1369
+ );
1370
+ }
1257
1371
  return installCollectionBinding(
1258
1372
  constructor as TCollectionModelConstructor<any>,
1259
1373
  () => (dbArg instanceof SmartdataDb ? dbArg : dbArg()),
1260
1374
  () => {
1375
+ if (identityAsDocumentId !== undefined) {
1376
+ assertIdentityDocumentIdIsOrdinary(
1377
+ constructor as TCollectionModelConstructor<any>,
1378
+ identityAsDocumentId,
1379
+ );
1380
+ }
1261
1381
  return schemaFromDecoratorMetadata(
1262
1382
  constructor as TCollectionModelConstructor<any>,
1263
1383
  collectionName,
1384
+ identityAsDocumentId,
1264
1385
  );
1265
1386
  },
1266
1387
  ) as any;
@@ -1719,6 +1840,11 @@ export class SmartdataCollection<T> {
1719
1840
  */
1720
1841
  public async markUniqueIndexes(keyArrayArg: string[] = []) {
1721
1842
  for (const key of keyArrayArg) {
1843
+ if (key === this.modelSchema?.identityAsDocumentId) {
1844
+ // The primary key already enforces this identity; building a second
1845
+ // unique index on a live collection is exactly what the option avoids.
1846
+ continue;
1847
+ }
1722
1848
  if (!this.uniqueIndexes.includes(key)) {
1723
1849
  try {
1724
1850
  await this.mongoDbCollection.createIndex({ [key]: 1 }, {
@@ -1959,6 +2085,7 @@ export class SmartdataCollection<T> {
1959
2085
  * create an object in the database
1960
2086
  */
1961
2087
  private prepareOrdinaryInsert(documentArg: any): any {
2088
+ documentArg = this.applyIdentityDocumentId(documentArg);
1962
2089
  const policy = this.modelSchema?.ordinaryPersistence;
1963
2090
  if (!policy) return documentArg;
1964
2091
  if (policy.idType === 'objectId' && !Object.prototype.hasOwnProperty.call(documentArg, '_id')) {
@@ -1967,11 +2094,46 @@ export class SmartdataCollection<T> {
1967
2094
  return validateOrdinaryStoredDocument(documentArg, policy, this.modelSchema!.persistedFields);
1968
2095
  }
1969
2096
 
2097
+ /**
2098
+ * Keys a stored document by its declared identity when the model opted into
2099
+ * `identityAsDocumentId`. The identity keeps its own stored field, but reads
2100
+ * address `_id`, so a document written before the option was declared is
2101
+ * reachable only if its `_id` already equals that identity.
2102
+ */
2103
+ private applyIdentityDocumentId(documentArg: any): any {
2104
+ const identityField = this.modelSchema?.identityAsDocumentId;
2105
+ if (!identityField) return documentArg;
2106
+ const identityValue = documentArg?.[identityField];
2107
+ if (typeof identityValue !== 'string' || identityValue.trim().length === 0) {
2108
+ throw new SmartdataPersistenceError(
2109
+ 'invalid_argument',
2110
+ `Identity field "${identityField}" must contain a non-empty string identity value to key the document.`,
2111
+ );
2112
+ }
2113
+ if (
2114
+ Object.prototype.hasOwnProperty.call(documentArg, '_id')
2115
+ && documentArg._id !== identityValue
2116
+ ) {
2117
+ throw new SmartdataPersistenceError(
2118
+ 'invalid_argument',
2119
+ `Identity field "${identityField}" owns the document _id and cannot be stored with a divergent _id.`,
2120
+ );
2121
+ }
2122
+ return { ...documentArg, _id: identityValue };
2123
+ }
2124
+
2125
+ /** True when stored documents need SmartData-owned preparation before a write. */
2126
+ private get preparesStoredDocuments(): boolean {
2127
+ return Boolean(
2128
+ this.modelSchema?.ordinaryPersistence || this.modelSchema?.identityAsDocumentId,
2129
+ );
2130
+ }
2131
+
1970
2132
  public async insert(
1971
2133
  dbDocArg: T & SmartDataDbDoc<T, unknown>,
1972
2134
  opts?: { session?: TSmartdataOrdinarySession }
1973
2135
  ): Promise<any> {
1974
- const preparedObject = this.modelSchema?.ordinaryPersistence
2136
+ const preparedObject = this.preparesStoredDocuments
1975
2137
  ? this.prepareOrdinaryInsert(await dbDocArg.createSavableObject()) : undefined;
1976
2138
  return this.runWithOrdinarySession(
1977
2139
  opts?.session,
@@ -2049,7 +2211,7 @@ export class SmartdataCollection<T> {
2049
2211
  `insertMany requires a non-empty document batch for collection "${this.collectionName}".`,
2050
2212
  );
2051
2213
  }
2052
- const preparedObjects: any[] | undefined = this.modelSchema?.ordinaryPersistence ? [] : undefined;
2214
+ const preparedObjects: any[] | undefined = this.preparesStoredDocuments ? [] : undefined;
2053
2215
  if (preparedObjects) {
2054
2216
  for (const dbDocArg of dbDocsArg) {
2055
2217
  preparedObjects.push(this.prepareOrdinaryInsert(await dbDocArg.createSavableObject()));
@@ -2118,14 +2280,23 @@ export class SmartdataCollection<T> {
2118
2280
  const first = documentsArg[0];
2119
2281
  await this.markUniqueIndexes(first.uniqueIndexes);
2120
2282
  await this.createRegularIndexes(first.regularIndexes || []);
2283
+ // A document keyed by its identity is addressed through _id, and the
2284
+ // seeded body must not repeat the immutable primary key: MongoDB derives
2285
+ // it from the filter's equality condition on the insert branch.
2286
+ const documentIdIdentity = this.modelSchema?.identityAsDocumentId === identityFieldArg;
2121
2287
  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 });
2288
+ const result = await this.mongoDbCollection.bulkWrite(prepared.map((document) => {
2289
+ const { _id: storedId, ...body } = document as Record<string, unknown>;
2290
+ return {
2291
+ updateOne: {
2292
+ filter: documentIdIdentity
2293
+ ? { _id: storedId as plugins.mongodb.Condition<plugins.mongodb.ObjectId> }
2294
+ : { [identityFieldArg]: document[identityFieldArg] },
2295
+ update: { $setOnInsert: documentIdIdentity ? body : document },
2296
+ upsert: true,
2297
+ },
2298
+ };
2299
+ }), { ordered: false, session: rawSessionArg, timeoutMS: optsArg.timeoutMS });
2129
2300
  if (result.upsertedCount + result.matchedCount !== prepared.length) {
2130
2301
  throw new SmartdataPersistenceError('unsupported_operation',
2131
2302
  'insertManyIfAbsent requires an acknowledged result for every identity.');