@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
@@ -25,7 +25,9 @@ import {
25
25
  import { SmartdataDbWatcher } from './classes.watcher.js';
26
26
  import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
27
27
  import { executeAtomicDelete, executeAtomicDeleteMany } from './classes.atomicdelete.js';
28
- import { executeAtomicFindOneAndUpdate } from './classes.atomicfindoneandupdate.js';
28
+ import {
29
+ executeAtomicFindOneAndReplace, executeAtomicFindOneAndUpdate,
30
+ } from './classes.atomicfindoneandupdate.js';
29
31
  import {
30
32
  executeAtomicUpdate, executeAtomicUpdateMany, executeAtomicUpsertMany,
31
33
  } from './classes.atomicupdate.js';
@@ -34,6 +36,7 @@ import {
34
36
  } from './classes.persistence.js';
35
37
  import {
36
38
  isOrdinarySessionInTransaction,
39
+ leaseOrdinarySmartdataSession,
37
40
  runWithOrdinarySmartdataSession,
38
41
  type TSmartdataOrdinarySession,
39
42
  } from './classes.session.js';
@@ -78,6 +81,7 @@ interface ISmartdataDecoratorMetadata extends DecoratorMetadataObject {
78
81
  saveableProperties?: string[];
79
82
  uniqueIndexes?: string[];
80
83
  identityValueTypes?: Record<string, TSmartdataIdentityValueType>;
84
+ identityIndexNames?: Record<string, string>;
81
85
  regularIndexes?: Array<{field: string, options: IIndexOptions}>;
82
86
  compoundIndexes?: Array<{
83
87
  name: string;
@@ -98,6 +102,14 @@ export interface IUnIOptions {
98
102
  * non-empty string. Numeric identities require an explicit opt-in.
99
103
  */
100
104
  valueType?: TSmartdataIdentityValueType;
105
+ /**
106
+ * Name of the single-field ascending unique index that backs this identity.
107
+ * Defaults to `<field>_1`. A collection whose unique index was created by a
108
+ * migration under another name declares that name here: MongoDB refuses a
109
+ * second index over the same key, so without it the identity could not be
110
+ * declared at all.
111
+ */
112
+ indexName?: string;
101
113
  }
102
114
 
103
115
  /**
@@ -210,6 +222,20 @@ export interface SvDbOptions {
210
222
  * expression references.
211
223
  */
212
224
  numeric?: boolean;
225
+ /**
226
+ * Declares a persisted field that only atomic operations may overwrite.
227
+ *
228
+ * The field stays a declared persisted root — atomic filters, updates and
229
+ * `$expr` references, projections, sorts and hydration all accept it. Every
230
+ * operation that creates a document stores it, so a created document is
231
+ * complete: `insert()`, `insertMany()`, `insertManyIfAbsent()`, a `save()`
232
+ * on a new instance, and the upsert branch of a `save()` whose row is gone,
233
+ * which seeds it through `$setOnInsert`. A `save()` that matches a stored
234
+ * document never writes it, so a stale instance can never overwrite a
235
+ * counter, quota or lease that concurrent atomic writers own. Combine with
236
+ * `numeric` for `$expr` references.
237
+ */
238
+ atomicOnly?: boolean;
213
239
  }
214
240
 
215
241
  /**
@@ -222,6 +248,29 @@ export function svDb(options?: SvDbOptions) {
222
248
  'svDb numeric must be a boolean.',
223
249
  );
224
250
  }
251
+ if (
252
+ options?.atomicOnly !== undefined
253
+ && typeof options.atomicOnly !== 'boolean'
254
+ ) {
255
+ throw new SmartdataPersistenceError(
256
+ 'invalid_configuration',
257
+ 'svDb atomicOnly must be a boolean.',
258
+ );
259
+ }
260
+ if (
261
+ options?.atomicOnly === true
262
+ && (
263
+ typeof options.serialize === 'function'
264
+ || typeof options.deserialize === 'function'
265
+ )
266
+ ) {
267
+ // Atomic operations refuse serialized roots outright, so an atomic-only
268
+ // serialized field could never be written at all.
269
+ throw new SmartdataPersistenceError(
270
+ 'invalid_configuration',
271
+ 'svDb atomicOnly cannot be combined with custom serialization.',
272
+ );
273
+ }
225
274
  const storedOptions = options
226
275
  ? {
227
276
  ...options,
@@ -323,11 +372,13 @@ export function unI(optionsArg: IUnIOptions = {}) {
323
372
  typeof optionsArg !== 'object'
324
373
  || optionsArg === null
325
374
  || Array.isArray(optionsArg)
326
- || Object.keys(optionsArg).some((keyArg) => keyArg !== 'valueType')
375
+ || Object.keys(optionsArg).some(
376
+ (keyArg) => keyArg !== 'valueType' && keyArg !== 'indexName',
377
+ )
327
378
  ) {
328
379
  throw new SmartdataPersistenceError(
329
380
  'invalid_configuration',
330
- 'unI options must be an object containing only valueType.',
381
+ 'unI options must be an object containing only valueType and indexName.',
331
382
  );
332
383
  }
333
384
  const valueType = optionsArg.valueType ?? 'string';
@@ -337,6 +388,25 @@ export function unI(optionsArg: IUnIOptions = {}) {
337
388
  'unI valueType must be "string" or "positiveSafeInteger".',
338
389
  );
339
390
  }
391
+ const indexName = optionsArg.indexName;
392
+ if (indexName !== undefined) {
393
+ if (
394
+ typeof indexName !== 'string'
395
+ || indexName.trim().length === 0
396
+ || indexName.includes('\0')
397
+ ) {
398
+ throw new SmartdataPersistenceError(
399
+ 'invalid_configuration',
400
+ 'unI indexName must be a non-empty index name.',
401
+ );
402
+ }
403
+ if (indexName === '_id_') {
404
+ throw new SmartdataPersistenceError(
405
+ 'invalid_configuration',
406
+ 'unI indexName "_id_" is reserved for MongoDB.',
407
+ );
408
+ }
409
+ }
340
410
  return (value: undefined, context: ClassFieldDecoratorContext) => {
341
411
  if (context.kind !== 'field') {
342
412
  throw new Error('unI can only decorate fields');
@@ -365,6 +435,24 @@ export function unI(optionsArg: IUnIOptions = {}) {
365
435
  );
366
436
  }
367
437
  metadata.identityValueTypes![propName] = valueType;
438
+ // An unnamed declaration writes no entry, so a subclass that redeclares
439
+ // the identity without a name keeps the index the base class named. Only
440
+ // a second, different name is a contradiction worth refusing.
441
+ if (indexName !== undefined) {
442
+ if (
443
+ !Object.prototype.hasOwnProperty.call(metadata, 'identityIndexNames')
444
+ ) {
445
+ metadata.identityIndexNames = { ...(metadata.identityIndexNames || {}) };
446
+ }
447
+ const existingIndexName = metadata.identityIndexNames![propName];
448
+ if (existingIndexName && existingIndexName !== indexName) {
449
+ throw new SmartdataPersistenceError(
450
+ 'invalid_configuration',
451
+ `Identity field "${propName}" has divergent indexName declarations.`,
452
+ );
453
+ }
454
+ metadata.identityIndexNames![propName] = indexName;
455
+ }
368
456
 
369
457
  // Also mark as saveable
370
458
  if (!Object.prototype.hasOwnProperty.call(metadata, 'saveableProperties')) {
@@ -457,13 +545,24 @@ export function index(options?: IIndexOptions) {
457
545
  // Helper type to extract element type from arrays or return T itself
458
546
  type ElementOf<T> = T extends ReadonlyArray<infer U> ? U : T;
459
547
 
548
+ /**
549
+ * Explicit BSON null is a legitimate predicate for a field the model declares
550
+ * optional. MongoDB answers `{ field: null }` with the documents that store an
551
+ * explicit null *and* the documents where the field is absent, so it is the
552
+ * only predicate that addresses a value an earlier writer stored as null. The
553
+ * runtime filters have always accepted it; the declared type admits it wherever
554
+ * the field type contains `undefined`.
555
+ */
556
+ export type TSmartdataOptionalNullPredicate<TValue> =
557
+ undefined extends TValue ? null : never;
558
+
460
559
  // Type for $in/$nin values - arrays of the element type
461
- type InValues<T> = ReadonlyArray<ElementOf<T>>;
560
+ type InValues<T> = ReadonlyArray<ElementOf<T> | TSmartdataOptionalNullPredicate<T>>;
462
561
 
463
562
  // Type that allows MongoDB operators on leaf values while maintaining nested type safety
464
- export type MongoFilterCondition<T> = T | {
465
- $eq?: T;
466
- $ne?: T;
563
+ export type MongoFilterCondition<T> = T | TSmartdataOptionalNullPredicate<T> | {
564
+ $eq?: T | TSmartdataOptionalNullPredicate<T>;
565
+ $ne?: T | TSmartdataOptionalNullPredicate<T>;
467
566
  $gt?: T;
468
567
  $gte?: T;
469
568
  $lt?: T;
@@ -527,7 +626,12 @@ export interface ISmartdataCursorOptions<T> {
527
626
  /** Model-declared index name, or MongoDB's built-in `_id_` index. */
528
627
  hint?: string;
529
628
  maxTimeMS?: number;
530
- session?: plugins.mongodb.ClientSession;
629
+ /**
630
+ * An owned session from `db.createSession()` stays leased until the cursor
631
+ * is closed, so the cursor and the session's next operation can never run in
632
+ * parallel. A raw `ClientSession` keeps its unmanaged pass-through behavior.
633
+ */
634
+ session?: TSmartdataOrdinarySession;
531
635
  /**
532
636
  * @deprecated Prefer the structured cursor options. The modifier runs last
533
637
  * for backward compatibility.
@@ -613,9 +717,20 @@ type TSmartdataAtomicMonotonic<T> =
613
717
  export type TSmartdataAtomicNumericFieldReference<T> =
614
718
  `$${TSmartdataNumericDocumentPath<T>}`;
615
719
 
720
+ /**
721
+ * Reads a declared numeric field, substituting a finite literal when the field
722
+ * is absent or stored as null. A document written before the field existed
723
+ * therefore takes the declared default instead of failing the comparison, while
724
+ * a present non-numeric value still fails closed.
725
+ */
726
+ export interface ISmartdataAtomicNumericIfNull<T> {
727
+ $ifNull: readonly [TSmartdataAtomicNumericFieldReference<T>, number];
728
+ }
729
+
616
730
  export type TSmartdataAtomicNumericExpressionOperand<T> =
617
731
  | number
618
732
  | TSmartdataAtomicNumericFieldReference<T>
733
+ | ISmartdataAtomicNumericIfNull<T>
619
734
  | {
620
735
  $add: readonly [
621
736
  TSmartdataAtomicNumericExpressionOperand<T>,
@@ -769,15 +884,24 @@ type TSmartdataAtomicInValue<TValue> =
769
884
 
770
885
  export type TSmartdataAtomicFilterCondition<TValue> =
771
886
  | TSmartdataAtomicDirectEquality<TValue>
887
+ | TSmartdataOptionalNullPredicate<TValue>
772
888
  | {
773
- $eq?: TValue;
774
- $ne?: TSmartdataAtomicDirectEquality<TValue>;
889
+ $eq?: TValue | TSmartdataOptionalNullPredicate<TValue>;
890
+ $ne?:
891
+ | TSmartdataAtomicDirectEquality<TValue>
892
+ | TSmartdataOptionalNullPredicate<TValue>;
775
893
  $gt?: TSmartdataAtomicDirectEquality<TValue>;
776
894
  $gte?: TSmartdataAtomicDirectEquality<TValue>;
777
895
  $lt?: TSmartdataAtomicDirectEquality<TValue>;
778
896
  $lte?: TSmartdataAtomicDirectEquality<TValue>;
779
- $in?: ReadonlyArray<TSmartdataAtomicInValue<TValue>>;
780
- $nin?: ReadonlyArray<TSmartdataAtomicInValue<TValue>>;
897
+ $in?: ReadonlyArray<
898
+ | TSmartdataAtomicInValue<TValue>
899
+ | TSmartdataOptionalNullPredicate<TValue>
900
+ >;
901
+ $nin?: ReadonlyArray<
902
+ | TSmartdataAtomicInValue<TValue>
903
+ | TSmartdataOptionalNullPredicate<TValue>
904
+ >;
781
905
  $exists?: boolean;
782
906
  $type?: plugins.mongodb.BSONType | plugins.mongodb.BSONTypeAlias;
783
907
  $regex?: string | RegExp;
@@ -858,6 +982,15 @@ export type TSmartdataAtomicFindOneAndUpdateResult<
858
982
  }
859
983
  | { status: 'not_matched'; document: null };
860
984
 
985
+ export interface ISmartdataAtomicFindOneAndReplaceOptions {
986
+ /**
987
+ * Which image to return: the stored replacement (`'after'`, the default) or
988
+ * the document it ousted (`'before'`).
989
+ */
990
+ returnDocument?: TSmartdataAtomicReturnDocument;
991
+ session?: TSmartdataOrdinarySession;
992
+ }
993
+
861
994
  export interface ISmartdataAtomicDeleteOptions {
862
995
  session?: TSmartdataOrdinarySession;
863
996
  /** Client-side operation deadline, including server selection and pool waits. */
@@ -916,6 +1049,87 @@ export interface ISmartdataAtomicUpsertManyResult {
916
1049
  upsertedCount: number;
917
1050
  }
918
1051
 
1052
+ /**
1053
+ * How a fenced save treats a declared persisted field the instance holds as
1054
+ * `undefined`. `'ignore'` leaves whatever the document stores for it, which is
1055
+ * what an instance that never loaded the field needs; `'unset'` removes it from
1056
+ * the stored document, which is what an instance that deliberately cleared the
1057
+ * field needs.
1058
+ */
1059
+ export type TSmartdataAbsentDeclaredFields = 'ignore' | 'unset';
1060
+
1061
+ export interface ISmartdataSaveIfOptions {
1062
+ session?: TSmartdataOrdinarySession;
1063
+ /** Defaults to `'ignore'`. */
1064
+ absentDeclaredFields?: TSmartdataAbsentDeclaredFields;
1065
+ }
1066
+
1067
+ export interface ISmartdataSaveIfResult {
1068
+ acknowledged: boolean;
1069
+ /** `0` means the fence did not match, so nothing was written. */
1070
+ matchedCount: number;
1071
+ modifiedCount: number;
1072
+ }
1073
+
1074
+ const normalizeSaveIfOptions = (
1075
+ optionsArg: ISmartdataSaveIfOptions | undefined,
1076
+ ): {
1077
+ session?: TSmartdataOrdinarySession;
1078
+ absentDeclaredFields: TSmartdataAbsentDeclaredFields;
1079
+ } => {
1080
+ if (optionsArg === undefined) {
1081
+ return { absentDeclaredFields: 'ignore' };
1082
+ }
1083
+ if (
1084
+ typeof optionsArg !== 'object'
1085
+ || optionsArg === null
1086
+ || plugins.nodeUtil.types.isProxy(optionsArg)
1087
+ || !isPlainObject(optionsArg)
1088
+ ) {
1089
+ throw new SmartdataPersistenceError(
1090
+ 'invalid_argument',
1091
+ 'Fenced save options must be an inert plain object.',
1092
+ );
1093
+ }
1094
+ const allowedKeys = new Set(['session', 'absentDeclaredFields']);
1095
+ for (const key of Reflect.ownKeys(optionsArg)) {
1096
+ if (typeof key !== 'string' || !allowedKeys.has(key)) {
1097
+ throw new SmartdataPersistenceError(
1098
+ 'invalid_argument',
1099
+ `Fenced save received unsupported option "${String(key)}".`,
1100
+ );
1101
+ }
1102
+ const descriptor = Object.getOwnPropertyDescriptor(optionsArg, key);
1103
+ if (!descriptor || !('value' in descriptor) || descriptor.enumerable !== true) {
1104
+ throw new SmartdataPersistenceError(
1105
+ 'invalid_argument',
1106
+ `Fenced save option "${key}" must be an inert enumerable data property.`,
1107
+ );
1108
+ }
1109
+ }
1110
+ const absentDeclaredFields = Object.getOwnPropertyDescriptor(
1111
+ optionsArg,
1112
+ 'absentDeclaredFields',
1113
+ )?.value as TSmartdataAbsentDeclaredFields | undefined;
1114
+ if (
1115
+ absentDeclaredFields !== undefined
1116
+ && absentDeclaredFields !== 'ignore'
1117
+ && absentDeclaredFields !== 'unset'
1118
+ ) {
1119
+ throw new SmartdataPersistenceError(
1120
+ 'invalid_argument',
1121
+ 'Fenced save absentDeclaredFields must be "ignore" or "unset".',
1122
+ );
1123
+ }
1124
+ const session = Object.getOwnPropertyDescriptor(optionsArg, 'session')?.value as
1125
+ | TSmartdataOrdinarySession
1126
+ | undefined;
1127
+ return {
1128
+ ...(session !== undefined ? { session } : {}),
1129
+ absentDeclaredFields: absentDeclaredFields ?? 'ignore',
1130
+ };
1131
+ };
1132
+
919
1133
  const normalizeAtomicFindOneAndUpdateOptions = <
920
1134
  TReturnDocument extends TSmartdataAtomicReturnDocument,
921
1135
  >(
@@ -993,6 +1207,63 @@ const normalizeAtomicFindOneAndUpdateOptions = <
993
1207
  };
994
1208
  };
995
1209
 
1210
+ const normalizeAtomicFindOneAndReplaceOptions = (
1211
+ optionsArg: ISmartdataAtomicFindOneAndReplaceOptions | undefined,
1212
+ ): {
1213
+ returnDocument: TSmartdataAtomicReturnDocument;
1214
+ session?: TSmartdataOrdinarySession;
1215
+ } => {
1216
+ if (optionsArg === undefined) {
1217
+ return { returnDocument: 'after' };
1218
+ }
1219
+ if (
1220
+ typeof optionsArg !== 'object'
1221
+ || optionsArg === null
1222
+ || plugins.nodeUtil.types.isProxy(optionsArg)
1223
+ || !isPlainObject(optionsArg)
1224
+ ) {
1225
+ throw new SmartdataPersistenceError(
1226
+ 'invalid_argument',
1227
+ 'Atomic find-one-and-replace options must be an inert plain object.',
1228
+ );
1229
+ }
1230
+ const allowedKeys = new Set(['returnDocument', 'session']);
1231
+ for (const key of Reflect.ownKeys(optionsArg)) {
1232
+ if (typeof key !== 'string' || !allowedKeys.has(key)) {
1233
+ throw new SmartdataPersistenceError(
1234
+ 'invalid_argument',
1235
+ `Atomic find-one-and-replace received unsupported option "${String(key)}".`,
1236
+ );
1237
+ }
1238
+ const descriptor = Object.getOwnPropertyDescriptor(optionsArg, key);
1239
+ if (!descriptor || !('value' in descriptor) || descriptor.enumerable !== true) {
1240
+ throw new SmartdataPersistenceError(
1241
+ 'invalid_argument',
1242
+ `Atomic find-one-and-replace option "${key}" must be an inert enumerable data property.`,
1243
+ );
1244
+ }
1245
+ }
1246
+ const returnDocument = Object.getOwnPropertyDescriptor(optionsArg, 'returnDocument')
1247
+ ?.value as TSmartdataAtomicReturnDocument | undefined;
1248
+ if (
1249
+ returnDocument !== undefined
1250
+ && returnDocument !== 'before'
1251
+ && returnDocument !== 'after'
1252
+ ) {
1253
+ throw new SmartdataPersistenceError(
1254
+ 'invalid_argument',
1255
+ 'Atomic find-one-and-replace returnDocument must be "before" or "after".',
1256
+ );
1257
+ }
1258
+ const session = Object.getOwnPropertyDescriptor(optionsArg, 'session')?.value as
1259
+ | TSmartdataOrdinarySession
1260
+ | undefined;
1261
+ return {
1262
+ returnDocument: returnDocument ?? 'after',
1263
+ ...(session !== undefined ? { session } : {}),
1264
+ };
1265
+ };
1266
+
996
1267
  const normalizeAtomicExpressionUpdateOptions = (
997
1268
  optionsArg: ISmartdataAtomicUpdateOptions | undefined,
998
1269
  ): ISmartdataAtomicUpdateOptions | undefined => {
@@ -2004,7 +2275,7 @@ const atomicNumericExpressionMaximumLeafOperands = 64;
2004
2275
 
2005
2276
  const getAtomicExpressionProperty = (
2006
2277
  valueArg: unknown,
2007
- expectedKeyArg: '$lte' | '$add',
2278
+ expectedKeyArg: '$lte' | '$add' | '$ifNull',
2008
2279
  labelArg: string,
2009
2280
  ): unknown => {
2010
2281
  if (
@@ -2100,9 +2371,27 @@ const getAtomicExpressionArrayValues = (
2100
2371
 
2101
2372
  interface INormalizedAtomicNumericExpressionOperand {
2102
2373
  mongoExpression: unknown;
2374
+ /** Fields the expression reads directly and that must be numeric to match. */
2103
2375
  referencedFields: Set<string>;
2376
+ /** Fields read through `$ifNull`, where absent or null takes the literal. */
2377
+ nullableFields: Set<string>;
2104
2378
  }
2105
2379
 
2380
+ /** Detects the inert `{ $ifNull: [...] }` operand shape before validating it. */
2381
+ const isAtomicIfNullOperand = (operandArg: unknown): boolean => {
2382
+ if (
2383
+ typeof operandArg !== 'object'
2384
+ || operandArg === null
2385
+ || Array.isArray(operandArg)
2386
+ || plugins.nodeUtil.types.isProxy(operandArg)
2387
+ || Object.getPrototypeOf(operandArg) !== Object.prototype
2388
+ ) {
2389
+ return false;
2390
+ }
2391
+ const keys = Reflect.ownKeys(operandArg);
2392
+ return keys.length === 1 && keys[0] === '$ifNull';
2393
+ };
2394
+
2106
2395
  const normalizeAtomicNumericExpression = (
2107
2396
  expressionArg: unknown,
2108
2397
  declaredRootsArg: Set<string>,
@@ -2111,77 +2400,88 @@ const normalizeAtomicNumericExpression = (
2111
2400
  operationLabelArg: string,
2112
2401
  ): plugins.mongodb.Document => {
2113
2402
  let leafOperandCount = 0;
2403
+ const normalizeNumericLiteral = (literalArg: unknown): number => {
2404
+ leafOperandCount++;
2405
+ if (
2406
+ typeof literalArg !== 'number'
2407
+ || !Number.isFinite(literalArg)
2408
+ || leafOperandCount > atomicNumericExpressionMaximumLeafOperands
2409
+ ) {
2410
+ throw new SmartdataPersistenceError(
2411
+ 'invalid_argument',
2412
+ `${operationLabelArg} $expr requires finite numeric literals and no more than ${atomicNumericExpressionMaximumLeafOperands} leaf operands.`,
2413
+ );
2414
+ }
2415
+ return literalArg;
2416
+ };
2417
+ const normalizeFieldReference = (referenceArg: unknown): string => {
2418
+ leafOperandCount++;
2419
+ if (leafOperandCount > atomicNumericExpressionMaximumLeafOperands) {
2420
+ throw new SmartdataPersistenceError(
2421
+ 'invalid_argument',
2422
+ `${operationLabelArg} $expr may not exceed ${atomicNumericExpressionMaximumLeafOperands} leaf operands.`,
2423
+ );
2424
+ }
2425
+ if (
2426
+ typeof referenceArg !== 'string'
2427
+ || !referenceArg.startsWith('$')
2428
+ || referenceArg.startsWith('$$')
2429
+ || referenceArg.length < 2
2430
+ ) {
2431
+ throw new SmartdataPersistenceError(
2432
+ 'invalid_argument',
2433
+ `${operationLabelArg} $expr field references must use exactly one leading $.`,
2434
+ );
2435
+ }
2436
+ const field = referenceArg.slice(1);
2437
+ if (field === 'toBSON' || field === '_bsontype') {
2438
+ throw new SmartdataPersistenceError(
2439
+ 'invalid_argument',
2440
+ `${operationLabelArg} $expr field "${field}" is reserved.`,
2441
+ );
2442
+ }
2443
+ const root = requireDeclaredPath(
2444
+ field,
2445
+ declaredRootsArg,
2446
+ `${operationLabelArg} $expr`,
2447
+ );
2448
+ if (field !== root) {
2449
+ throw new SmartdataPersistenceError(
2450
+ 'invalid_argument',
2451
+ `${operationLabelArg} $expr may reference only declared top-level fields.`,
2452
+ );
2453
+ }
2454
+ if (!numericRootsArg.has(root)) {
2455
+ throw new SmartdataPersistenceError(
2456
+ 'invalid_argument',
2457
+ `${operationLabelArg} $expr field "${root}" is not declared numeric.`,
2458
+ );
2459
+ }
2460
+ if (serializedRootsArg.has(root)) {
2461
+ throw new SmartdataPersistenceError(
2462
+ 'unsupported_operation',
2463
+ `${operationLabelArg} $expr cannot reference serialized field "${root}".`,
2464
+ );
2465
+ }
2466
+ return root;
2467
+ };
2114
2468
  const normalizeOperand = (
2115
2469
  operandArg: unknown,
2116
2470
  depthArg: number,
2117
2471
  ): INormalizedAtomicNumericExpressionOperand => {
2118
2472
  if (typeof operandArg === 'number') {
2119
- leafOperandCount++;
2120
- if (
2121
- !Number.isFinite(operandArg)
2122
- || leafOperandCount > atomicNumericExpressionMaximumLeafOperands
2123
- ) {
2124
- throw new SmartdataPersistenceError(
2125
- 'invalid_argument',
2126
- `${operationLabelArg} $expr requires finite numeric literals and no more than ${atomicNumericExpressionMaximumLeafOperands} leaf operands.`,
2127
- );
2128
- }
2129
2473
  return {
2130
- mongoExpression: operandArg,
2474
+ mongoExpression: normalizeNumericLiteral(operandArg),
2131
2475
  referencedFields: new Set(),
2476
+ nullableFields: new Set(),
2132
2477
  };
2133
2478
  }
2134
2479
  if (typeof operandArg === 'string') {
2135
- leafOperandCount++;
2136
- if (leafOperandCount > atomicNumericExpressionMaximumLeafOperands) {
2137
- throw new SmartdataPersistenceError(
2138
- 'invalid_argument',
2139
- `${operationLabelArg} $expr may not exceed ${atomicNumericExpressionMaximumLeafOperands} leaf operands.`,
2140
- );
2141
- }
2142
- if (
2143
- !operandArg.startsWith('$')
2144
- || operandArg.startsWith('$$')
2145
- || operandArg.length < 2
2146
- ) {
2147
- throw new SmartdataPersistenceError(
2148
- 'invalid_argument',
2149
- `${operationLabelArg} $expr field references must use exactly one leading $.`,
2150
- );
2151
- }
2152
- const field = operandArg.slice(1);
2153
- if (field === 'toBSON' || field === '_bsontype') {
2154
- throw new SmartdataPersistenceError(
2155
- 'invalid_argument',
2156
- `${operationLabelArg} $expr field "${field}" is reserved.`,
2157
- );
2158
- }
2159
- const root = requireDeclaredPath(
2160
- field,
2161
- declaredRootsArg,
2162
- `${operationLabelArg} $expr`,
2163
- );
2164
- if (field !== root) {
2165
- throw new SmartdataPersistenceError(
2166
- 'invalid_argument',
2167
- `${operationLabelArg} $expr may reference only declared top-level fields.`,
2168
- );
2169
- }
2170
- if (!numericRootsArg.has(root)) {
2171
- throw new SmartdataPersistenceError(
2172
- 'invalid_argument',
2173
- `${operationLabelArg} $expr field "${root}" is not declared numeric.`,
2174
- );
2175
- }
2176
- if (serializedRootsArg.has(root)) {
2177
- throw new SmartdataPersistenceError(
2178
- 'unsupported_operation',
2179
- `${operationLabelArg} $expr cannot reference serialized field "${root}".`,
2180
- );
2181
- }
2480
+ const root = normalizeFieldReference(operandArg);
2182
2481
  return {
2183
2482
  mongoExpression: `$${root}`,
2184
2483
  referencedFields: new Set([root]),
2484
+ nullableFields: new Set(),
2185
2485
  };
2186
2486
  }
2187
2487
  if (depthArg >= atomicNumericExpressionMaximumDepth) {
@@ -2190,6 +2490,28 @@ const normalizeAtomicNumericExpression = (
2190
2490
  `${operationLabelArg} $expr exceeds the maximum $add depth of ${atomicNumericExpressionMaximumDepth}.`,
2191
2491
  );
2192
2492
  }
2493
+ if (isAtomicIfNullOperand(operandArg)) {
2494
+ const ifNullOperands = getAtomicExpressionArrayValues(
2495
+ getAtomicExpressionProperty(
2496
+ operandArg,
2497
+ '$ifNull',
2498
+ `${operationLabelArg} $expr operand`,
2499
+ ),
2500
+ 2,
2501
+ 2,
2502
+ `${operationLabelArg} $expr $ifNull`,
2503
+ );
2504
+ const root = normalizeFieldReference(ifNullOperands[0]);
2505
+ const fallback = normalizeNumericLiteral(ifNullOperands[1]);
2506
+ return {
2507
+ // A field that is absent or stored as null takes the literal; any other
2508
+ // stored value flows into the guarded comparison unchanged, so a
2509
+ // non-numeric value still fails closed.
2510
+ mongoExpression: { $ifNull: [`$${root}`, fallback] },
2511
+ referencedFields: new Set(),
2512
+ nullableFields: new Set([root]),
2513
+ };
2514
+ }
2193
2515
  const rawOperands = getAtomicExpressionArrayValues(
2194
2516
  getAtomicExpressionProperty(
2195
2517
  operandArg,
@@ -2212,6 +2534,11 @@ const normalizeAtomicNumericExpression = (
2212
2534
  ...entryArg.referencedFields,
2213
2535
  ]),
2214
2536
  ),
2537
+ nullableFields: new Set(
2538
+ normalizedOperands.flatMap((entryArg) => [
2539
+ ...entryArg.nullableFields,
2540
+ ]),
2541
+ ),
2215
2542
  };
2216
2543
  };
2217
2544
 
@@ -2231,7 +2558,14 @@ const normalizeAtomicNumericExpression = (
2231
2558
  ...left.referencedFields,
2232
2559
  ...right.referencedFields,
2233
2560
  ]);
2234
- const fieldGuards = [...referencedFields].map((fieldArg) => ({
2561
+ // A field read both directly and through $ifNull must still be numeric: the
2562
+ // direct read has no fallback, so the stricter guard wins.
2563
+ const nullableFields = new Set(
2564
+ [...left.nullableFields, ...right.nullableFields].filter(
2565
+ (fieldArg) => !referencedFields.has(fieldArg),
2566
+ ),
2567
+ );
2568
+ const requireStoredNumber = (fieldArg: string) => ({
2235
2569
  $cond: [
2236
2570
  { $isNumber: `$${fieldArg}` },
2237
2571
  {
@@ -2242,7 +2576,20 @@ const normalizeAtomicNumericExpression = (
2242
2576
  },
2243
2577
  false,
2244
2578
  ],
2245
- }));
2579
+ });
2580
+ const fieldGuards = [
2581
+ ...[...referencedFields].map((fieldArg) => requireStoredNumber(fieldArg)),
2582
+ // An absent or null field takes the $ifNull literal, which validation
2583
+ // already proved finite. Every other stored value must still be a finite
2584
+ // number, so a present non-numeric value fails closed exactly as before.
2585
+ ...[...nullableFields].map((fieldArg) => ({
2586
+ $cond: [
2587
+ { $in: [{ $type: `$${fieldArg}` }, ['missing', 'null']] },
2588
+ true,
2589
+ requireStoredNumber(fieldArg),
2590
+ ],
2591
+ })),
2592
+ ];
2246
2593
  const guardedComparison = {
2247
2594
  $let: {
2248
2595
  vars: {
@@ -2313,8 +2660,10 @@ const normalizeStrictFilter = (
2313
2660
  | 'Atomic update-many'
2314
2661
  | 'Atomic upsert-many'
2315
2662
  | 'Atomic find-one-and-update'
2663
+ | 'Atomic find-one-and-replace'
2316
2664
  | 'Atomic delete'
2317
- | 'Atomic delete-many',
2665
+ | 'Atomic delete-many'
2666
+ | 'Fenced save',
2318
2667
  globallyConstrainingKeySetsArg?: ReadonlyArray<ReadonlySet<string>>,
2319
2668
  ): plugins.mongodb.Filter<plugins.mongodb.Document> => {
2320
2669
  if (
@@ -2377,7 +2726,19 @@ const normalizeStrictFilter = (
2377
2726
  assertOnlyDataProperties(entry, `${labelArg} entry`, false);
2378
2727
  }
2379
2728
  };
2380
- const conditionIsEqualityAnchor = (valueArg: unknown): boolean => {
2729
+ /**
2730
+ * Plural intent is explicit in the plural method names, so an explicitly
2731
+ * enumerated, non-empty `$in` over equality scalars anchors those filters as
2732
+ * well. A regular expression is a pattern rather than an enumerated value, so
2733
+ * it never anchors. The singular operations keep requiring an equality that
2734
+ * can address exactly one document, and the unique-index check below never
2735
+ * accepts `$in`.
2736
+ */
2737
+ const pluralEqualityAnchors =
2738
+ operationLabelArg === 'Atomic update-many'
2739
+ || operationLabelArg === 'Atomic delete-many'
2740
+ || operationLabelArg === 'Atomic upsert-many';
2741
+ const valueIsEqualityScalar = (valueArg: unknown): boolean => {
2381
2742
  if (Array.isArray(valueArg)) {
2382
2743
  return false;
2383
2744
  }
@@ -2394,6 +2755,24 @@ const normalizeStrictFilter = (
2394
2755
  `${operationLabelArg} filters may not contain Proxy values.`,
2395
2756
  );
2396
2757
  }
2758
+ return isAtomicMongoScalar(valueArg);
2759
+ };
2760
+ const conditionIsEqualityAnchor = (
2761
+ valueArg: unknown,
2762
+ allowBoundedInArg: boolean = pluralEqualityAnchors,
2763
+ ): boolean => {
2764
+ if (Array.isArray(valueArg)) {
2765
+ return false;
2766
+ }
2767
+ if (typeof valueArg !== 'object' || valueArg === null) {
2768
+ return valueIsEqualityScalar(valueArg);
2769
+ }
2770
+ if (plugins.nodeUtil.types.isProxy(valueArg)) {
2771
+ throw new SmartdataPersistenceError(
2772
+ 'invalid_argument',
2773
+ `${operationLabelArg} filters may not contain Proxy values.`,
2774
+ );
2775
+ }
2397
2776
  if (isAtomicMongoScalar(valueArg)) {
2398
2777
  return true;
2399
2778
  }
@@ -2406,27 +2785,30 @@ const normalizeStrictFilter = (
2406
2785
  false,
2407
2786
  );
2408
2787
  const entries = Object.entries(valueArg);
2409
- if (entries.length !== 1 || entries[0][0] !== '$eq') {
2788
+ if (entries.length !== 1) {
2410
2789
  return false;
2411
2790
  }
2412
- const operand = entries[0][1];
2413
- if (Array.isArray(operand)) {
2414
- return false;
2791
+ const [operator, operand] = entries[0];
2792
+ if (operator === '$eq') {
2793
+ return valueIsEqualityScalar(operand);
2415
2794
  }
2416
- if (typeof operand !== 'object' || operand === null) {
2795
+ if (operator === '$in' && allowBoundedInArg) {
2796
+ // An empty or exotic array is refused by the condition normalizer; it
2797
+ // never becomes an anchor here either. A regular expression entry states
2798
+ // a pattern instead of enumerating documents, so it does not bound the
2799
+ // write and the filter stays unanchored.
2417
2800
  return (
2418
- operand !== undefined
2419
- && typeof operand !== 'function'
2420
- && typeof operand !== 'symbol'
2421
- );
2422
- }
2423
- if (plugins.nodeUtil.types.isProxy(operand)) {
2424
- throw new SmartdataPersistenceError(
2425
- 'invalid_argument',
2426
- `${operationLabelArg} filters may not contain Proxy values.`,
2801
+ Array.isArray(operand)
2802
+ && !plugins.nodeUtil.types.isProxy(operand)
2803
+ && Object.getPrototypeOf(operand) === Array.prototype
2804
+ && operand.length > 0
2805
+ && operand.every(
2806
+ (entryArg) =>
2807
+ !(entryArg instanceof RegExp) && valueIsEqualityScalar(entryArg),
2808
+ )
2427
2809
  );
2428
2810
  }
2429
- return isAtomicMongoScalar(operand);
2811
+ return false;
2430
2812
  };
2431
2813
  const filterHasEqualityAnchor = (
2432
2814
  filterObjectArg: Record<string, unknown>,
@@ -2486,8 +2868,10 @@ const normalizeStrictFilter = (
2486
2868
  continue;
2487
2869
  }
2488
2870
  // $or branches are deliberately excluded: an equality inside one $or
2489
- // branch does not globally constrain the matched document.
2490
- if (!key.startsWith('$') && conditionIsEqualityAnchor(value)) {
2871
+ // branch does not globally constrain the matched document. A bounded
2872
+ // `$in` is excluded for the same reason: it selects a set, so it never
2873
+ // pins a unique index to one document.
2874
+ if (!key.startsWith('$') && conditionIsEqualityAnchor(value, false)) {
2491
2875
  anchoredKeysArg.add(key);
2492
2876
  }
2493
2877
  }
@@ -3973,6 +4357,137 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3973
4357
  } as TSmartdataAtomicFindOneAndUpdateResult<T, TReturnDocument>;
3974
4358
  }
3975
4359
 
4360
+ /**
4361
+ * Atomically replaces one ordinary model document with a new one, including
4362
+ * its declared identity.
4363
+ *
4364
+ * This is the one operation that moves an `@unI()` identity: the matched
4365
+ * document ceases to exist and the replacement takes its `_id` slot in a
4366
+ * single compare-and-swap, which is what a model whose identity is the
4367
+ * additional-authenticated-data of an encrypted field needs when it rotates.
4368
+ * Every other write keeps identities immutable.
4369
+ *
4370
+ * `replacementArg` is a newly constructed instance that was never stored.
4371
+ * Its `createSavableObject()` body is written whole, so a field the matched
4372
+ * document carried and the replacement does not is gone afterwards — replace
4373
+ * semantics, not `$set` — and atomic-only fields are seeded from the
4374
+ * instance exactly as `insert()` seeds them. The body never carries an
4375
+ * `_id`: keeping the matched document's primary key is the point of the
4376
+ * operation, so a model whose declared identity *is* the `_id`
4377
+ * (`identityAsDocumentId`) cannot rotate and is refused before any write.
4378
+ *
4379
+ * The filter follows the singular rule of `atomicUpdate()` and
4380
+ * `atomicFindOneAndUpdate()`: declared paths and at least one top-level
4381
+ * equality anchor. It does not have to pin a unique index — the caller
4382
+ * usually searches by the owning field precisely because it does not know
4383
+ * which identity is stored — so a filter matching several documents replaces
4384
+ * one of them, as with every singular atomic operation.
4385
+ *
4386
+ * Like `insert()` and the other static atomic operations, this one runs no
4387
+ * instance hooks; `beforeSave()`-style normalization belongs before the
4388
+ * call. On a match the replacement becomes a stored instance, exactly as
4389
+ * `insert()` marks it, and the returned instance is hydrated from the image
4390
+ * named by `returnDocument`. Without a match nothing is written, `null` is
4391
+ * returned, and the replacement stays untouched.
4392
+ */
4393
+ public static async atomicFindOneAndReplace<T>(
4394
+ this: plugins.tsclass.typeFest.Class<T>,
4395
+ filterArg: TSmartdataAtomicFilter<T>,
4396
+ replacementArg: T,
4397
+ opts?: ISmartdataAtomicFindOneAndReplaceOptions,
4398
+ ): Promise<T | null> {
4399
+ if (getOrdinaryPersistencePolicy(this)) {
4400
+ throw new SmartdataPersistenceError('unsupported_operation',
4401
+ 'Validated ordinary models require transactional postimage updates.');
4402
+ }
4403
+ if ((this as any)[exactPersistencePolicySymbol]) {
4404
+ throw new SmartdataPersistenceError(
4405
+ 'unsupported_operation',
4406
+ 'atomicFindOneAndReplace is unavailable for exact-persistence models.',
4407
+ );
4408
+ }
4409
+ const normalizedOptions = normalizeAtomicFindOneAndReplaceOptions(opts);
4410
+ if (!(replacementArg instanceof this)) {
4411
+ throw new SmartdataPersistenceError(
4412
+ 'invalid_argument',
4413
+ `Atomic find-one-and-replace requires a replacement instance of "${this.name}".`,
4414
+ );
4415
+ }
4416
+ const replacement = replacementArg as T & SmartDataDbDoc<any, any>;
4417
+ if (replacement.creationStatus !== 'new') {
4418
+ // A stored instance already owns a document. Writing its body into a
4419
+ // second one would leave two documents claiming one identity until the
4420
+ // unique index refuses the loser.
4421
+ throw new SmartdataPersistenceError(
4422
+ 'invalid_argument',
4423
+ 'Atomic find-one-and-replace requires a newly constructed replacement; a stored instance already owns a document.',
4424
+ );
4425
+ }
4426
+ const identityDocumentIdField = (this as any).getIdentityDocumentIdField() as
4427
+ | string
4428
+ | undefined;
4429
+ if (identityDocumentIdField) {
4430
+ throw new SmartdataPersistenceError(
4431
+ 'invalid_argument',
4432
+ `Identity field "${identityDocumentIdField}" owns the document _id, which a replacement cannot move; rotate such an identity by inserting the new document and deleting the old one.`,
4433
+ );
4434
+ }
4435
+ assertUsableIdentityValues(
4436
+ replacement as unknown as Record<string, unknown>,
4437
+ (this.prototype as { uniqueIndexes?: string[] }).uniqueIndexes || [],
4438
+ (this.prototype as {
4439
+ identityValueTypes?: Record<string, TSmartdataIdentityValueType>;
4440
+ }).identityValueTypes,
4441
+ );
4442
+ const normalizedFilter = normalizeStrictFilter(
4443
+ filterArg as Record<string, unknown>,
4444
+ (this as any).getDeclaredPersistedRoots() as Set<string>,
4445
+ (this as any).getDeclaredAtomicPaths() as Set<string>,
4446
+ (this as any).getDeclaredIdentityValueTypes() as Map<
4447
+ string,
4448
+ TSmartdataIdentityValueType
4449
+ >,
4450
+ (this as any).getDeclaredNumericRoots() as Set<string>,
4451
+ (this as any).getSerializedPersistedRoots() as Set<string>,
4452
+ 'Atomic find-one-and-replace',
4453
+ );
4454
+ // The replacement is a document of its own, so it carries the timestamps
4455
+ // an insert would give it rather than the matched document's.
4456
+ const previousCreatedAt = replacement._createdAt;
4457
+ const previousUpdatedAt = replacement._updatedAt;
4458
+ const now = new Date().toISOString();
4459
+ replacement._createdAt = now;
4460
+ replacement._updatedAt = now;
4461
+ const body = await replacement.createSavableObject() as Record<string, unknown>;
4462
+ if (Object.prototype.hasOwnProperty.call(body, '_id')) {
4463
+ throw new SmartdataPersistenceError(
4464
+ 'invalid_argument',
4465
+ 'Atomic find-one-and-replace cannot carry a stored document _id; the matched document keeps its own.',
4466
+ );
4467
+ }
4468
+ const collection: SmartdataCollection<T> = (this as any).collection;
4469
+ const result = await executeAtomicFindOneAndReplace(
4470
+ collection,
4471
+ normalizedFilter,
4472
+ body,
4473
+ {
4474
+ returnDocument: normalizedOptions.returnDocument,
4475
+ ...(normalizedOptions.session ? { session: normalizedOptions.session } : {}),
4476
+ },
4477
+ );
4478
+ if (result.status === 'not_matched') {
4479
+ replacement._createdAt = previousCreatedAt;
4480
+ replacement._updatedAt = previousUpdatedAt;
4481
+ return null;
4482
+ }
4483
+ if (!isOrdinarySessionInTransaction(normalizedOptions.session)) {
4484
+ // A transaction may still abort, so the instance counts as stored only
4485
+ // once the write is outside one, exactly as insert() decides it.
4486
+ replacement.creationStatus = 'db';
4487
+ }
4488
+ return (this as any).createInstanceFromMongoDbNativeDoc(result.document) as T;
4489
+ }
4490
+
3976
4491
  /**
3977
4492
  * Atomically deletes one ordinary model document using a strict selector.
3978
4493
  *
@@ -4643,6 +5158,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4643
5158
  return runWithOrdinarySmartdataSession(optionsArg.session, collection.smartdataDb, {
4644
5159
  ordinaryWrite: false,
4645
5160
  prepared: collection.isInitializedForCurrentDatabase(),
5161
+ collectionName: collection.collectionName,
4646
5162
  }, async (rawSessionArg) => {
4647
5163
  await collection.init();
4648
5164
  const rawCursor = collection.mongoDbCollection
@@ -4788,54 +5304,69 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4788
5304
  );
4789
5305
  }
4790
5306
  const collection: SmartdataCollection<T> = (this as any).collection;
4791
- await collection.init();
4792
- let rawCursor: plugins.mongodb.FindCursor<any> =
4793
- collection.mongoDbCollection.find((this as any).normalizeReadFilter(filterArg), {
4794
- projection,
4795
- session,
4796
- hint,
4797
- skip,
4798
- });
4799
- if (sort) {
4800
- rawCursor = rawCursor.sort(sort);
4801
- }
4802
- if (batchSize) {
4803
- rawCursor = rawCursor.batchSize(batchSize);
4804
- }
4805
- if (limit) {
4806
- rawCursor = rawCursor.limit(limit);
4807
- }
4808
- if (maxTimeMS) {
4809
- rawCursor = rawCursor.maxTimeMS(maxTimeMS);
4810
- }
4811
- if (modifier) {
4812
- const originalCursor = rawCursor;
4813
- let modifiedCursor: plugins.mongodb.FindCursor<any>;
4814
- try {
4815
- modifiedCursor = modifier(originalCursor);
4816
- } catch (modifierError) {
4817
- try {
4818
- await originalCursor.close();
4819
- } catch {
4820
- // Preserve the modifier error when best-effort cleanup also fails.
4821
- }
4822
- throw modifierError;
5307
+ // The cursor outlives this call, so an owned session stays leased until the
5308
+ // cursor is closed. Every failure below releases it before rethrowing.
5309
+ const lease = leaseOrdinarySmartdataSession(session, collection.smartdataDb, {
5310
+ prepared: collection.isInitializedForCurrentDatabase(),
5311
+ collectionName: collection.collectionName,
5312
+ });
5313
+ try {
5314
+ await collection.init();
5315
+ let rawCursor: plugins.mongodb.FindCursor<any> =
5316
+ collection.mongoDbCollection.find((this as any).normalizeReadFilter(filterArg), {
5317
+ projection,
5318
+ session: lease.rawSession,
5319
+ hint,
5320
+ skip,
5321
+ });
5322
+ if (sort) {
5323
+ rawCursor = rawCursor.sort(sort);
5324
+ }
5325
+ if (batchSize) {
5326
+ rawCursor = rawCursor.batchSize(batchSize);
4823
5327
  }
4824
- if (modifiedCursor !== originalCursor) {
5328
+ if (limit) {
5329
+ rawCursor = rawCursor.limit(limit);
5330
+ }
5331
+ if (maxTimeMS) {
5332
+ rawCursor = rawCursor.maxTimeMS(maxTimeMS);
5333
+ }
5334
+ if (modifier) {
5335
+ const originalCursor = rawCursor;
5336
+ let modifiedCursor: plugins.mongodb.FindCursor<any>;
4825
5337
  try {
4826
- await originalCursor.close();
4827
- } catch (originalCursorCloseError) {
5338
+ modifiedCursor = modifier(originalCursor);
5339
+ } catch (modifierError) {
4828
5340
  try {
4829
- await modifiedCursor.close();
5341
+ await originalCursor.close();
4830
5342
  } catch {
4831
- // Preserve the original cursor's cleanup error.
5343
+ // Preserve the modifier error when best-effort cleanup also fails.
5344
+ }
5345
+ throw modifierError;
5346
+ }
5347
+ if (modifiedCursor !== originalCursor) {
5348
+ try {
5349
+ await originalCursor.close();
5350
+ } catch (originalCursorCloseError) {
5351
+ try {
5352
+ await modifiedCursor.close();
5353
+ } catch {
5354
+ // Preserve the original cursor's cleanup error.
5355
+ }
5356
+ throw originalCursorCloseError;
4832
5357
  }
4833
- throw originalCursorCloseError;
4834
5358
  }
5359
+ rawCursor = modifiedCursor;
4835
5360
  }
4836
- rawCursor = modifiedCursor;
5361
+ return new SmartdataDbCursor<T>(
5362
+ rawCursor,
5363
+ this as any as typeof SmartDataDbDoc,
5364
+ lease.release,
5365
+ );
5366
+ } catch (cursorCreationError) {
5367
+ lease.release();
5368
+ throw cursorCreationError;
4837
5369
  }
4838
- return new SmartdataDbCursor<T>(rawCursor, this as any as typeof SmartDataDbDoc);
4839
5370
  }
4840
5371
 
4841
5372
  /**
@@ -5279,6 +5810,162 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5279
5810
  return dbResult;
5280
5811
  }
5281
5812
 
5813
+ /**
5814
+ * Saves this instance only while the stored document still matches `fence`.
5815
+ *
5816
+ * The write is one `updateOne` that never upserts: the instance's identity
5817
+ * addresses the document, the fence adds the condition the caller depends on
5818
+ * — a status, a revision, a lease owner — and the update applies only if the
5819
+ * server still sees that condition. `matchedCount === 0` means another writer
5820
+ * moved the document first; nothing was written and this instance is exactly
5821
+ * as it was before the call, so the caller can reload and decide.
5822
+ *
5823
+ * The stored document receives the same fields an ordinary `save()` writes,
5824
+ * minus the ones no `save()` may write: declared identities are immutable and
5825
+ * already pinned by the filter, and atomic-only fields belong to concurrent
5826
+ * atomic writers — a fenced save never creates a document, so it has nothing
5827
+ * to seed either. Declared fields this instance holds as `undefined` follow
5828
+ * `absentDeclaredFields`.
5829
+ *
5830
+ * `beforeSave()` runs before the write, as with `save()`; `afterSave()` runs
5831
+ * only when the fence matched, because a fence that missed saved nothing.
5832
+ */
5833
+ public async saveIf(
5834
+ fenceArg: TSmartdataAtomicFilter<T>,
5835
+ optsArg?: ISmartdataSaveIfOptions,
5836
+ ): Promise<ISmartdataSaveIfResult> {
5837
+ if (getOrdinaryPersistencePolicy(this.constructor)) {
5838
+ throw new SmartdataPersistenceError('unsupported_operation',
5839
+ 'Validated ordinary models use insert() or transactional postimage updates.');
5840
+ }
5841
+ if ((this.constructor as any)[exactPersistencePolicySymbol]) {
5842
+ throw new SmartdataPersistenceError('unsupported_operation',
5843
+ 'saveIf() is unavailable for exact-persistence models.');
5844
+ }
5845
+ const options = normalizeSaveIfOptions(optsArg);
5846
+ if (this.creationStatus !== 'db') {
5847
+ // A fence states what the stored document must still look like. An
5848
+ // instance that was never stored has no such document, so the call is a
5849
+ // programming error rather than a miss the caller could retry.
5850
+ throw new SmartdataPersistenceError('invalid_argument',
5851
+ 'saveIf() requires an instance read from the database; use save() to create a document.');
5852
+ }
5853
+ const identityFields = this.uniqueIndexes || [];
5854
+ if (identityFields.length === 0) {
5855
+ throw new SmartdataPersistenceError('invalid_configuration',
5856
+ 'saveIf() requires declared identity fields; without them the fence would address an arbitrary document.');
5857
+ }
5858
+ if (
5859
+ typeof fenceArg !== 'object'
5860
+ || fenceArg === null
5861
+ || Array.isArray(fenceArg)
5862
+ || plugins.nodeUtil.types.isProxy(fenceArg)
5863
+ || !isPlainObject(fenceArg)
5864
+ || Object.keys(fenceArg).length === 0
5865
+ ) {
5866
+ throw new SmartdataPersistenceError('invalid_argument',
5867
+ 'saveIf() requires a non-empty fence object; an unfenced save is save().');
5868
+ }
5869
+ assertOnlyDataProperties(fenceArg, 'Fenced save fence', false);
5870
+ for (const identityField of identityFields) {
5871
+ if (Object.prototype.hasOwnProperty.call(fenceArg, identityField)) {
5872
+ // The instance's own identity addresses the document. A second
5873
+ // top-level condition on it would silently redirect the write.
5874
+ throw new SmartdataPersistenceError('invalid_argument',
5875
+ `Fenced save may not fence on identity field "${identityField}"; the instance already addresses its document.`);
5876
+ }
5877
+ }
5878
+ const modelConstructor: any = this.constructor;
5879
+ // Numeric and atomic-only declarations live on the bound collection, so a
5880
+ // @managed instance answers for the collection it was read through.
5881
+ const collection = this.getCollectionSafe();
5882
+ const modelSchema = collection.getBoundModelSchema();
5883
+ const identityFilter: Record<string, unknown> = {};
5884
+ for (const identityField of identityFields) {
5885
+ identityFilter[identityField] = (this as any)[identityField];
5886
+ }
5887
+ const normalizedFilter = normalizeStrictFilter(
5888
+ { ...identityFilter, ...(fenceArg as Record<string, unknown>) },
5889
+ modelConstructor.getDeclaredPersistedRoots() as Set<string>,
5890
+ modelConstructor.getDeclaredAtomicPaths() as Set<string>,
5891
+ modelConstructor.getDeclaredIdentityValueTypes() as Map<
5892
+ string,
5893
+ TSmartdataIdentityValueType
5894
+ >,
5895
+ new Set(modelSchema?.numericFields || []),
5896
+ modelConstructor.getSerializedPersistedRoots() as Set<string>,
5897
+ 'Fenced save',
5898
+ );
5899
+ if (typeof (this as any).beforeSave === 'function') {
5900
+ await (this as any).beforeSave();
5901
+ }
5902
+ assertUsableIdentityValues(
5903
+ this as unknown as Record<string, unknown>,
5904
+ identityFields,
5905
+ this.identityValueTypes,
5906
+ );
5907
+ const previousUpdatedAt = this._updatedAt;
5908
+ this._updatedAt = new Date().toISOString();
5909
+ const saveableObject = await this.createSavableObject() as Record<string, unknown>;
5910
+ const atomicOnlyRoots = new Set(modelSchema?.atomicOnlyFields || []);
5911
+ const isWritableField = (fieldArg: string): boolean =>
5912
+ !identityFields.includes(fieldArg) && !atomicOnlyRoots.has(fieldArg);
5913
+ const setObject: Record<string, unknown> = {};
5914
+ for (const key of Object.keys(saveableObject)) {
5915
+ if (isWritableField(key)) {
5916
+ setObject[key] = saveableObject[key];
5917
+ }
5918
+ }
5919
+ const unsetObject: Record<string, unknown> = {};
5920
+ if (options.absentDeclaredFields === 'unset') {
5921
+ // createSavableObject() omits every field the instance holds as
5922
+ // undefined, so a declared field missing from it is exactly a field this
5923
+ // instance states as absent.
5924
+ const declaredSavableFields = new Set([
5925
+ ...(this.globalSaveableProperties || []),
5926
+ ...(this.saveableProperties || []),
5927
+ ]);
5928
+ for (const field of declaredSavableFields) {
5929
+ if (
5930
+ isWritableField(field)
5931
+ && !Object.prototype.hasOwnProperty.call(saveableObject, field)
5932
+ ) {
5933
+ unsetObject[field] = '';
5934
+ }
5935
+ }
5936
+ }
5937
+ const result = await executeAtomicUpdate(
5938
+ collection,
5939
+ mapIdentityFilterToDocumentId(
5940
+ normalizedFilter,
5941
+ getIdentityDocumentIdField(this.constructor),
5942
+ ),
5943
+ {
5944
+ $set: setObject,
5945
+ ...(Object.keys(unsetObject).length > 0 ? { $unset: unsetObject } : {}),
5946
+ },
5947
+ { session: options.session },
5948
+ );
5949
+ if (result.matchedCount === 0) {
5950
+ // Nothing was written, so the instance keeps the update timestamp the
5951
+ // stored document still carries.
5952
+ this._updatedAt = previousUpdatedAt;
5953
+ return {
5954
+ acknowledged: result.acknowledged,
5955
+ matchedCount: 0,
5956
+ modifiedCount: result.modifiedCount,
5957
+ };
5958
+ }
5959
+ if (typeof (this as any).afterSave === 'function') {
5960
+ await (this as any).afterSave();
5961
+ }
5962
+ return {
5963
+ acknowledged: result.acknowledged,
5964
+ matchedCount: result.matchedCount,
5965
+ modifiedCount: result.modifiedCount,
5966
+ };
5967
+ }
5968
+
5282
5969
  /**
5283
5970
  * deletes a document from the database (optionally within a transaction)
5284
5971
  */