@lossless.org/client 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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';
@@ -219,6 +222,20 @@ export interface SvDbOptions {
219
222
  * expression references.
220
223
  */
221
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;
222
239
  }
223
240
 
224
241
  /**
@@ -231,6 +248,29 @@ export function svDb(options?: SvDbOptions) {
231
248
  'svDb numeric must be a boolean.',
232
249
  );
233
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
+ }
234
274
  const storedOptions = options
235
275
  ? {
236
276
  ...options,
@@ -505,13 +545,24 @@ export function index(options?: IIndexOptions) {
505
545
  // Helper type to extract element type from arrays or return T itself
506
546
  type ElementOf<T> = T extends ReadonlyArray<infer U> ? U : T;
507
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
+
508
559
  // Type for $in/$nin values - arrays of the element type
509
- type InValues<T> = ReadonlyArray<ElementOf<T>>;
560
+ type InValues<T> = ReadonlyArray<ElementOf<T> | TSmartdataOptionalNullPredicate<T>>;
510
561
 
511
562
  // Type that allows MongoDB operators on leaf values while maintaining nested type safety
512
- export type MongoFilterCondition<T> = T | {
513
- $eq?: T;
514
- $ne?: T;
563
+ export type MongoFilterCondition<T> = T | TSmartdataOptionalNullPredicate<T> | {
564
+ $eq?: T | TSmartdataOptionalNullPredicate<T>;
565
+ $ne?: T | TSmartdataOptionalNullPredicate<T>;
515
566
  $gt?: T;
516
567
  $gte?: T;
517
568
  $lt?: T;
@@ -575,7 +626,12 @@ export interface ISmartdataCursorOptions<T> {
575
626
  /** Model-declared index name, or MongoDB's built-in `_id_` index. */
576
627
  hint?: string;
577
628
  maxTimeMS?: number;
578
- 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;
579
635
  /**
580
636
  * @deprecated Prefer the structured cursor options. The modifier runs last
581
637
  * for backward compatibility.
@@ -661,9 +717,20 @@ type TSmartdataAtomicMonotonic<T> =
661
717
  export type TSmartdataAtomicNumericFieldReference<T> =
662
718
  `$${TSmartdataNumericDocumentPath<T>}`;
663
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
+
664
730
  export type TSmartdataAtomicNumericExpressionOperand<T> =
665
731
  | number
666
732
  | TSmartdataAtomicNumericFieldReference<T>
733
+ | ISmartdataAtomicNumericIfNull<T>
667
734
  | {
668
735
  $add: readonly [
669
736
  TSmartdataAtomicNumericExpressionOperand<T>,
@@ -817,15 +884,24 @@ type TSmartdataAtomicInValue<TValue> =
817
884
 
818
885
  export type TSmartdataAtomicFilterCondition<TValue> =
819
886
  | TSmartdataAtomicDirectEquality<TValue>
887
+ | TSmartdataOptionalNullPredicate<TValue>
820
888
  | {
821
- $eq?: TValue;
822
- $ne?: TSmartdataAtomicDirectEquality<TValue>;
889
+ $eq?: TValue | TSmartdataOptionalNullPredicate<TValue>;
890
+ $ne?:
891
+ | TSmartdataAtomicDirectEquality<TValue>
892
+ | TSmartdataOptionalNullPredicate<TValue>;
823
893
  $gt?: TSmartdataAtomicDirectEquality<TValue>;
824
894
  $gte?: TSmartdataAtomicDirectEquality<TValue>;
825
895
  $lt?: TSmartdataAtomicDirectEquality<TValue>;
826
896
  $lte?: TSmartdataAtomicDirectEquality<TValue>;
827
- $in?: ReadonlyArray<TSmartdataAtomicInValue<TValue>>;
828
- $nin?: ReadonlyArray<TSmartdataAtomicInValue<TValue>>;
897
+ $in?: ReadonlyArray<
898
+ | TSmartdataAtomicInValue<TValue>
899
+ | TSmartdataOptionalNullPredicate<TValue>
900
+ >;
901
+ $nin?: ReadonlyArray<
902
+ | TSmartdataAtomicInValue<TValue>
903
+ | TSmartdataOptionalNullPredicate<TValue>
904
+ >;
829
905
  $exists?: boolean;
830
906
  $type?: plugins.mongodb.BSONType | plugins.mongodb.BSONTypeAlias;
831
907
  $regex?: string | RegExp;
@@ -906,6 +982,15 @@ export type TSmartdataAtomicFindOneAndUpdateResult<
906
982
  }
907
983
  | { status: 'not_matched'; document: null };
908
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
+
909
994
  export interface ISmartdataAtomicDeleteOptions {
910
995
  session?: TSmartdataOrdinarySession;
911
996
  /** Client-side operation deadline, including server selection and pool waits. */
