@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.
- 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 +152 -11
- package/dist_ts/nosqldb/classes.collectiontopology.d.ts +8 -0
- package/dist_ts/nosqldb/classes.collectiontopology.js +14 -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 +130 -11
- package/dist_ts/nosqldb/classes.doc.js +464 -89
- package/dist_ts/nosqldb/classes.namespaceinspection.js +2 -2
- 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 +37 -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 +240 -8
- package/ts/nosqldb/classes.collectiontopology.ts +31 -1
- package/ts/nosqldb/classes.cursor.ts +22 -2
- package/ts/nosqldb/classes.doc.ts +769 -130
- package/ts/nosqldb/classes.namespaceinspection.ts +1 -1
- 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';
|
|
@@ -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
|
-
|
|
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?:
|
|
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<
|
|
828
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2788
|
+
if (entries.length !== 1) {
|
|
2458
2789
|
return false;
|
|
2459
2790
|
}
|
|
2460
|
-
const operand = entries[0]
|
|
2461
|
-
if (
|
|
2462
|
-
return
|
|
2791
|
+
const [operator, operand] = entries[0];
|
|
2792
|
+
if (operator === '$eq') {
|
|
2793
|
+
return valueIsEqualityScalar(operand);
|
|
2463
2794
|
}
|
|
2464
|
-
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.
|
|
2465
2800
|
return (
|
|
2466
|
-
operand
|
|
2467
|
-
&&
|
|
2468
|
-
&&
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
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 (
|
|
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
|
-
|
|
4875
|
-
} catch (
|
|
5338
|
+
modifiedCursor = modifier(originalCursor);
|
|
5339
|
+
} catch (modifierError) {
|
|
4876
5340
|
try {
|
|
4877
|
-
await
|
|
5341
|
+
await originalCursor.close();
|
|
4878
5342
|
} catch {
|
|
4879
|
-
// 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;
|
|
4880
5357
|
}
|
|
4881
|
-
throw originalCursorCloseError;
|
|
4882
5358
|
}
|
|
5359
|
+
rawCursor = modifiedCursor;
|
|
4883
5360
|
}
|
|
4884
|
-
|
|
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
|
*/
|