@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.
- package/.smartconfig.json +1 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/nosqldb/classes.atomicdelete.js +3 -3
- package/dist_ts/nosqldb/classes.atomicfindoneandupdate.d.ts +20 -0
- package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +45 -2
- package/dist_ts/nosqldb/classes.atomicupdate.js +4 -4
- package/dist_ts/nosqldb/classes.collection.d.ts +37 -1
- package/dist_ts/nosqldb/classes.collection.js +235 -30
- package/dist_ts/nosqldb/classes.collectiontopology.d.ts +19 -0
- package/dist_ts/nosqldb/classes.collectiontopology.js +29 -3
- package/dist_ts/nosqldb/classes.cursor.d.ts +12 -1
- package/dist_ts/nosqldb/classes.cursor.js +20 -3
- package/dist_ts/nosqldb/classes.doc.d.ts +138 -11
- package/dist_ts/nosqldb/classes.doc.js +490 -91
- package/dist_ts/nosqldb/classes.exactpersistence.js +10 -2
- package/dist_ts/nosqldb/classes.namespaceinspection.js +2 -2
- package/dist_ts/nosqldb/classes.persistence.d.ts +6 -0
- package/dist_ts/nosqldb/classes.persistence.js +13 -1
- package/dist_ts/nosqldb/classes.session.d.ts +15 -1
- package/dist_ts/nosqldb/classes.session.js +32 -6
- package/package.json +4 -1
- package/readme.md +38 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.atomicdelete.ts +2 -4
- package/ts/nosqldb/classes.atomicfindoneandupdate.ts +69 -2
- package/ts/nosqldb/classes.atomicupdate.ts +3 -6
- package/ts/nosqldb/classes.collection.ts +372 -35
- package/ts/nosqldb/classes.collectiontopology.ts +56 -1
- package/ts/nosqldb/classes.cursor.ts +22 -2
- package/ts/nosqldb/classes.doc.ts +819 -132
- package/ts/nosqldb/classes.exactpersistence.ts +9 -1
- package/ts/nosqldb/classes.namespaceinspection.ts +1 -1
- package/ts/nosqldb/classes.persistence.ts +17 -0
- 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 {
|
|
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(
|
|
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
|
-
|
|
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?:
|
|
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<
|
|
780
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2788
|
+
if (entries.length !== 1) {
|
|
2410
2789
|
return false;
|
|
2411
2790
|
}
|
|
2412
|
-
const operand = entries[0]
|
|
2413
|
-
if (
|
|
2414
|
-
return
|
|
2791
|
+
const [operator, operand] = entries[0];
|
|
2792
|
+
if (operator === '$eq') {
|
|
2793
|
+
return valueIsEqualityScalar(operand);
|
|
2415
2794
|
}
|
|
2416
|
-
if (
|
|
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
|
|
2419
|
-
&&
|
|
2420
|
-
&&
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
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 (
|
|
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
|
-
|
|
4827
|
-
} catch (
|
|
5338
|
+
modifiedCursor = modifier(originalCursor);
|
|
5339
|
+
} catch (modifierError) {
|
|
4828
5340
|
try {
|
|
4829
|
-
await
|
|
5341
|
+
await originalCursor.close();
|
|
4830
5342
|
} catch {
|
|
4831
|
-
// Preserve the
|
|
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
|
-
|
|
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
|
*/
|