@@ -964,6 +1049,87 @@ export interface ISmartdataAtomicUpsertManyResult {
964
1049
  upsertedCount: number;
965
1050
  }
966
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
+
967
1133
  const normalizeAtomicFindOneAndUpdateOptions = <
968
1134
  TReturnDocument extends TSmartdataAtomicReturnDocument,
969
1135
  >(
@@ -1041,6 +1207,63 @@ const normalizeAtomicFindOneAndUpdateOptions = <
1041
1207
  };
1042
1208
  };
1043
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
+
1044
1267
  const normalizeAtomicExpressionUpdateOptions = (
1045
1268
  optionsArg: ISmartdataAtomicUpdateOptions | undefined,
1046
1269
  ): ISmartdataAtomicUpdateOptions | undefined => {
@@ -2052,7 +2275,7 @@ const atomicNumericExpressionMaximumLeafOperands = 64;
2052
2275
 
2053
2276
  const getAtomicExpressionProperty = (
2054
2277
  valueArg: unknown,
2055
- expectedKeyArg: '$lte' | '$add',
2278
+ expectedKeyArg: '$lte' | '$add' | '$ifNull',
2056
2279
  labelArg: string,
2057
2280
  ): unknown => {
2058
2281
  if (
@@ -2148,9 +2371,27 @@ const getAtomicExpressionArrayValues = (
2148
2371
 
2149
2372
  interface INormalizedAtomicNumericExpressionOperand {
2150
2373
  mongoExpression: unknown;
2374
+ /** Fields the expression reads directly and that must be numeric to match. */
2151
2375
  referencedFields: Set<string>;
2376
+ /** Fields read through `$ifNull`, where absent or null takes the literal. */
2377
+ nullableFields: Set<string>;
2152
2378
  }
2153
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
+
2154
2395
  const normalizeAtomicNumericExpression = (
2155
2396
  expressionArg: unknown,
2156
2397
  declaredRootsArg: Set<string>,
@@ -2159,77 +2400,88 @@ const normalizeAtomicNumericExpression = (
2159
2400
  operationLabelArg: string,
2160
2401
  ): plugins.mongodb.Document => {
2161
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
+ };
2162
2468
  const normalizeOperand = (
2163
2469
  operandArg: unknown,
2164
2470
  depthArg: number,
2165
2471
  ): INormalizedAtomicNumericExpressionOperand => {
2166
2472
  if (typeof operandArg === 'number') {
2167
- leafOperandCount++;
2168
- if (
2169
- !Number.isFinite(operandArg)
2170
- || leafOperandCount > atomicNumericExpressionMaximumLeafOperands
2171
- ) {
2172
- throw new SmartdataPersistenceError(
2173
- 'invalid_argument',
2174
- `${operationLabelArg} $expr requires finite numeric literals and no more than ${atomicNumericExpressionMaximumLeafOperands} leaf operands.`,
2175
- );
2176
- }
2177
2473
  return {
2178
- mongoExpression: operandArg,
2474
+ mongoExpression: normalizeNumericLiteral(operandArg),
2179
2475
  referencedFields: new Set(),
2476
+ nullableFields: new Set(),
2180
2477
  };
2181
2478
  }
2182
2479
  if (typeof operandArg === 'string') {
2183
- leafOperandCount++;
2184
- if (leafOperandCount > atomicNumericExpressionMaximumLeafOperands) {
2185
- throw new SmartdataPersistenceError(
2186
- 'invalid_argument',
2187
- `${operationLabelArg} $expr may not exceed ${atomicNumericExpressionMaximumLeafOperands} leaf operands.`,
2188
- );
2189
- }
2190
- if (
2191
- !operandArg.startsWith('$')
2192
- || operandArg.startsWith('$$')
2193
- || operandArg.length < 2
2194
- ) {
2195
- throw new SmartdataPersistenceError(
2196
- 'invalid_argument',
2197
- `${operationLabelArg} $expr field references must use exactly one leading $.`,
2198
- );
2199
- }
2200
- const field = operandArg.slice(1);
2201
- if (field === 'toBSON' || field === '_bsontype') {
2202
- throw new SmartdataPersistenceError(
2203
- 'invalid_argument',
2204
- `${operationLabelArg} $expr field "${field}" is reserved.`,
2205
- );
2206
- }
2207
- const root = requireDeclaredPath(
2208
- field,
2209
- declaredRootsArg,
2210
- `${operationLabelArg} $expr`,
2211
- );
2212
- if (field !== root) {
2213
- throw new SmartdataPersistenceError(
2214
- 'invalid_argument',
2215
- `${operationLabelArg} $expr may reference only declared top-level fields.`,
2216
- );
2217
- }
2218
- if (!numericRootsArg.has(root)) {
2219
- throw new SmartdataPersistenceError(
2220
- 'invalid_argument',
2221
- `${operationLabelArg} $expr field "${root}" is not declared numeric.`,
2222
- );
2223
- }
2224
- if (serializedRootsArg.has(root)) {
2225
- throw new SmartdataPersistenceError(
2226
- 'unsupported_operation',
2227
- `${operationLabelArg} $expr cannot reference serialized field "${root}".`,
2228
- );
2229
- }
2480
+ const root = normalizeFieldReference(operandArg);
2230
2481
  return {
2231
2482
  mongoExpression: `$${root}`,
2232
2483
  referencedFields: new Set([root]),
2484
+ nullableFields: new Set(),
2233
2485
  };
2234
2486
  }
2235
2487
  if (depthArg >= atomicNumericExpressionMaximumDepth) {
@@ -2238,6 +2490,28 @@ const normalizeAtomicNumericExpression = (
2238
2490
  `${operationLabelArg} $expr exceeds the maximum $add depth of ${atomicNumericExpressionMaximumDepth}.`,
2239
2491
  );
2240
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
+ }
2241
2515
  const rawOperands = getAtomicExpressionArrayValues(
2242
2516
  getAtomicExpressionProperty(
2243
2517
  operandArg,
@@ -2260,6 +2534,11 @@ const normalizeAtomicNumericExpression = (
2260
2534
  ...entryArg.referencedFields,
2261
2535
  ]),
2262
2536
  ),
2537
+ nullableFields: new Set(
2538
+ normalizedOperands.flatMap((entryArg) => [
2539
+ ...entryArg.nullableFields,
2540
+ ]),
2541
+ ),
2263
2542
  };
2264
2543
  };
2265
2544
 
@@ -2279,7 +2558,14 @@ const normalizeAtomicNumericExpression = (
2279
2558
  ...left.referencedFields,
2280
2559
  ...right.referencedFields,
2281
2560
  ]);
2282
- 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) => ({
2283
2569
  $cond: [
2284
2570
  { $isNumber: `$${fieldArg}` },
2285
2571
  {
@@ -2290,7 +2576,20 @@ const normalizeAtomicNumericExpression = (
2290
2576
  },
2291
2577
  false,
2292
2578
  ],
2293
- }));
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
+ ];
2294
2593
  const guardedComparison = {
2295
2594
  $let: {
2296
2595
  vars: {
@@ -2361,8 +2660,10 @@ const normalizeStrictFilter = (
2361
2660
  | 'Atomic update-many'
2362
2661
  | 'Atomic upsert-many'
2363
2662
  | 'Atomic find-one-and-update'
2663
+ | 'Atomic find-one-and-replace'
2364
2664
  | 'Atomic delete'
2365
- | 'Atomic delete-many',
2665
+ | 'Atomic delete-many'
2666
+ | 'Fenced save',
2366
2667
  globallyConstrainingKeySetsArg?: ReadonlyArray<ReadonlySet<string>>,
2367
2668
  ): plugins.mongodb.Filter<plugins.mongodb.Document> => {
2368
2669
  if (
@@ -2425,7 +2726,19 @@ const normalizeStrictFilter = (
2425
2726
  assertOnlyDataProperties(entry, `${labelArg} entry`, false);
2426
2727
  }
2427
2728
  };
2428
- 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 => {
2429
2742
  if (Array.isArray(valueArg)) {
2430
2743
  return false;
2431
2744
  }
@@ -2442,6 +2755,24 @@ const normalizeStrictFilter = (
2442
2755
  `${operationLabelArg} filters may not contain Proxy values.`,
2443
2756
  );
2444
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
+ }
2445
2776
  if (isAtomicMongoScalar(valueArg)) {
2446
2777
  return true;
2447
2778
  }
@@ -2454,27 +2785,30 @@ const normalizeStrictFilter = (
2454
2785
  false,
2455
2786
  );
2456
2787
  const entries = Object.entries(valueArg);
2457
- if (entries.length !== 1 || entries[0][0] !== '$eq') {
2788
+ if (entries.length !== 1) {
2458
2789
  return false;
2459
2790
  }
2460
- const operand = entries[0][1];
2461
- if (Array.isArray(operand)) {
2462
- return false;
2791
+ const [operator, operand] = entries[0];
2792
+ if (operator === '$eq') {
2793
+ return valueIsEqualityScalar(operand);
2463
2794
  }
2464
- 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.
2465
2800
  return (
2466
- operand !== undefined
2467
- && typeof operand !== 'function'
2468
- && typeof operand !== 'symbol'
2469
- );
2470
- }
2471
- if (plugins.nodeUtil.types.isProxy(operand)) {
2472
- throw new SmartdataPersistenceError(
2473
- 'invalid_argument',
2474
- `${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
+ )
2475
2809
  );
2476
2810
  }
2477
- return isAtomicMongoScalar(operand);
2811
+ return false;
2478
2812
  };
2479
2813
  const filterHasEqualityAnchor = (
2480
2814
  filterObjectArg: Record<string, unknown>,
@@ -2534,8 +2868,10 @@ const normalizeStrictFilter = (
2534
2868
  continue;
2535
2869
  }
2536
2870
  // $or branches are deliberately excluded: an equality inside one $or
2537
- // branch does not globally constrain the matched document.
2538
- 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)) {
2539
2875
  anchoredKeysArg.add(key);
2540
2876
  }
2541
2877
  }
@@ -4021,6 +4357,137 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4021
4357
  } as TSmartdataAtomicFindOneAndUpdateResult<T, TReturnDocument>;
4022
4358
  }
4023
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
+
4024
4491
  /**
4025
4492
  * Atomically deletes one ordinary model document using a strict selector.
4026
4493
  *
@@ -4691,6 +5158,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4691
5158
  return runWithOrdinarySmartdataSession(optionsArg.session, collection.smartdataDb, {
4692
5159
  ordinaryWrite: false,
4693
5160
  prepared: collection.isInitializedForCurrentDatabase(),
5161
+ collectionName: collection.collectionName,
4694
5162
  }, async (rawSessionArg) => {
4695
5163
  await collection.init();
4696
5164
  const rawCursor = collection.mongoDbCollection
@@ -4836,54 +5304,69 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4836
5304
  );
4837
5305
  }
4838
5306
  const collection: SmartdataCollection<T> = (this as any).collection;
4839
- await collection.init();
4840
- let rawCursor: plugins.mongodb.FindCursor<any> =
4841
- collection.mongoDbCollection.find((this as any).normalizeReadFilter(filterArg), {
4842
- projection,
4843
- session,
4844
- hint,
4845
- skip,
4846
- });
4847
- if (sort) {
4848
- rawCursor = rawCursor.sort(sort);
4849
- }
4850
- if (batchSize) {
4851
- rawCursor = rawCursor.batchSize(batchSize);
4852
- }
4853
- if (limit) {
4854
- rawCursor = rawCursor.limit(limit);
4855
- }
4856
- if (maxTimeMS) {
4857
- rawCursor = rawCursor.maxTimeMS(maxTimeMS);
4858
- }
4859
- if (modifier) {
4860
- const originalCursor = rawCursor;
4861
- let modifiedCursor: plugins.mongodb.FindCursor<any>;
4862
- try {
4863
- modifiedCursor = modifier(originalCursor);
4864
- } catch (modifierError) {
4865
- try {
4866
- await originalCursor.close();
4867
- } catch {
4868
- // Preserve the modifier error when best-effort cleanup also fails.
4869
- }
4870
- 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);
5327
+ }
5328
+ if (limit) {
5329
+ rawCursor = rawCursor.limit(limit);
4871
5330
  }
4872
- if (modifiedCursor !== originalCursor) {
5331
+ if (maxTimeMS) {
5332
+ rawCursor = rawCursor.maxTimeMS(maxTimeMS);
5333
+ }
5334
+ if (modifier) {
5335
+ const originalCursor = rawCursor;
5336
+ let modifiedCursor: plugins.mongodb.FindCursor<any>;
4873
5337
  try {
4874
- await originalCursor.close();
4875
- } catch (originalCursorCloseError) {
5338
+ modifiedCursor = modifier(originalCursor);
5339
+ } catch (modifierError) {
4876
5340
  try {
4877
- await modifiedCursor.close();
5341
+ await originalCursor.close();
4878
5342
  } catch {
4879
- // 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;
4880
5357
  }
4881
- throw originalCursorCloseError;
4882
5358
  }
5359
+ rawCursor = modifiedCursor;
4883
5360
  }
4884
- 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;
4885
5369
  }
4886
- return new SmartdataDbCursor<T>(rawCursor, this as any as typeof SmartDataDbDoc);
4887
5370
  }
4888
5371
 
4889
5372
  /**
@@ -5327,6 +5810,162 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5327
5810
  return dbResult;
5328
5811
  }
5329
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
+
5330
5969
  /**
5331
5970
  * deletes a document from the database (optionally within a transaction)
5332
5971
  */