@lossless.org/client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.smartconfig.json +37 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/classes.losslessorgclient.d.ts +32 -0
- package/dist_ts/classes.losslessorgclient.js +103 -0
- package/dist_ts/core/classes.error.d.ts +16 -0
- package/dist_ts/core/classes.error.js +29 -0
- package/dist_ts/core/classes.operation.d.ts +17 -0
- package/dist_ts/core/classes.operation.js +70 -0
- package/dist_ts/core/interfaces.d.ts +18 -0
- package/dist_ts/core/interfaces.js +2 -0
- package/dist_ts/index.d.ts +4 -0
- package/dist_ts/index.js +3 -0
- package/dist_ts/nosqldb/classes.atomicdelete.d.ts +26 -0
- package/dist_ts/nosqldb/classes.atomicdelete.js +44 -0
- package/dist_ts/nosqldb/classes.atomicfindoneandupdate.d.ts +27 -0
- package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +86 -0
- package/dist_ts/nosqldb/classes.atomicupdate.d.ts +26 -0
- package/dist_ts/nosqldb/classes.atomicupdate.js +55 -0
- package/dist_ts/nosqldb/classes.bootstraprecord.d.ts +14 -0
- package/dist_ts/nosqldb/classes.bootstraprecord.js +80 -0
- package/dist_ts/nosqldb/classes.bsonassertion.d.ts +2 -0
- package/dist_ts/nosqldb/classes.bsonassertion.js +29 -0
- package/dist_ts/nosqldb/classes.bsonobjectid.d.ts +2 -0
- package/dist_ts/nosqldb/classes.bsonobjectid.js +50 -0
- package/dist_ts/nosqldb/classes.bsonsnapshot.d.ts +3 -0
- package/dist_ts/nosqldb/classes.bsonsnapshot.js +126 -0
- package/dist_ts/nosqldb/classes.cached-document.d.ts +27 -0
- package/dist_ts/nosqldb/classes.cached-document.js +112 -0
- package/dist_ts/nosqldb/classes.collection.d.ts +302 -0
- package/dist_ts/nosqldb/classes.collection.js +1568 -0
- package/dist_ts/nosqldb/classes.collectionfactory.d.ts +30 -0
- package/dist_ts/nosqldb/classes.collectionfactory.js +65 -0
- package/dist_ts/nosqldb/classes.collectionlifecycle.d.ts +4 -0
- package/dist_ts/nosqldb/classes.collectionlifecycle.js +15 -0
- package/dist_ts/nosqldb/classes.collectionpreparation.d.ts +34 -0
- package/dist_ts/nosqldb/classes.collectionpreparation.js +134 -0
- package/dist_ts/nosqldb/classes.collectiontopology.d.ts +47 -0
- package/dist_ts/nosqldb/classes.collectiontopology.js +388 -0
- package/dist_ts/nosqldb/classes.connection.d.ts +18 -0
- package/dist_ts/nosqldb/classes.connection.js +23 -0
- package/dist_ts/nosqldb/classes.convenience.d.ts +1 -0
- package/dist_ts/nosqldb/classes.convenience.js +5 -0
- package/dist_ts/nosqldb/classes.cursor.d.ts +15 -0
- package/dist_ts/nosqldb/classes.cursor.js +82 -0
- package/dist_ts/nosqldb/classes.cursoroperation.d.ts +7 -0
- package/dist_ts/nosqldb/classes.cursoroperation.js +36 -0
- package/dist_ts/nosqldb/classes.db.d.ts +98 -0
- package/dist_ts/nosqldb/classes.db.js +434 -0
- package/dist_ts/nosqldb/classes.distributedcoordinator.d.ts +52 -0
- package/dist_ts/nosqldb/classes.distributedcoordinator.js +314 -0
- package/dist_ts/nosqldb/classes.doc.d.ts +722 -0
- package/dist_ts/nosqldb/classes.doc.js +2976 -0
- package/dist_ts/nosqldb/classes.easystore.d.ts +42 -0
- package/dist_ts/nosqldb/classes.easystore.js +199 -0
- package/dist_ts/nosqldb/classes.exactpersistence.d.ts +141 -0
- package/dist_ts/nosqldb/classes.exactpersistence.js +1110 -0
- package/dist_ts/nosqldb/classes.lucene.adapter.d.ts +173 -0
- package/dist_ts/nosqldb/classes.lucene.adapter.js +623 -0
- package/dist_ts/nosqldb/classes.mongoadministration.d.ts +73 -0
- package/dist_ts/nosqldb/classes.mongoadministration.js +497 -0
- package/dist_ts/nosqldb/classes.namespaceinspection.d.ts +42 -0
- package/dist_ts/nosqldb/classes.namespaceinspection.js +138 -0
- package/dist_ts/nosqldb/classes.operationbudget.d.ts +22 -0
- package/dist_ts/nosqldb/classes.operationbudget.js +65 -0
- package/dist_ts/nosqldb/classes.ordinarypersistence.d.ts +18 -0
- package/dist_ts/nosqldb/classes.ordinarypersistence.js +71 -0
- package/dist_ts/nosqldb/classes.persistence.d.ts +14 -0
- package/dist_ts/nosqldb/classes.persistence.js +34 -0
- package/dist_ts/nosqldb/classes.session.d.ts +28 -0
- package/dist_ts/nosqldb/classes.session.js +246 -0
- package/dist_ts/nosqldb/classes.storedinspection.d.ts +44 -0
- package/dist_ts/nosqldb/classes.storedinspection.js +100 -0
- package/dist_ts/nosqldb/classes.testdatabase.d.ts +56 -0
- package/dist_ts/nosqldb/classes.testdatabase.js +250 -0
- package/dist_ts/nosqldb/classes.watcher.d.ts +37 -0
- package/dist_ts/nosqldb/classes.watcher.js +98 -0
- package/dist_ts/nosqldb/index.d.ts +27 -0
- package/dist_ts/nosqldb/index.js +18 -0
- package/dist_ts/nosqldb/logging.d.ts +2 -0
- package/dist_ts/nosqldb/logging.js +3 -0
- package/dist_ts/nosqldb/plugins.d.ts +17 -0
- package/dist_ts/nosqldb/plugins.js +20 -0
- package/dist_ts/nosqldb/shim.d.ts +1 -0
- package/dist_ts/nosqldb/shim.js +8 -0
- package/dist_ts/nosqldb/testsupport.d.ts +3 -0
- package/dist_ts/nosqldb/testsupport.js +3 -0
- package/dist_ts/objectstorage/classes.bucket.d.ts +227 -0
- package/dist_ts/objectstorage/classes.bucket.js +939 -0
- package/dist_ts/objectstorage/classes.connection.d.ts +21 -0
- package/dist_ts/objectstorage/classes.connection.js +27 -0
- package/dist_ts/objectstorage/classes.directory.d.ts +124 -0
- package/dist_ts/objectstorage/classes.directory.js +335 -0
- package/dist_ts/objectstorage/classes.exactpathpurgeerror.d.ts +5 -0
- package/dist_ts/objectstorage/classes.exactpathpurgeerror.js +18 -0
- package/dist_ts/objectstorage/classes.exactuploaderror.d.ts +5 -0
- package/dist_ts/objectstorage/classes.exactuploaderror.js +19 -0
- package/dist_ts/objectstorage/classes.file.d.ts +91 -0
- package/dist_ts/objectstorage/classes.file.js +255 -0
- package/dist_ts/objectstorage/classes.listcursor.d.ts +41 -0
- package/dist_ts/objectstorage/classes.listcursor.js +67 -0
- package/dist_ts/objectstorage/classes.metadata.d.ts +57 -0
- package/dist_ts/objectstorage/classes.metadata.js +113 -0
- package/dist_ts/objectstorage/classes.smartbucket.d.ts +57 -0
- package/dist_ts/objectstorage/classes.smartbucket.js +357 -0
- package/dist_ts/objectstorage/classes.trash.d.ts +11 -0
- package/dist_ts/objectstorage/classes.trash.js +22 -0
- package/dist_ts/objectstorage/classes.watcher.d.ts +79 -0
- package/dist_ts/objectstorage/classes.watcher.js +249 -0
- package/dist_ts/objectstorage/helpers.d.ts +25 -0
- package/dist_ts/objectstorage/helpers.js +215 -0
- package/dist_ts/objectstorage/index.d.ts +13 -0
- package/dist_ts/objectstorage/index.js +13 -0
- package/dist_ts/objectstorage/interfaces.d.ts +276 -0
- package/dist_ts/objectstorage/interfaces.js +2 -0
- package/dist_ts/objectstorage/internal.exactpathpurge.operations.d.ts +11 -0
- package/dist_ts/objectstorage/internal.exactpathpurge.operations.js +1069 -0
- package/dist_ts/objectstorage/internal.exactupload.capability.d.ts +40 -0
- package/dist_ts/objectstorage/internal.exactupload.capability.js +382 -0
- package/dist_ts/objectstorage/internal.exactupload.operations.d.ts +11 -0
- package/dist_ts/objectstorage/internal.exactupload.operations.js +1104 -0
- package/dist_ts/objectstorage/internal.multipart.planner.d.ts +8 -0
- package/dist_ts/objectstorage/internal.multipart.planner.js +63 -0
- package/dist_ts/objectstorage/internal.multipart.stream.d.ts +17 -0
- package/dist_ts/objectstorage/internal.multipart.stream.js +440 -0
- package/dist_ts/objectstorage/internal.multipart.types.d.ts +82 -0
- package/dist_ts/objectstorage/internal.multipart.types.js +2 -0
- package/dist_ts/objectstorage/plugins.d.ts +17 -0
- package/dist_ts/objectstorage/plugins.js +23 -0
- package/dist_ts/plugins.d.ts +4 -0
- package/dist_ts/plugins.js +5 -0
- package/dist_ts/sqldb/classes.clickhouseconnection.d.ts +39 -0
- package/dist_ts/sqldb/classes.clickhouseconnection.js +225 -0
- package/dist_ts/sqldb/classes.sqlconnection.d.ts +50 -0
- package/dist_ts/sqldb/classes.sqlconnection.js +376 -0
- package/dist_ts/sqldb/classes.sqltable.d.ts +29 -0
- package/dist_ts/sqldb/classes.sqltable.js +70 -0
- package/dist_ts/sqldb/clickhouse/classes.metricwriter.d.ts +27 -0
- package/dist_ts/sqldb/clickhouse/classes.metricwriter.js +89 -0
- package/dist_ts/sqldb/clickhouse/index.d.ts +9 -0
- package/dist_ts/sqldb/clickhouse/index.js +13 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.clickhousetable.d.ts +88 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.clickhousetable.js +304 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.httpclient.d.ts +28 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.httpclient.js +46 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.querybuilder.d.ts +43 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.querybuilder.js +155 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.resultset.d.ts +13 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.resultset.js +36 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.smartclickhouse.d.ts +49 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.smartclickhouse.js +106 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.timedatatable.d.ts +51 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.timedatatable.js +96 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.plugins.d.ts +5 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.plugins.js +6 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.types.d.ts +53 -0
- package/dist_ts/sqldb/clickhouse/smartclickhouse.types.js +41 -0
- package/dist_ts/sqldb/index.d.ts +6 -0
- package/dist_ts/sqldb/index.js +6 -0
- package/dist_ts/sqldb/interfaces.d.ts +53 -0
- package/dist_ts/sqldb/interfaces.js +19 -0
- package/dist_ts/sqldb/internal.sqlpool.d.ts +51 -0
- package/dist_ts/sqldb/internal.sqlpool.js +214 -0
- package/dist_ts/sqldb/plugins.clickhouse.d.ts +4 -0
- package/dist_ts/sqldb/plugins.clickhouse.js +5 -0
- package/dist_ts/sqldb/plugins.mariadb.d.ts +4 -0
- package/dist_ts/sqldb/plugins.mariadb.js +5 -0
- package/docs/source/smartbucket/license.md +19 -0
- package/docs/source/smartclickhouse/license.md +19 -0
- package/license.md +21 -0
- package/package.json +101 -0
- package/readme.md +157 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/classes.losslessorgclient.ts +104 -0
- package/ts/core/classes.error.ts +31 -0
- package/ts/core/classes.operation.ts +60 -0
- package/ts/core/interfaces.ts +24 -0
- package/ts/index.ts +4 -0
- package/ts/nosqldb/classes.atomicdelete.ts +81 -0
- package/ts/nosqldb/classes.atomicfindoneandupdate.ts +152 -0
- package/ts/nosqldb/classes.atomicupdate.ts +99 -0
- package/ts/nosqldb/classes.bootstraprecord.ts +107 -0
- package/ts/nosqldb/classes.bsonassertion.ts +25 -0
- package/ts/nosqldb/classes.bsonobjectid.ts +45 -0
- package/ts/nosqldb/classes.bsonsnapshot.ts +105 -0
- package/ts/nosqldb/classes.cached-document.ts +67 -0
- package/ts/nosqldb/classes.collection.ts +2399 -0
- package/ts/nosqldb/classes.collectionfactory.ts +87 -0
- package/ts/nosqldb/classes.collectionlifecycle.ts +24 -0
- package/ts/nosqldb/classes.collectionpreparation.ts +164 -0
- package/ts/nosqldb/classes.collectiontopology.ts +645 -0
- package/ts/nosqldb/classes.connection.ts +31 -0
- package/ts/nosqldb/classes.convenience.ts +5 -0
- package/ts/nosqldb/classes.cursor.ts +88 -0
- package/ts/nosqldb/classes.cursoroperation.ts +38 -0
- package/ts/nosqldb/classes.db.ts +637 -0
- package/ts/nosqldb/classes.distributedcoordinator.ts +305 -0
- package/ts/nosqldb/classes.doc.ts +4928 -0
- package/ts/nosqldb/classes.easystore.ts +154 -0
- package/ts/nosqldb/classes.exactpersistence.ts +1974 -0
- package/ts/nosqldb/classes.lucene.adapter.ts +780 -0
- package/ts/nosqldb/classes.mongoadministration.ts +867 -0
- package/ts/nosqldb/classes.namespaceinspection.ts +199 -0
- package/ts/nosqldb/classes.operationbudget.ts +75 -0
- package/ts/nosqldb/classes.ordinarypersistence.ts +84 -0
- package/ts/nosqldb/classes.persistence.ts +62 -0
- package/ts/nosqldb/classes.session.ts +389 -0
- package/ts/nosqldb/classes.storedinspection.ts +151 -0
- package/ts/nosqldb/classes.testdatabase.ts +272 -0
- package/ts/nosqldb/classes.watcher.ts +96 -0
- package/ts/nosqldb/index.ts +51 -0
- package/ts/nosqldb/logging.ts +3 -0
- package/ts/nosqldb/plugins.ts +37 -0
- package/ts/nosqldb/shim.ts +6 -0
- package/ts/nosqldb/testsupport.ts +13 -0
- package/ts/objectstorage/classes.bucket.ts +1114 -0
- package/ts/objectstorage/classes.connection.ts +36 -0
- package/ts/objectstorage/classes.directory.ts +425 -0
- package/ts/objectstorage/classes.exactpathpurgeerror.ts +24 -0
- package/ts/objectstorage/classes.exactuploaderror.ts +25 -0
- package/ts/objectstorage/classes.file.ts +303 -0
- package/ts/objectstorage/classes.listcursor.ts +89 -0
- package/ts/objectstorage/classes.metadata.ts +134 -0
- package/ts/objectstorage/classes.smartbucket.ts +445 -0
- package/ts/objectstorage/classes.trash.ts +30 -0
- package/ts/objectstorage/classes.watcher.ts +289 -0
- package/ts/objectstorage/helpers.ts +254 -0
- package/ts/objectstorage/index.ts +13 -0
- package/ts/objectstorage/interfaces.ts +389 -0
- package/ts/objectstorage/internal.exactpathpurge.operations.ts +1936 -0
- package/ts/objectstorage/internal.exactupload.capability.ts +580 -0
- package/ts/objectstorage/internal.exactupload.operations.ts +1715 -0
- package/ts/objectstorage/internal.multipart.planner.ts +90 -0
- package/ts/objectstorage/internal.multipart.stream.ts +606 -0
- package/ts/objectstorage/internal.multipart.types.ts +106 -0
- package/ts/objectstorage/plugins.ts +35 -0
- package/ts/plugins.ts +4 -0
- package/ts/sqldb/classes.clickhouseconnection.ts +189 -0
- package/ts/sqldb/classes.sqlconnection.ts +350 -0
- package/ts/sqldb/classes.sqltable.ts +70 -0
- package/ts/sqldb/clickhouse/classes.metricwriter.ts +72 -0
- package/ts/sqldb/clickhouse/index.ts +16 -0
- package/ts/sqldb/clickhouse/smartclickhouse.classes.clickhousetable.ts +372 -0
- package/ts/sqldb/clickhouse/smartclickhouse.classes.httpclient.ts +52 -0
- package/ts/sqldb/clickhouse/smartclickhouse.classes.querybuilder.ts +120 -0
- package/ts/sqldb/clickhouse/smartclickhouse.classes.resultset.ts +44 -0
- package/ts/sqldb/clickhouse/smartclickhouse.classes.smartclickhouse.ts +123 -0
- package/ts/sqldb/clickhouse/smartclickhouse.classes.timedatatable.ts +114 -0
- package/ts/sqldb/clickhouse/smartclickhouse.plugins.ts +5 -0
- package/ts/sqldb/clickhouse/smartclickhouse.types.ts +134 -0
- package/ts/sqldb/index.ts +6 -0
- package/ts/sqldb/interfaces.ts +61 -0
- package/ts/sqldb/internal.sqlpool.ts +179 -0
- package/ts/sqldb/plugins.clickhouse.ts +4 -0
- package/ts/sqldb/plugins.mariadb.ts +4 -0
|
@@ -0,0 +1,4928 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
|
|
3
|
+
import { SmartdataDb } from './classes.db.js';
|
|
4
|
+
import { logger } from './logging.js';
|
|
5
|
+
import { SmartdataDbCursor } from './classes.cursor.js';
|
|
6
|
+
import { runCursorOperation } from './classes.cursoroperation.js';
|
|
7
|
+
import {
|
|
8
|
+
insertBootstrapRecord, type ISmartdataBootstrapRecordOptions, type ISmartdataBootstrapRecordResult,
|
|
9
|
+
} from './classes.bootstraprecord.js';
|
|
10
|
+
import {
|
|
11
|
+
validateCollectionPreparationOptions, type ISmartdataCollectionPreparationOptions,
|
|
12
|
+
} from './classes.collectionpreparation.js';
|
|
13
|
+
import { getOrdinaryPersistencePolicy, validateOrdinaryStoredDocument } from './classes.ordinarypersistence.js';
|
|
14
|
+
import {
|
|
15
|
+
inspectStoredDocuments, inspectStoredDocumentEvidence,
|
|
16
|
+
type ISmartdataStoredInspectionOptions, type ISmartdataStoredInspection,
|
|
17
|
+
type ISmartdataStoredEvidenceInspection,
|
|
18
|
+
} from './classes.storedinspection.js';
|
|
19
|
+
import {
|
|
20
|
+
type IManager,
|
|
21
|
+
type ISmartdataIndexInfo,
|
|
22
|
+
SmartdataCollection,
|
|
23
|
+
} from './classes.collection.js';
|
|
24
|
+
import { SmartdataDbWatcher } from './classes.watcher.js';
|
|
25
|
+
import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
|
|
26
|
+
import { executeAtomicDelete, executeAtomicDeleteMany } from './classes.atomicdelete.js';
|
|
27
|
+
import { executeAtomicFindOneAndUpdate } from './classes.atomicfindoneandupdate.js';
|
|
28
|
+
import { executeAtomicUpdate, executeAtomicUpdateMany } from './classes.atomicupdate.js';
|
|
29
|
+
import {
|
|
30
|
+
SmartdataPersistenceError,
|
|
31
|
+
} from './classes.persistence.js';
|
|
32
|
+
import {
|
|
33
|
+
isOrdinarySessionInTransaction,
|
|
34
|
+
runWithOrdinarySmartdataSession,
|
|
35
|
+
type TSmartdataOrdinarySession,
|
|
36
|
+
} from './classes.session.js';
|
|
37
|
+
import {
|
|
38
|
+
getExpectedCollectionTopologyForModel,
|
|
39
|
+
inspectCollectionTopologyForModel,
|
|
40
|
+
type ISmartdataCollectionTopologyInspection,
|
|
41
|
+
type ISmartdataExpectedCollectionTopology,
|
|
42
|
+
} from './classes.collectiontopology.js';
|
|
43
|
+
/**
|
|
44
|
+
* Search options for `.search()`:
|
|
45
|
+
* - filter: additional MongoDB query to AND-merge
|
|
46
|
+
* - validate: post-fetch validator, return true to keep a doc
|
|
47
|
+
*/
|
|
48
|
+
export interface SearchOptions<T> {
|
|
49
|
+
/**
|
|
50
|
+
* Additional MongoDB filter to AND‐merge into the query
|
|
51
|
+
*/
|
|
52
|
+
filter?: Record<string, any>;
|
|
53
|
+
/**
|
|
54
|
+
* Post‐fetch validator; return true to keep each doc
|
|
55
|
+
*/
|
|
56
|
+
validate?: (doc: T) => Promise<boolean> | boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Optional MongoDB session for transactional operations
|
|
59
|
+
*/
|
|
60
|
+
session?: TSmartdataOrdinarySession;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type TDocCreation = 'db' | 'new' | 'mixed';
|
|
64
|
+
export type TSmartdataPrimaryId<T> = T extends { _id: infer TId }
|
|
65
|
+
? Extract<TId, string | plugins.mongodb.ObjectId>
|
|
66
|
+
: plugins.mongodb.ObjectId;
|
|
67
|
+
|
|
68
|
+
const exactPersistencePolicySymbol = Symbol.for(
|
|
69
|
+
'@push.rocks/smartdata.exactPersistencePolicy',
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Type for decorator metadata - extends TypeScript's built-in DecoratorMetadataObject
|
|
73
|
+
interface ISmartdataDecoratorMetadata extends DecoratorMetadataObject {
|
|
74
|
+
globalSaveableProperties?: string[];
|
|
75
|
+
saveableProperties?: string[];
|
|
76
|
+
uniqueIndexes?: string[];
|
|
77
|
+
identityValueTypes?: Record<string, TSmartdataIdentityValueType>;
|
|
78
|
+
regularIndexes?: Array<{field: string, options: IIndexOptions}>;
|
|
79
|
+
compoundIndexes?: Array<{
|
|
80
|
+
name: string;
|
|
81
|
+
key: Record<string, 1 | -1>;
|
|
82
|
+
options?: Omit<plugins.mongodb.CreateIndexesOptions, 'name'>;
|
|
83
|
+
}>;
|
|
84
|
+
searchableFields?: string[];
|
|
85
|
+
_svDbOptions?: Record<string, SvDbOptions>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type TSmartdataIdentityValueType =
|
|
89
|
+
| 'string'
|
|
90
|
+
| 'positiveSafeInteger';
|
|
91
|
+
|
|
92
|
+
export interface IUnIOptions {
|
|
93
|
+
/**
|
|
94
|
+
* Runtime value contract for this identity field. The default remains a
|
|
95
|
+
* non-empty string. Numeric identities require an explicit opt-in.
|
|
96
|
+
*/
|
|
97
|
+
valueType?: TSmartdataIdentityValueType;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Detects plain object literals only. Anything carrying a custom prototype
|
|
102
|
+
* (Date, ObjectId, Binary, Buffer, RegExp, Decimal128, class instances, ...) is
|
|
103
|
+
* deliberately excluded so that BSON types are never rebuilt or otherwise
|
|
104
|
+
* altered while stripping undefined values.
|
|
105
|
+
*/
|
|
106
|
+
const isPlainObject = (valueArg: unknown): valueArg is Record<string, unknown> => {
|
|
107
|
+
if (typeof valueArg !== 'object' || valueArg === null) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
const prototype = Object.getPrototypeOf(valueArg);
|
|
111
|
+
return prototype === Object.prototype || prototype === null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Recursively drops object properties whose value is `undefined` so MongoDB
|
|
116
|
+
* stores them as absent rather than as BSON null.
|
|
117
|
+
*
|
|
118
|
+
* Notes on the deliberate boundaries of this traversal:
|
|
119
|
+
* - Array positions are preserved: an `undefined` element stays in place and is
|
|
120
|
+
* serialized as null, exactly as `JSON.stringify` does. Removing it would
|
|
121
|
+
* shift every following index.
|
|
122
|
+
* - Only plain objects are recursed into, so BSON types keep their identity.
|
|
123
|
+
* - On a circular structure the offending value is returned untouched, letting
|
|
124
|
+
* BSON raise its usual "Cannot convert circular structure to BSON" error
|
|
125
|
+
* instead of overflowing the stack here.
|
|
126
|
+
*/
|
|
127
|
+
const stripUndefinedValues = <T>(valueArg: T, seenArg?: WeakSet<object>): T => {
|
|
128
|
+
if (typeof valueArg !== 'object' || valueArg === null) {
|
|
129
|
+
return valueArg;
|
|
130
|
+
}
|
|
131
|
+
const isArray = Array.isArray(valueArg);
|
|
132
|
+
if (!isArray && !isPlainObject(valueArg)) {
|
|
133
|
+
return valueArg;
|
|
134
|
+
}
|
|
135
|
+
const seen = seenArg ?? new WeakSet<object>();
|
|
136
|
+
if (seen.has(valueArg as object)) {
|
|
137
|
+
return valueArg;
|
|
138
|
+
}
|
|
139
|
+
seen.add(valueArg as object);
|
|
140
|
+
if (isArray) {
|
|
141
|
+
return (valueArg as unknown[]).map((entryArg) =>
|
|
142
|
+
entryArg === undefined ? null : stripUndefinedValues(entryArg, seen),
|
|
143
|
+
) as unknown as T;
|
|
144
|
+
}
|
|
145
|
+
const strippedObject: Record<string, unknown> = {};
|
|
146
|
+
for (const keyArg of Object.keys(valueArg as Record<string, unknown>)) {
|
|
147
|
+
const entry = (valueArg as Record<string, unknown>)[keyArg];
|
|
148
|
+
if (entry === undefined) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
strippedObject[keyArg] = stripUndefinedValues(entry, seen);
|
|
152
|
+
}
|
|
153
|
+
return strippedObject as unknown as T;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export function globalSvDb() {
|
|
157
|
+
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
|
158
|
+
if (context.kind !== 'field') {
|
|
159
|
+
throw new Error('globalSvDb can only decorate fields');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Store metadata at class level using Symbol.metadata
|
|
163
|
+
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
|
164
|
+
if (
|
|
165
|
+
!Object.prototype.hasOwnProperty.call(
|
|
166
|
+
metadata,
|
|
167
|
+
'globalSaveableProperties',
|
|
168
|
+
)
|
|
169
|
+
) {
|
|
170
|
+
metadata.globalSaveableProperties = [
|
|
171
|
+
...(metadata.globalSaveableProperties || []),
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
const propName = String(context.name);
|
|
175
|
+
if (!metadata.globalSaveableProperties!.includes(propName)) {
|
|
176
|
+
metadata.globalSaveableProperties!.push(propName);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Use addInitializer to ensure prototype arrays are set up once
|
|
180
|
+
context.addInitializer(function(this: any) {
|
|
181
|
+
const proto = this.constructor.prototype;
|
|
182
|
+
const metadata = this.constructor[Symbol.metadata];
|
|
183
|
+
|
|
184
|
+
if (metadata && metadata.globalSaveableProperties && !proto.globalSaveableProperties) {
|
|
185
|
+
// Initialize prototype array from metadata (runs once per class)
|
|
186
|
+
proto.globalSaveableProperties = [...metadata.globalSaveableProperties];
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Options for custom serialization/deserialization of a field.
|
|
194
|
+
*/
|
|
195
|
+
export interface SvDbOptions {
|
|
196
|
+
/** Function to serialize the field value before saving to DB */
|
|
197
|
+
serialize?: (value: any) => any;
|
|
198
|
+
/** Function to deserialize the field value after reading from DB */
|
|
199
|
+
deserialize?: (value: any) => any;
|
|
200
|
+
/**
|
|
201
|
+
* Explicit relative nested paths that atomic filters and updates may target
|
|
202
|
+
* below this persisted field.
|
|
203
|
+
*/
|
|
204
|
+
atomicPaths?: readonly string[];
|
|
205
|
+
/**
|
|
206
|
+
* Declares this persisted top-level field as numeric for strict atomic
|
|
207
|
+
* expression references.
|
|
208
|
+
*/
|
|
209
|
+
numeric?: boolean;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* saveable - saveable decorator to be used on class properties
|
|
214
|
+
*/
|
|
215
|
+
export function svDb(options?: SvDbOptions) {
|
|
216
|
+
if (options?.numeric !== undefined && typeof options.numeric !== 'boolean') {
|
|
217
|
+
throw new SmartdataPersistenceError(
|
|
218
|
+
'invalid_configuration',
|
|
219
|
+
'svDb numeric must be a boolean.',
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const storedOptions = options
|
|
223
|
+
? {
|
|
224
|
+
...options,
|
|
225
|
+
...(options.atomicPaths !== undefined
|
|
226
|
+
? {
|
|
227
|
+
atomicPaths: Array.isArray(options.atomicPaths)
|
|
228
|
+
? [...options.atomicPaths]
|
|
229
|
+
: options.atomicPaths,
|
|
230
|
+
}
|
|
231
|
+
: {}),
|
|
232
|
+
}
|
|
233
|
+
: undefined;
|
|
234
|
+
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
|
235
|
+
if (context.kind !== 'field') {
|
|
236
|
+
throw new Error('svDb can only decorate fields');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const propName = String(context.name);
|
|
240
|
+
|
|
241
|
+
// Store metadata at class level using Symbol.metadata
|
|
242
|
+
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
|
243
|
+
if (!Object.prototype.hasOwnProperty.call(metadata, 'saveableProperties')) {
|
|
244
|
+
metadata.saveableProperties = [...(metadata.saveableProperties || [])];
|
|
245
|
+
}
|
|
246
|
+
if (!metadata.saveableProperties!.includes(propName)) {
|
|
247
|
+
metadata.saveableProperties!.push(propName);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Store options in metadata
|
|
251
|
+
if (storedOptions) {
|
|
252
|
+
if (!Object.prototype.hasOwnProperty.call(metadata, '_svDbOptions')) {
|
|
253
|
+
metadata._svDbOptions = { ...(metadata._svDbOptions || {}) };
|
|
254
|
+
}
|
|
255
|
+
metadata._svDbOptions![propName] = storedOptions;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Use addInitializer to ensure prototype arrays are set up once
|
|
259
|
+
context.addInitializer(function(this: any) {
|
|
260
|
+
const proto = this.constructor.prototype;
|
|
261
|
+
const ctor = this.constructor;
|
|
262
|
+
const metadata = ctor[Symbol.metadata];
|
|
263
|
+
|
|
264
|
+
if (metadata && metadata.saveableProperties && !proto.saveableProperties) {
|
|
265
|
+
// Initialize prototype array from metadata (runs once per class)
|
|
266
|
+
proto.saveableProperties = [...metadata.saveableProperties];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Initialize svDbOptions from metadata
|
|
270
|
+
if (metadata && metadata._svDbOptions && !ctor._svDbOptions) {
|
|
271
|
+
ctor._svDbOptions = { ...metadata._svDbOptions };
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* searchable - marks a property as searchable with Lucene query syntax
|
|
279
|
+
*/
|
|
280
|
+
export function searchable() {
|
|
281
|
+
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
|
282
|
+
if (context.kind !== 'field') {
|
|
283
|
+
throw new Error('searchable can only decorate fields');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const propName = String(context.name);
|
|
287
|
+
|
|
288
|
+
// Store metadata at class level
|
|
289
|
+
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
|
290
|
+
if (!Object.prototype.hasOwnProperty.call(metadata, 'searchableFields')) {
|
|
291
|
+
metadata.searchableFields = [...(metadata.searchableFields || [])];
|
|
292
|
+
}
|
|
293
|
+
if (!metadata.searchableFields!.includes(propName)) {
|
|
294
|
+
metadata.searchableFields!.push(propName);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Use addInitializer to set up constructor property once
|
|
298
|
+
context.addInitializer(function(this: any) {
|
|
299
|
+
const ctor = this.constructor as any;
|
|
300
|
+
const metadata = ctor[Symbol.metadata];
|
|
301
|
+
|
|
302
|
+
if (metadata && metadata.searchableFields && !Array.isArray(ctor.searchableFields)) {
|
|
303
|
+
// Initialize from metadata (runs once per class)
|
|
304
|
+
ctor.searchableFields = [...metadata.searchableFields];
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Escape user input for safe use in MongoDB regular expressions
|
|
311
|
+
function escapeForRegex(input: string): string {
|
|
312
|
+
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* unique index - decorator to mark a unique index
|
|
317
|
+
*/
|
|
318
|
+
export function unI(optionsArg: IUnIOptions = {}) {
|
|
319
|
+
if (
|
|
320
|
+
typeof optionsArg !== 'object'
|
|
321
|
+
|| optionsArg === null
|
|
322
|
+
|| Array.isArray(optionsArg)
|
|
323
|
+
|| Object.keys(optionsArg).some((keyArg) => keyArg !== 'valueType')
|
|
324
|
+
) {
|
|
325
|
+
throw new SmartdataPersistenceError(
|
|
326
|
+
'invalid_configuration',
|
|
327
|
+
'unI options must be an object containing only valueType.',
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const valueType = optionsArg.valueType ?? 'string';
|
|
331
|
+
if (valueType !== 'string' && valueType !== 'positiveSafeInteger') {
|
|
332
|
+
throw new SmartdataPersistenceError(
|
|
333
|
+
'invalid_configuration',
|
|
334
|
+
'unI valueType must be "string" or "positiveSafeInteger".',
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
|
338
|
+
if (context.kind !== 'field') {
|
|
339
|
+
throw new Error('unI can only decorate fields');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const propName = String(context.name);
|
|
343
|
+
|
|
344
|
+
// Store metadata at class level
|
|
345
|
+
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
|
346
|
+
if (!Object.prototype.hasOwnProperty.call(metadata, 'uniqueIndexes')) {
|
|
347
|
+
metadata.uniqueIndexes = [...(metadata.uniqueIndexes || [])];
|
|
348
|
+
}
|
|
349
|
+
if (!metadata.uniqueIndexes!.includes(propName)) {
|
|
350
|
+
metadata.uniqueIndexes!.push(propName);
|
|
351
|
+
}
|
|
352
|
+
if (
|
|
353
|
+
!Object.prototype.hasOwnProperty.call(metadata, 'identityValueTypes')
|
|
354
|
+
) {
|
|
355
|
+
metadata.identityValueTypes = { ...(metadata.identityValueTypes || {}) };
|
|
356
|
+
}
|
|
357
|
+
const existingValueType = metadata.identityValueTypes![propName];
|
|
358
|
+
if (existingValueType && existingValueType !== valueType) {
|
|
359
|
+
throw new SmartdataPersistenceError(
|
|
360
|
+
'invalid_configuration',
|
|
361
|
+
`Identity field "${propName}" has divergent valueType declarations.`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
metadata.identityValueTypes![propName] = valueType;
|
|
365
|
+
|
|
366
|
+
// Also mark as saveable
|
|
367
|
+
if (!Object.prototype.hasOwnProperty.call(metadata, 'saveableProperties')) {
|
|
368
|
+
metadata.saveableProperties = [...(metadata.saveableProperties || [])];
|
|
369
|
+
}
|
|
370
|
+
if (!metadata.saveableProperties!.includes(propName)) {
|
|
371
|
+
metadata.saveableProperties!.push(propName);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Use addInitializer to ensure prototype arrays are set up once
|
|
375
|
+
context.addInitializer(function(this: any) {
|
|
376
|
+
const proto = this.constructor.prototype;
|
|
377
|
+
const metadata = this.constructor[Symbol.metadata];
|
|
378
|
+
|
|
379
|
+
if (metadata && metadata.uniqueIndexes && !proto.uniqueIndexes) {
|
|
380
|
+
proto.uniqueIndexes = [...metadata.uniqueIndexes];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (
|
|
384
|
+
metadata
|
|
385
|
+
&& metadata.identityValueTypes
|
|
386
|
+
&& !Object.prototype.hasOwnProperty.call(proto, 'identityValueTypes')
|
|
387
|
+
) {
|
|
388
|
+
proto.identityValueTypes = { ...metadata.identityValueTypes };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (metadata && metadata.saveableProperties && !proto.saveableProperties) {
|
|
392
|
+
proto.saveableProperties = [...metadata.saveableProperties];
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Options for MongoDB indexes
|
|
400
|
+
*/
|
|
401
|
+
export interface IIndexOptions {
|
|
402
|
+
background?: boolean;
|
|
403
|
+
unique?: boolean;
|
|
404
|
+
sparse?: boolean;
|
|
405
|
+
expireAfterSeconds?: number;
|
|
406
|
+
[key: string]: any;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* index - decorator to mark a field for regular indexing
|
|
411
|
+
*/
|
|
412
|
+
export function index(options?: IIndexOptions) {
|
|
413
|
+
return (value: undefined, context: ClassFieldDecoratorContext) => {
|
|
414
|
+
if (context.kind !== 'field') {
|
|
415
|
+
throw new Error('index can only decorate fields');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const propName = String(context.name);
|
|
419
|
+
|
|
420
|
+
// Store metadata at class level
|
|
421
|
+
const metadata = context.metadata as ISmartdataDecoratorMetadata;
|
|
422
|
+
if (!Object.prototype.hasOwnProperty.call(metadata, 'regularIndexes')) {
|
|
423
|
+
metadata.regularIndexes = [...(metadata.regularIndexes || [])];
|
|
424
|
+
}
|
|
425
|
+
metadata.regularIndexes!.push({
|
|
426
|
+
field: propName,
|
|
427
|
+
options: { ...(options || {}) }
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Also mark as saveable
|
|
431
|
+
if (!Object.prototype.hasOwnProperty.call(metadata, 'saveableProperties')) {
|
|
432
|
+
metadata.saveableProperties = [...(metadata.saveableProperties || [])];
|
|
433
|
+
}
|
|
434
|
+
if (!metadata.saveableProperties!.includes(propName)) {
|
|
435
|
+
metadata.saveableProperties!.push(propName);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Use addInitializer to ensure prototype arrays are set up once
|
|
439
|
+
context.addInitializer(function(this: any) {
|
|
440
|
+
const proto = this.constructor.prototype;
|
|
441
|
+
const metadata = this.constructor[Symbol.metadata];
|
|
442
|
+
|
|
443
|
+
if (metadata && metadata.regularIndexes && !proto.regularIndexes) {
|
|
444
|
+
proto.regularIndexes = [...metadata.regularIndexes];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (metadata && metadata.saveableProperties && !proto.saveableProperties) {
|
|
448
|
+
proto.saveableProperties = [...metadata.saveableProperties];
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Helper type to extract element type from arrays or return T itself
|
|
455
|
+
type ElementOf<T> = T extends ReadonlyArray<infer U> ? U : T;
|
|
456
|
+
|
|
457
|
+
// Type for $in/$nin values - arrays of the element type
|
|
458
|
+
type InValues<T> = ReadonlyArray<ElementOf<T>>;
|
|
459
|
+
|
|
460
|
+
// Type that allows MongoDB operators on leaf values while maintaining nested type safety
|
|
461
|
+
export type MongoFilterCondition<T> = T | {
|
|
462
|
+
$eq?: T;
|
|
463
|
+
$ne?: T;
|
|
464
|
+
$gt?: T;
|
|
465
|
+
$gte?: T;
|
|
466
|
+
$lt?: T;
|
|
467
|
+
$lte?: T;
|
|
468
|
+
$in?: InValues<T>;
|
|
469
|
+
$nin?: InValues<T>;
|
|
470
|
+
$exists?: boolean;
|
|
471
|
+
$type?: string | number;
|
|
472
|
+
$regex?: string | RegExp;
|
|
473
|
+
$options?: string;
|
|
474
|
+
$all?: T extends ReadonlyArray<infer U> ? ReadonlyArray<U> : never;
|
|
475
|
+
$elemMatch?: T extends ReadonlyArray<infer U> ? MongoFilter<U> : never;
|
|
476
|
+
$size?: T extends ReadonlyArray<any> ? number : never;
|
|
477
|
+
$not?: MongoFilterCondition<T>;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
export type MongoFilter<T> = {
|
|
481
|
+
[K in keyof T]?: T[K] extends object
|
|
482
|
+
? T[K] extends any[]
|
|
483
|
+
? MongoFilterCondition<T[K]> // Arrays can have operators
|
|
484
|
+
: MongoFilter<T[K]> | MongoFilterCondition<T[K]> // Objects can be nested or have operators
|
|
485
|
+
: MongoFilterCondition<T[K]>; // Primitives get operators
|
|
486
|
+
} & {
|
|
487
|
+
// Logical operators
|
|
488
|
+
$and?: MongoFilter<T>[];
|
|
489
|
+
$or?: MongoFilter<T>[];
|
|
490
|
+
$nor?: MongoFilter<T>[];
|
|
491
|
+
$not?: MongoFilter<T>;
|
|
492
|
+
// Allow any string key for dot notation (we lose type safety here but maintain flexibility)
|
|
493
|
+
[key: string]: any;
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
export type TSmartdataDocumentPath<T> = keyof T & string;
|
|
497
|
+
|
|
498
|
+
export type TSmartdataAtomicNestedDocumentPath<T> =
|
|
499
|
+
`${TSmartdataDocumentPath<T>}.${string}`;
|
|
500
|
+
|
|
501
|
+
export type TSmartdataAtomicDocumentPath<T> =
|
|
502
|
+
| TSmartdataDocumentPath<T>
|
|
503
|
+
| TSmartdataAtomicNestedDocumentPath<T>;
|
|
504
|
+
|
|
505
|
+
export type TSmartdataProjection<T> = Partial<
|
|
506
|
+
Record<TSmartdataAtomicDocumentPath<T> | '_id', 0 | 1>
|
|
507
|
+
>;
|
|
508
|
+
|
|
509
|
+
export type TSmartdataSort<T> = Partial<
|
|
510
|
+
Record<TSmartdataAtomicDocumentPath<T> | '_id', 1 | -1>
|
|
511
|
+
>;
|
|
512
|
+
|
|
513
|
+
export interface ISmartdataCursorOptions<T> {
|
|
514
|
+
projection?: TSmartdataProjection<T>;
|
|
515
|
+
/**
|
|
516
|
+
* Callers are responsible for including a unique final tiebreaker when
|
|
517
|
+
* deterministic pagination across equal values matters.
|
|
518
|
+
*/
|
|
519
|
+
sort?: TSmartdataSort<T>;
|
|
520
|
+
batchSize?: number;
|
|
521
|
+
limit?: number;
|
|
522
|
+
/** Zero-based offset. Requires an explicit sort and bounded limit. */
|
|
523
|
+
skip?: number;
|
|
524
|
+
/** Model-declared index name, or MongoDB's built-in `_id_` index. */
|
|
525
|
+
hint?: string;
|
|
526
|
+
maxTimeMS?: number;
|
|
527
|
+
session?: plugins.mongodb.ClientSession;
|
|
528
|
+
/**
|
|
529
|
+
* @deprecated Prefer the structured cursor options. The modifier runs last
|
|
530
|
+
* for backward compatibility.
|
|
531
|
+
*/
|
|
532
|
+
modifier?: (
|
|
533
|
+
cursorArg: plugins.mongodb.FindCursor<
|
|
534
|
+
plugins.mongodb.WithId<plugins.mongodb.BSON.Document>
|
|
535
|
+
>,
|
|
536
|
+
) => plugins.mongodb.FindCursor<
|
|
537
|
+
plugins.mongodb.WithId<plugins.mongodb.BSON.Document>
|
|
538
|
+
>;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export interface ISmartdataCountOptions {
|
|
542
|
+
limit?: number;
|
|
543
|
+
/** Model-declared index name, or MongoDB's built-in `_id_` index. */
|
|
544
|
+
hint?: string;
|
|
545
|
+
maxTimeMS?: number;
|
|
546
|
+
/** Client-side operation deadline, including server selection and pool waits. */
|
|
547
|
+
timeoutMS?: number;
|
|
548
|
+
signal?: AbortSignal;
|
|
549
|
+
session?: TSmartdataOrdinarySession;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export interface ISmartdataOffsetPageOptions<T> {
|
|
553
|
+
skip: number;
|
|
554
|
+
limit: number;
|
|
555
|
+
/** Include a unique final tiebreaker to order equal preceding values. */
|
|
556
|
+
sort: TSmartdataSort<T>;
|
|
557
|
+
projection?: TSmartdataProjection<T>;
|
|
558
|
+
hint?: string;
|
|
559
|
+
maxTimeMS?: number;
|
|
560
|
+
/** Defaults to 5000ms; maximum 120000ms. Covers the cursor's entire lifetime. */
|
|
561
|
+
timeoutMS?: number;
|
|
562
|
+
signal?: AbortSignal;
|
|
563
|
+
session?: TSmartdataOrdinarySession;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export interface ISmartdataFindOneOptions<T> {
|
|
567
|
+
projection?: TSmartdataProjection<T>;
|
|
568
|
+
maxTimeMS?: number;
|
|
569
|
+
session?: TSmartdataOrdinarySession;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
type TSmartdataAtomicSet<T> =
|
|
573
|
+
& Partial<Pick<T, TSmartdataDocumentPath<T>>>
|
|
574
|
+
& Partial<Record<TSmartdataAtomicNestedDocumentPath<T>, unknown>>;
|
|
575
|
+
|
|
576
|
+
type TSmartdataAtomicIncrement<T> =
|
|
577
|
+
& Partial<
|
|
578
|
+
Record<
|
|
579
|
+
{
|
|
580
|
+
[TKey in TSmartdataDocumentPath<T>]:
|
|
581
|
+
NonNullable<T[TKey]> extends number ? TKey : never;
|
|
582
|
+
}[TSmartdataDocumentPath<T>],
|
|
583
|
+
number
|
|
584
|
+
>
|
|
585
|
+
>
|
|
586
|
+
& Partial<Record<TSmartdataAtomicNestedDocumentPath<T>, number>>;
|
|
587
|
+
|
|
588
|
+
export type TSmartdataNumericDocumentPath<T> = {
|
|
589
|
+
[TKey in TSmartdataDocumentPath<T>]:
|
|
590
|
+
NonNullable<T[TKey]> extends number ? TKey : never;
|
|
591
|
+
}[TSmartdataDocumentPath<T>];
|
|
592
|
+
|
|
593
|
+
export type TSmartdataAtomicNumericFieldReference<T> =
|
|
594
|
+
`$${TSmartdataNumericDocumentPath<T>}`;
|
|
595
|
+
|
|
596
|
+
export type TSmartdataAtomicNumericExpressionOperand<T> =
|
|
597
|
+
| number
|
|
598
|
+
| TSmartdataAtomicNumericFieldReference<T>
|
|
599
|
+
| {
|
|
600
|
+
$add: readonly [
|
|
601
|
+
TSmartdataAtomicNumericExpressionOperand<T>,
|
|
602
|
+
TSmartdataAtomicNumericExpressionOperand<T>,
|
|
603
|
+
...TSmartdataAtomicNumericExpressionOperand<T>[],
|
|
604
|
+
];
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
export interface ISmartdataAtomicNumericExpression<T> {
|
|
608
|
+
$lte: readonly [
|
|
609
|
+
TSmartdataAtomicNumericExpressionOperand<T>,
|
|
610
|
+
TSmartdataAtomicNumericExpressionOperand<T>,
|
|
611
|
+
];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export interface ISmartdataGroupedTotalsOptions<T> {
|
|
615
|
+
/**
|
|
616
|
+
* One or two declared top-level fields whose distinct value combinations
|
|
617
|
+
* form the groups.
|
|
618
|
+
*/
|
|
619
|
+
groupBy: ReadonlyArray<TSmartdataDocumentPath<T>>;
|
|
620
|
+
/**
|
|
621
|
+
* Declared numeric top-level fields to sum per group.
|
|
622
|
+
*/
|
|
623
|
+
sums?: ReadonlyArray<TSmartdataNumericDocumentPath<T>>;
|
|
624
|
+
filter?: MongoFilter<T>;
|
|
625
|
+
/**
|
|
626
|
+
* Required bound on the number of returned groups (1..10000). The group
|
|
627
|
+
* stage is not sorted, so when more groups exist than the limit the
|
|
628
|
+
* returned subset is arbitrary. Use `getGroupedTotalsPage()` when the
|
|
629
|
+
* caller must detect overflow at the 10000-group boundary.
|
|
630
|
+
*/
|
|
631
|
+
limit: number;
|
|
632
|
+
maxTimeMS?: number;
|
|
633
|
+
/** Client-side deadline for the complete aggregation cursor lifetime. */
|
|
634
|
+
timeoutMS?: number;
|
|
635
|
+
signal?: AbortSignal;
|
|
636
|
+
session?: TSmartdataOrdinarySession;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export interface ISmartdataGroupedTotalsRow<T> {
|
|
640
|
+
/**
|
|
641
|
+
* The group's key values. Documents missing a group field and documents
|
|
642
|
+
* holding an explicit null merge into one group reported as null.
|
|
643
|
+
*/
|
|
644
|
+
group: { [TKey in TSmartdataDocumentPath<T>]?: T[TKey] | null };
|
|
645
|
+
count: number;
|
|
646
|
+
sums: Partial<Record<TSmartdataNumericDocumentPath<T>, number>>;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export interface ISmartdataGroupedTotalsPage<T> {
|
|
650
|
+
rows: Array<ISmartdataGroupedTotalsRow<T>>;
|
|
651
|
+
hasMore: boolean;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export type TSmartdataAtomicArrayPath<T> = {
|
|
655
|
+
[TKey in TSmartdataDocumentPath<T>]:
|
|
656
|
+
NonNullable<T[TKey]> extends ReadonlyArray<unknown> ? TKey : never;
|
|
657
|
+
}[TSmartdataDocumentPath<T>];
|
|
658
|
+
|
|
659
|
+
type TSmartdataAtomicArrayElement<TValue> =
|
|
660
|
+
NonNullable<TValue> extends ReadonlyArray<infer TElement> ? TElement : never;
|
|
661
|
+
|
|
662
|
+
type TSmartdataAtomicArrayOperation<T> = Partial<{
|
|
663
|
+
[TKey in TSmartdataAtomicArrayPath<T>]: TSmartdataAtomicArrayElement<T[TKey]>;
|
|
664
|
+
}>;
|
|
665
|
+
|
|
666
|
+
type TSmartdataAtomicPullScalar<TValue> = Extract<
|
|
667
|
+
TValue,
|
|
668
|
+
| string
|
|
669
|
+
| number
|
|
670
|
+
| boolean
|
|
671
|
+
| null
|
|
672
|
+
| Date
|
|
673
|
+
| plugins.mongodb.ObjectId
|
|
674
|
+
| plugins.mongodb.Binary
|
|
675
|
+
| plugins.mongodb.Decimal128
|
|
676
|
+
| plugins.mongodb.Long
|
|
677
|
+
| plugins.mongodb.Int32
|
|
678
|
+
| plugins.mongodb.Double
|
|
679
|
+
| plugins.mongodb.Timestamp
|
|
680
|
+
>;
|
|
681
|
+
|
|
682
|
+
type TSmartdataAtomicArrayPullOperation<T> = Partial<{
|
|
683
|
+
[TKey in TSmartdataAtomicArrayPath<T>]: TSmartdataAtomicPullScalar<
|
|
684
|
+
TSmartdataAtomicArrayElement<T[TKey]>
|
|
685
|
+
>;
|
|
686
|
+
}>;
|
|
687
|
+
|
|
688
|
+
export interface ISmartdataAtomicUpdate<T> {
|
|
689
|
+
$set?: TSmartdataAtomicSet<T>;
|
|
690
|
+
$unset?: Partial<
|
|
691
|
+
Record<TSmartdataAtomicDocumentPath<T>, '' | true | 1>
|
|
692
|
+
>;
|
|
693
|
+
$inc?: TSmartdataAtomicIncrement<T>;
|
|
694
|
+
$setOnInsert?: TSmartdataAtomicSet<T>;
|
|
695
|
+
/**
|
|
696
|
+
* Appends one element to a declared top-level array field. Modifier
|
|
697
|
+
* documents ($each, $position, $slice, $sort) are rejected.
|
|
698
|
+
*/
|
|
699
|
+
$push?: TSmartdataAtomicArrayOperation<T>;
|
|
700
|
+
/**
|
|
701
|
+
* Adds one element to a declared top-level array field unless an equal
|
|
702
|
+
* element is already present. Modifier documents are rejected.
|
|
703
|
+
*/
|
|
704
|
+
$addToSet?: TSmartdataAtomicArrayOperation<T>;
|
|
705
|
+
/**
|
|
706
|
+
* Removes every element equal to a scalar operand from a declared
|
|
707
|
+
* top-level array field. Operator documents, plain objects, arrays, and
|
|
708
|
+
* regular expressions are rejected.
|
|
709
|
+
*/
|
|
710
|
+
$pull?: TSmartdataAtomicArrayPullOperation<T>;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
type TSmartdataAtomicDirectEquality<TValue> =
|
|
714
|
+
TValue extends ReadonlyArray<infer TEntry>
|
|
715
|
+
? ReadonlyArray<TSmartdataAtomicDirectEquality<TEntry>>
|
|
716
|
+
: TValue extends object
|
|
717
|
+
? TValue extends
|
|
718
|
+
| Date
|
|
719
|
+
| ArrayBufferView
|
|
720
|
+
| plugins.mongodb.ObjectId
|
|
721
|
+
| plugins.mongodb.Binary
|
|
722
|
+
| plugins.mongodb.Decimal128
|
|
723
|
+
| plugins.mongodb.Long
|
|
724
|
+
| plugins.mongodb.Int32
|
|
725
|
+
| plugins.mongodb.Double
|
|
726
|
+
| plugins.mongodb.Timestamp
|
|
727
|
+
| plugins.mongodb.MinKey
|
|
728
|
+
| plugins.mongodb.MaxKey
|
|
729
|
+
? TValue
|
|
730
|
+
: never
|
|
731
|
+
: Exclude<TValue, undefined>;
|
|
732
|
+
|
|
733
|
+
type TSmartdataAtomicInValue<TValue> =
|
|
734
|
+
TValue extends ReadonlyArray<infer TEntry>
|
|
735
|
+
? TSmartdataAtomicDirectEquality<TEntry>
|
|
736
|
+
: TSmartdataAtomicDirectEquality<TValue>;
|
|
737
|
+
|
|
738
|
+
export type TSmartdataAtomicFilterCondition<TValue> =
|
|
739
|
+
| TSmartdataAtomicDirectEquality<TValue>
|
|
740
|
+
| {
|
|
741
|
+
$eq?: TValue;
|
|
742
|
+
$ne?: TSmartdataAtomicDirectEquality<TValue>;
|
|
743
|
+
$gt?: TSmartdataAtomicDirectEquality<TValue>;
|
|
744
|
+
$gte?: TSmartdataAtomicDirectEquality<TValue>;
|
|
745
|
+
$lt?: TSmartdataAtomicDirectEquality<TValue>;
|
|
746
|
+
$lte?: TSmartdataAtomicDirectEquality<TValue>;
|
|
747
|
+
$in?: ReadonlyArray<TSmartdataAtomicInValue<TValue>>;
|
|
748
|
+
$nin?: ReadonlyArray<TSmartdataAtomicInValue<TValue>>;
|
|
749
|
+
$exists?: boolean;
|
|
750
|
+
$type?: plugins.mongodb.BSONType | plugins.mongodb.BSONTypeAlias;
|
|
751
|
+
$regex?: string | RegExp;
|
|
752
|
+
$options?: string;
|
|
753
|
+
$not?: TSmartdataAtomicFilterCondition<TValue>;
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
type TSmartdataAtomicTopLevelFilter<T> = {
|
|
757
|
+
[TKey in TSmartdataDocumentPath<T>]?:
|
|
758
|
+
TSmartdataAtomicFilterCondition<T[TKey]>;
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
type TSmartdataAtomicNestedFilter<T> = Partial<
|
|
762
|
+
Record<
|
|
763
|
+
TSmartdataAtomicNestedDocumentPath<T>,
|
|
764
|
+
TSmartdataAtomicFilterCondition<unknown>
|
|
765
|
+
>
|
|
766
|
+
>;
|
|
767
|
+
|
|
768
|
+
type TSmartdataAtomicFilterBranch<T> =
|
|
769
|
+
& TSmartdataAtomicTopLevelFilter<T>
|
|
770
|
+
& TSmartdataAtomicNestedFilter<T>
|
|
771
|
+
& {
|
|
772
|
+
$and?: Array<TSmartdataAtomicFilterBranch<T>>;
|
|
773
|
+
$or?: Array<TSmartdataAtomicFilterBranch<T>>;
|
|
774
|
+
$nor?: Array<TSmartdataAtomicFilterBranch<T>>;
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
export type TSmartdataAtomicFilter<T> =
|
|
778
|
+
& TSmartdataAtomicFilterBranch<T>
|
|
779
|
+
& {
|
|
780
|
+
/**
|
|
781
|
+
* A root-only, fail-closed numeric expression. This predicate never
|
|
782
|
+
* counts as the equality anchor required by atomic writes.
|
|
783
|
+
*/
|
|
784
|
+
$expr?: ISmartdataAtomicNumericExpression<T>;
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
export interface ISmartdataAtomicUpdateOptions {
|
|
788
|
+
upsert?: boolean;
|
|
789
|
+
session?: TSmartdataOrdinarySession;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export interface ISmartdataAtomicUpdateResult {
|
|
793
|
+
acknowledged: boolean;
|
|
794
|
+
matchedCount: number;
|
|
795
|
+
modifiedCount: number;
|
|
796
|
+
upsertedId: plugins.mongodb.ObjectId | null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
export type TSmartdataAtomicReturnDocument = 'before' | 'after';
|
|
800
|
+
|
|
801
|
+
export interface ISmartdataAtomicFindOneAndUpdateOptions<
|
|
802
|
+
TReturnDocument extends TSmartdataAtomicReturnDocument,
|
|
803
|
+
TModel = any,
|
|
804
|
+
> {
|
|
805
|
+
returnDocument: TReturnDocument;
|
|
806
|
+
upsert?: boolean;
|
|
807
|
+
/**
|
|
808
|
+
* Optional sort applied before MongoDB selects the single document to
|
|
809
|
+
* update. Accepts the same paths as cursor sorts: declared top-level fields,
|
|
810
|
+
* nested paths declared through `@svDb({ atomicPaths })` or a model index,
|
|
811
|
+
* and `_id`. Callers wanting deterministic claim ordering should include a
|
|
812
|
+
* unique tiebreaker field.
|
|
813
|
+
*/
|
|
814
|
+
sort?: TSmartdataSort<TModel>;
|
|
815
|
+
session?: TSmartdataOrdinarySession;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export type TSmartdataAtomicFindOneAndUpdateResult<
|
|
819
|
+
T,
|
|
820
|
+
TReturnDocument extends TSmartdataAtomicReturnDocument,
|
|
821
|
+
> =
|
|
822
|
+
| { status: 'matched'; document: T }
|
|
823
|
+
| {
|
|
824
|
+
status: 'upserted';
|
|
825
|
+
document: TReturnDocument extends 'after' ? T : null;
|
|
826
|
+
}
|
|
827
|
+
| { status: 'not_matched'; document: null };
|
|
828
|
+
|
|
829
|
+
export interface ISmartdataAtomicDeleteOptions {
|
|
830
|
+
session?: TSmartdataOrdinarySession;
|
|
831
|
+
/** Client-side operation deadline, including server selection and pool waits. */
|
|
832
|
+
timeoutMS?: number;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
export interface ISmartdataInsertManyIfAbsentOptions<T> {
|
|
836
|
+
/** A declared, non-serialized @unI identity field. */
|
|
837
|
+
identityField: TSmartdataDocumentPath<T>;
|
|
838
|
+
/** Client-side bulk operation deadline, including server selection and pool waits. */
|
|
839
|
+
timeoutMS?: number;
|
|
840
|
+
session?: TSmartdataOrdinarySession;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export interface ISmartdataInsertManyIfAbsentResult {
|
|
844
|
+
insertedCount: number;
|
|
845
|
+
matchedCount: number;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export interface ISmartdataAtomicDeleteResult {
|
|
849
|
+
acknowledged: boolean;
|
|
850
|
+
deletedCount: number;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export interface ISmartdataAtomicUpdateManyOptions {
|
|
854
|
+
session?: TSmartdataOrdinarySession;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export interface ISmartdataAtomicUpdateManyResult {
|
|
858
|
+
acknowledged: boolean;
|
|
859
|
+
matchedCount: number;
|
|
860
|
+
modifiedCount: number;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const normalizeAtomicFindOneAndUpdateOptions = <
|
|
864
|
+
TReturnDocument extends TSmartdataAtomicReturnDocument,
|
|
865
|
+
>(
|
|
866
|
+
optionsArg: ISmartdataAtomicFindOneAndUpdateOptions<TReturnDocument>,
|
|
867
|
+
): {
|
|
868
|
+
returnDocument: TReturnDocument;
|
|
869
|
+
upsert: boolean;
|
|
870
|
+
sort?: unknown;
|
|
871
|
+
session?: TSmartdataOrdinarySession;
|
|
872
|
+
} => {
|
|
873
|
+
if (
|
|
874
|
+
typeof optionsArg !== 'object'
|
|
875
|
+
|| optionsArg === null
|
|
876
|
+
|| plugins.nodeUtil.types.isProxy(optionsArg)
|
|
877
|
+
|| !isPlainObject(optionsArg)
|
|
878
|
+
) {
|
|
879
|
+
throw new SmartdataPersistenceError(
|
|
880
|
+
'invalid_argument',
|
|
881
|
+
'Atomic find-one-and-update options must be an inert plain object.',
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
const allowedKeys = new Set(['returnDocument', 'upsert', 'sort', 'session']);
|
|
885
|
+
const keys = Reflect.ownKeys(optionsArg);
|
|
886
|
+
for (const key of keys) {
|
|
887
|
+
if (typeof key !== 'string' || !allowedKeys.has(key)) {
|
|
888
|
+
throw new SmartdataPersistenceError(
|
|
889
|
+
'invalid_argument',
|
|
890
|
+
`Atomic find-one-and-update received unsupported option "${String(key)}".`,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
const descriptor = Object.getOwnPropertyDescriptor(optionsArg, key);
|
|
894
|
+
if (!descriptor || !('value' in descriptor) || descriptor.enumerable !== true) {
|
|
895
|
+
throw new SmartdataPersistenceError(
|
|
896
|
+
'invalid_argument',
|
|
897
|
+
`Atomic find-one-and-update option "${key}" must be an inert enumerable data property.`,
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const returnDocumentDescriptor = Object.getOwnPropertyDescriptor(
|
|
902
|
+
optionsArg,
|
|
903
|
+
'returnDocument',
|
|
904
|
+
);
|
|
905
|
+
if (
|
|
906
|
+
!returnDocumentDescriptor
|
|
907
|
+
|| !('value' in returnDocumentDescriptor)
|
|
908
|
+
|| (
|
|
909
|
+
returnDocumentDescriptor.value !== 'before'
|
|
910
|
+
&& returnDocumentDescriptor.value !== 'after'
|
|
911
|
+
)
|
|
912
|
+
) {
|
|
913
|
+
throw new SmartdataPersistenceError(
|
|
914
|
+
'invalid_argument',
|
|
915
|
+
'Atomic find-one-and-update requires returnDocument to be "before" or "after".',
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
const upsertDescriptor = Object.getOwnPropertyDescriptor(optionsArg, 'upsert');
|
|
919
|
+
if (upsertDescriptor && upsertDescriptor.value !== undefined
|
|
920
|
+
&& typeof upsertDescriptor.value !== 'boolean') {
|
|
921
|
+
throw new SmartdataPersistenceError(
|
|
922
|
+
'invalid_argument',
|
|
923
|
+
'Atomic find-one-and-update upsert must be a boolean.',
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
const sortDescriptor = Object.getOwnPropertyDescriptor(optionsArg, 'sort');
|
|
927
|
+
const sessionDescriptor = Object.getOwnPropertyDescriptor(optionsArg, 'session');
|
|
928
|
+
return {
|
|
929
|
+
returnDocument: returnDocumentDescriptor.value as TReturnDocument,
|
|
930
|
+
upsert: upsertDescriptor?.value === true,
|
|
931
|
+
...(sortDescriptor?.value !== undefined
|
|
932
|
+
? { sort: sortDescriptor.value }
|
|
933
|
+
: {}),
|
|
934
|
+
...(sessionDescriptor?.value !== undefined
|
|
935
|
+
? { session: sessionDescriptor.value as TSmartdataOrdinarySession }
|
|
936
|
+
: {}),
|
|
937
|
+
};
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
const normalizeAtomicExpressionUpdateOptions = (
|
|
941
|
+
optionsArg: ISmartdataAtomicUpdateOptions | undefined,
|
|
942
|
+
): ISmartdataAtomicUpdateOptions | undefined => {
|
|
943
|
+
if (optionsArg === undefined) {
|
|
944
|
+
return undefined;
|
|
945
|
+
}
|
|
946
|
+
if (
|
|
947
|
+
typeof optionsArg !== 'object'
|
|
948
|
+
|| optionsArg === null
|
|
949
|
+
|| plugins.nodeUtil.types.isProxy(optionsArg)
|
|
950
|
+
|| Object.getPrototypeOf(optionsArg) !== Object.prototype
|
|
951
|
+
) {
|
|
952
|
+
throw new SmartdataPersistenceError(
|
|
953
|
+
'invalid_argument',
|
|
954
|
+
'Atomic update options used with $expr must be an inert ordinary object.',
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
const allowedKeys = new Set(['upsert', 'session']);
|
|
958
|
+
const normalized: ISmartdataAtomicUpdateOptions = {};
|
|
959
|
+
for (const key of Reflect.ownKeys(optionsArg)) {
|
|
960
|
+
if (typeof key !== 'string' || !allowedKeys.has(key)) {
|
|
961
|
+
throw new SmartdataPersistenceError(
|
|
962
|
+
'invalid_argument',
|
|
963
|
+
`Atomic update received unsupported option "${String(key)}".`,
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
const descriptor = Object.getOwnPropertyDescriptor(optionsArg, key);
|
|
967
|
+
if (
|
|
968
|
+
!descriptor
|
|
969
|
+
|| !('value' in descriptor)
|
|
970
|
+
|| descriptor.enumerable !== true
|
|
971
|
+
) {
|
|
972
|
+
throw new SmartdataPersistenceError(
|
|
973
|
+
'invalid_argument',
|
|
974
|
+
`Atomic update option "${key}" must be an inert enumerable data property.`,
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
if (key === 'upsert') {
|
|
978
|
+
if (
|
|
979
|
+
descriptor.value !== undefined
|
|
980
|
+
&& typeof descriptor.value !== 'boolean'
|
|
981
|
+
) {
|
|
982
|
+
throw new SmartdataPersistenceError(
|
|
983
|
+
'invalid_argument',
|
|
984
|
+
'Atomic update upsert must be a boolean.',
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
if (descriptor.value !== undefined) {
|
|
988
|
+
normalized.upsert = descriptor.value;
|
|
989
|
+
}
|
|
990
|
+
} else if (descriptor.value !== undefined) {
|
|
991
|
+
normalized.session = descriptor.value as TSmartdataOrdinarySession;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return normalized;
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
const reservedOrdinaryPersistenceFields = new Set([
|
|
998
|
+
'_id',
|
|
999
|
+
'_createdAt',
|
|
1000
|
+
'_updatedAt',
|
|
1001
|
+
'_smartdataRevision',
|
|
1002
|
+
]);
|
|
1003
|
+
const stringIdRootSets = new WeakSet<Set<string>>();
|
|
1004
|
+
|
|
1005
|
+
const requireBoundedInteger = (
|
|
1006
|
+
valueArg: number | undefined,
|
|
1007
|
+
labelArg: string,
|
|
1008
|
+
maximumArg: number,
|
|
1009
|
+
): number | undefined => {
|
|
1010
|
+
if (valueArg === undefined) {
|
|
1011
|
+
return undefined;
|
|
1012
|
+
}
|
|
1013
|
+
if (
|
|
1014
|
+
!Number.isSafeInteger(valueArg) ||
|
|
1015
|
+
valueArg <= 0 ||
|
|
1016
|
+
valueArg > maximumArg
|
|
1017
|
+
) {
|
|
1018
|
+
throw new SmartdataPersistenceError(
|
|
1019
|
+
'invalid_argument',
|
|
1020
|
+
`${labelArg} must be a positive safe integer no greater than ${maximumArg}.`,
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
return valueArg;
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
const requireDeclaredPath = (
|
|
1027
|
+
pathArg: string,
|
|
1028
|
+
declaredRootsArg: Set<string>,
|
|
1029
|
+
labelArg: string,
|
|
1030
|
+
declaredNestedPathsArg?: Set<string>,
|
|
1031
|
+
): string => {
|
|
1032
|
+
const pathSegments = typeof pathArg === 'string'
|
|
1033
|
+
? pathArg.split('.')
|
|
1034
|
+
: [];
|
|
1035
|
+
if (
|
|
1036
|
+
typeof pathArg !== 'string' ||
|
|
1037
|
+
pathArg.length === 0 ||
|
|
1038
|
+
pathArg.length > 1024 ||
|
|
1039
|
+
pathArg.includes('\0') ||
|
|
1040
|
+
pathArg.startsWith('$') ||
|
|
1041
|
+
pathSegments.length > 32 ||
|
|
1042
|
+
pathSegments.some((segmentArg) => (
|
|
1043
|
+
segmentArg.length === 0
|
|
1044
|
+
|| segmentArg.startsWith('$')
|
|
1045
|
+
|| segmentArg.includes('\0')
|
|
1046
|
+
|| unsafePersistedObjectKeys.has(segmentArg)
|
|
1047
|
+
)) ||
|
|
1048
|
+
(
|
|
1049
|
+
pathSegments.length > 1
|
|
1050
|
+
&& !declaredNestedPathsArg?.has(pathArg)
|
|
1051
|
+
)
|
|
1052
|
+
) {
|
|
1053
|
+
throw new SmartdataPersistenceError(
|
|
1054
|
+
'invalid_argument',
|
|
1055
|
+
`${labelArg} contains invalid persisted path "${pathArg}".`,
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
const root = pathSegments[0];
|
|
1059
|
+
if (
|
|
1060
|
+
(reservedOrdinaryPersistenceFields.has(root) &&
|
|
1061
|
+
!(root === '_id' && stringIdRootSets.has(declaredRootsArg))) ||
|
|
1062
|
+
!declaredRootsArg.has(root)
|
|
1063
|
+
) {
|
|
1064
|
+
throw new SmartdataPersistenceError(
|
|
1065
|
+
'invalid_argument',
|
|
1066
|
+
`${labelArg} references undeclared or reserved persisted path "${pathArg}".`,
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
return root;
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
const unsafePersistedObjectKeys = new Set([
|
|
1073
|
+
'__proto__',
|
|
1074
|
+
'prototype',
|
|
1075
|
+
'constructor',
|
|
1076
|
+
]);
|
|
1077
|
+
|
|
1078
|
+
const assertSafePersistedValue = (
|
|
1079
|
+
valueArg: unknown,
|
|
1080
|
+
labelArg: string,
|
|
1081
|
+
seenArg = new WeakSet<object>(),
|
|
1082
|
+
): void => {
|
|
1083
|
+
if (typeof valueArg !== 'object' || valueArg === null) {
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (seenArg.has(valueArg)) {
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
seenArg.add(valueArg);
|
|
1090
|
+
if (Array.isArray(valueArg)) {
|
|
1091
|
+
for (const entryArg of valueArg) {
|
|
1092
|
+
assertSafePersistedValue(entryArg, labelArg, seenArg);
|
|
1093
|
+
}
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
if (!isPlainObject(valueArg)) {
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
for (const [keyArg, entryArg] of Object.entries(valueArg)) {
|
|
1100
|
+
if (
|
|
1101
|
+
unsafePersistedObjectKeys.has(keyArg) ||
|
|
1102
|
+
keyArg.startsWith('$') ||
|
|
1103
|
+
keyArg.includes('.') ||
|
|
1104
|
+
keyArg.includes('\0')
|
|
1105
|
+
) {
|
|
1106
|
+
throw new SmartdataPersistenceError(
|
|
1107
|
+
'invalid_argument',
|
|
1108
|
+
`${labelArg} contains unsafe object key "${keyArg}".`,
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
assertSafePersistedValue(entryArg, labelArg, seenArg);
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
const atomicFilterComparisonOperators = new Set([
|
|
1116
|
+
'$eq',
|
|
1117
|
+
'$ne',
|
|
1118
|
+
'$gt',
|
|
1119
|
+
'$gte',
|
|
1120
|
+
'$lt',
|
|
1121
|
+
'$lte',
|
|
1122
|
+
]);
|
|
1123
|
+
|
|
1124
|
+
const atomicFilterArrayOperators = new Set(['$in', '$nin']);
|
|
1125
|
+
|
|
1126
|
+
const atomicFilterOperators = new Set([
|
|
1127
|
+
...atomicFilterComparisonOperators,
|
|
1128
|
+
...atomicFilterArrayOperators,
|
|
1129
|
+
'$exists',
|
|
1130
|
+
'$type',
|
|
1131
|
+
'$regex',
|
|
1132
|
+
'$options',
|
|
1133
|
+
'$not',
|
|
1134
|
+
]);
|
|
1135
|
+
|
|
1136
|
+
const atomicFilterBsonTypes = new Set<unknown>([
|
|
1137
|
+
...Object.keys(plugins.mongodb.BSONType),
|
|
1138
|
+
...Object.values(plugins.mongodb.BSONType),
|
|
1139
|
+
]);
|
|
1140
|
+
|
|
1141
|
+
const atomicBsonScalarPrototypes = new Map<object, string>([
|
|
1142
|
+
[plugins.mongodb.ObjectId.prototype, 'ObjectId'],
|
|
1143
|
+
[plugins.mongodb.Binary.prototype, 'Binary'],
|
|
1144
|
+
[plugins.mongodb.Decimal128.prototype, 'Decimal128'],
|
|
1145
|
+
[plugins.mongodb.Long.prototype, 'Long'],
|
|
1146
|
+
[plugins.mongodb.Int32.prototype, 'Int32'],
|
|
1147
|
+
[plugins.mongodb.Double.prototype, 'Double'],
|
|
1148
|
+
[plugins.mongodb.Timestamp.prototype, 'Timestamp'],
|
|
1149
|
+
[plugins.mongodb.MinKey.prototype, 'MinKey'],
|
|
1150
|
+
[plugins.mongodb.MaxKey.prototype, 'MaxKey'],
|
|
1151
|
+
]);
|
|
1152
|
+
|
|
1153
|
+
const getTrustedAtomicBsonScalarType = (
|
|
1154
|
+
valueArg: unknown,
|
|
1155
|
+
): string | undefined => {
|
|
1156
|
+
if (
|
|
1157
|
+
typeof valueArg !== 'object'
|
|
1158
|
+
|| valueArg === null
|
|
1159
|
+
|| plugins.nodeUtil.types.isProxy(valueArg)
|
|
1160
|
+
) {
|
|
1161
|
+
return undefined;
|
|
1162
|
+
}
|
|
1163
|
+
const expectedType = atomicBsonScalarPrototypes.get(
|
|
1164
|
+
Object.getPrototypeOf(valueArg),
|
|
1165
|
+
);
|
|
1166
|
+
if (!expectedType) {
|
|
1167
|
+
return undefined;
|
|
1168
|
+
}
|
|
1169
|
+
const ownBsonTypeDescriptor = Object.getOwnPropertyDescriptor(
|
|
1170
|
+
valueArg,
|
|
1171
|
+
'_bsontype',
|
|
1172
|
+
);
|
|
1173
|
+
if (
|
|
1174
|
+
ownBsonTypeDescriptor
|
|
1175
|
+
&& (
|
|
1176
|
+
!('value' in ownBsonTypeDescriptor)
|
|
1177
|
+
|| ownBsonTypeDescriptor.value !== expectedType
|
|
1178
|
+
)
|
|
1179
|
+
) {
|
|
1180
|
+
return undefined;
|
|
1181
|
+
}
|
|
1182
|
+
return expectedType;
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
const hasAtomicBsonTypeClaim = (valueArg: unknown): boolean => {
|
|
1186
|
+
if (
|
|
1187
|
+
typeof valueArg !== 'object'
|
|
1188
|
+
|| valueArg === null
|
|
1189
|
+
|| plugins.nodeUtil.types.isProxy(valueArg)
|
|
1190
|
+
) {
|
|
1191
|
+
return false;
|
|
1192
|
+
}
|
|
1193
|
+
for (
|
|
1194
|
+
let prototype = valueArg as object | null;
|
|
1195
|
+
prototype;
|
|
1196
|
+
prototype = Object.getPrototypeOf(prototype)
|
|
1197
|
+
) {
|
|
1198
|
+
if (Object.getOwnPropertyDescriptor(prototype, '_bsontype')) {
|
|
1199
|
+
return true;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
return false;
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
const isAtomicMongoScalar = (valueArg: unknown): boolean => {
|
|
1206
|
+
if (
|
|
1207
|
+
typeof valueArg !== 'object'
|
|
1208
|
+
|| valueArg === null
|
|
1209
|
+
|| plugins.nodeUtil.types.isProxy(valueArg)
|
|
1210
|
+
) {
|
|
1211
|
+
return false;
|
|
1212
|
+
}
|
|
1213
|
+
const valuePrototype = Object.getPrototypeOf(valueArg);
|
|
1214
|
+
if (
|
|
1215
|
+
valuePrototype === Date.prototype
|
|
1216
|
+
|| valuePrototype === RegExp.prototype
|
|
1217
|
+
) {
|
|
1218
|
+
return true;
|
|
1219
|
+
}
|
|
1220
|
+
if (ArrayBuffer.isView(valueArg)) {
|
|
1221
|
+
return true;
|
|
1222
|
+
}
|
|
1223
|
+
return getTrustedAtomicBsonScalarType(valueArg) !== undefined;
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
const allowedAtomicBsonScalarTypes = new Set([
|
|
1227
|
+
'ObjectId',
|
|
1228
|
+
'Binary',
|
|
1229
|
+
'Decimal128',
|
|
1230
|
+
'Long',
|
|
1231
|
+
'Int32',
|
|
1232
|
+
'Double',
|
|
1233
|
+
'Timestamp',
|
|
1234
|
+
'MinKey',
|
|
1235
|
+
'MaxKey',
|
|
1236
|
+
]);
|
|
1237
|
+
|
|
1238
|
+
const requireSafeAtomicEqualityScalar = (
|
|
1239
|
+
valueArg: unknown,
|
|
1240
|
+
labelArg: string,
|
|
1241
|
+
): void => {
|
|
1242
|
+
if (
|
|
1243
|
+
typeof valueArg === 'object'
|
|
1244
|
+
&& valueArg !== null
|
|
1245
|
+
&& plugins.nodeUtil.types.isProxy(valueArg)
|
|
1246
|
+
) {
|
|
1247
|
+
throw new SmartdataPersistenceError(
|
|
1248
|
+
'invalid_argument',
|
|
1249
|
+
`${labelArg} may not use a Proxy object.`,
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
if (valueArg instanceof RegExp) {
|
|
1253
|
+
throw new SmartdataPersistenceError(
|
|
1254
|
+
'invalid_argument',
|
|
1255
|
+
`${labelArg} may not use a regular expression as an equality value.`,
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
if (
|
|
1259
|
+
valueArg instanceof Date &&
|
|
1260
|
+
Object.getPrototypeOf(valueArg) !== Date.prototype
|
|
1261
|
+
) {
|
|
1262
|
+
throw new SmartdataPersistenceError(
|
|
1263
|
+
'invalid_argument',
|
|
1264
|
+
`${labelArg} may not use a Date subclass with custom BSON behavior.`,
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
const bsonType = getTrustedAtomicBsonScalarType(valueArg);
|
|
1268
|
+
if (
|
|
1269
|
+
hasAtomicBsonTypeClaim(valueArg)
|
|
1270
|
+
&& (
|
|
1271
|
+
!bsonType
|
|
1272
|
+
|| !allowedAtomicBsonScalarTypes.has(bsonType)
|
|
1273
|
+
)
|
|
1274
|
+
) {
|
|
1275
|
+
throw new SmartdataPersistenceError(
|
|
1276
|
+
'invalid_argument',
|
|
1277
|
+
`${labelArg} uses an unsupported BSON equality scalar.`,
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
const requireUsableIdentityValue = (
|
|
1283
|
+
valueArg: unknown,
|
|
1284
|
+
labelArg: string,
|
|
1285
|
+
valueTypeArg: TSmartdataIdentityValueType,
|
|
1286
|
+
): void => {
|
|
1287
|
+
if (valueTypeArg === 'positiveSafeInteger') {
|
|
1288
|
+
if (
|
|
1289
|
+
typeof valueArg === 'number'
|
|
1290
|
+
&& Number.isSafeInteger(valueArg)
|
|
1291
|
+
&& valueArg > 0
|
|
1292
|
+
) {
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
throw new SmartdataPersistenceError(
|
|
1296
|
+
'invalid_argument',
|
|
1297
|
+
`${labelArg} must contain a positive safe integer identity value.`,
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
if (typeof valueArg !== 'string' || valueArg.trim().length === 0) {
|
|
1301
|
+
throw new SmartdataPersistenceError(
|
|
1302
|
+
'invalid_argument',
|
|
1303
|
+
`${labelArg} must contain a non-empty string identity value.`,
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
|
|
1308
|
+
const assertUsableIdentityValues = (
|
|
1309
|
+
documentArg: Record<string, unknown>,
|
|
1310
|
+
identityFieldsArg: Iterable<string>,
|
|
1311
|
+
identityValueTypesArg?: Readonly<Record<string, TSmartdataIdentityValueType>>,
|
|
1312
|
+
): void => {
|
|
1313
|
+
for (const identityField of identityFieldsArg) {
|
|
1314
|
+
requireUsableIdentityValue(
|
|
1315
|
+
documentArg[identityField],
|
|
1316
|
+
`Identity field "${identityField}"`,
|
|
1317
|
+
identityValueTypesArg?.[identityField] ?? 'string',
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Validates the explicit `$eq` operand used for whole-object compare-and-set
|
|
1324
|
+
* filters. Direct nested object selectors remain forbidden; the operator is
|
|
1325
|
+
* required so object equality cannot be confused with query structure.
|
|
1326
|
+
*/
|
|
1327
|
+
const assertOnlyDataProperties = (
|
|
1328
|
+
valueArg: object,
|
|
1329
|
+
labelArg: string,
|
|
1330
|
+
arrayArg: boolean,
|
|
1331
|
+
): void => {
|
|
1332
|
+
for (const ownKey of Reflect.ownKeys(valueArg)) {
|
|
1333
|
+
if (typeof ownKey === 'symbol') {
|
|
1334
|
+
throw new SmartdataPersistenceError(
|
|
1335
|
+
'invalid_argument',
|
|
1336
|
+
`${labelArg} may not contain symbol properties.`,
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
if (arrayArg && ownKey === 'length') {
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
const descriptor = Object.getOwnPropertyDescriptor(valueArg, ownKey);
|
|
1343
|
+
if (
|
|
1344
|
+
!descriptor ||
|
|
1345
|
+
!('value' in descriptor) ||
|
|
1346
|
+
typeof descriptor.value === 'function'
|
|
1347
|
+
) {
|
|
1348
|
+
throw new SmartdataPersistenceError(
|
|
1349
|
+
'invalid_argument',
|
|
1350
|
+
`${labelArg} may contain only inert data properties.`,
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
if (!descriptor.enumerable && !arrayArg) {
|
|
1354
|
+
throw new SmartdataPersistenceError(
|
|
1355
|
+
'invalid_argument',
|
|
1356
|
+
`${labelArg} may not contain hidden data properties.`,
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
|
|
1362
|
+
const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype);
|
|
1363
|
+
const typedArrayBufferGetter = Object.getOwnPropertyDescriptor(
|
|
1364
|
+
typedArrayPrototype,
|
|
1365
|
+
'buffer',
|
|
1366
|
+
)?.get;
|
|
1367
|
+
const typedArrayByteOffsetGetter = Object.getOwnPropertyDescriptor(
|
|
1368
|
+
typedArrayPrototype,
|
|
1369
|
+
'byteOffset',
|
|
1370
|
+
)?.get;
|
|
1371
|
+
const typedArrayByteLengthGetter = Object.getOwnPropertyDescriptor(
|
|
1372
|
+
typedArrayPrototype,
|
|
1373
|
+
'byteLength',
|
|
1374
|
+
)?.get;
|
|
1375
|
+
const dataViewBufferGetter = Object.getOwnPropertyDescriptor(
|
|
1376
|
+
DataView.prototype,
|
|
1377
|
+
'buffer',
|
|
1378
|
+
)?.get;
|
|
1379
|
+
const dataViewByteOffsetGetter = Object.getOwnPropertyDescriptor(
|
|
1380
|
+
DataView.prototype,
|
|
1381
|
+
'byteOffset',
|
|
1382
|
+
)?.get;
|
|
1383
|
+
const dataViewByteLengthGetter = Object.getOwnPropertyDescriptor(
|
|
1384
|
+
DataView.prototype,
|
|
1385
|
+
'byteLength',
|
|
1386
|
+
)?.get;
|
|
1387
|
+
const regExpSourceGetter = Object.getOwnPropertyDescriptor(
|
|
1388
|
+
RegExp.prototype,
|
|
1389
|
+
'source',
|
|
1390
|
+
)?.get;
|
|
1391
|
+
const regExpFlagGetters = [
|
|
1392
|
+
['d', 'hasIndices'],
|
|
1393
|
+
['g', 'global'],
|
|
1394
|
+
['i', 'ignoreCase'],
|
|
1395
|
+
['m', 'multiline'],
|
|
1396
|
+
['s', 'dotAll'],
|
|
1397
|
+
['u', 'unicode'],
|
|
1398
|
+
['v', 'unicodeSets'],
|
|
1399
|
+
['y', 'sticky'],
|
|
1400
|
+
].map(([flag, property]) => ({
|
|
1401
|
+
flag,
|
|
1402
|
+
getter: Object.getOwnPropertyDescriptor(
|
|
1403
|
+
RegExp.prototype,
|
|
1404
|
+
property,
|
|
1405
|
+
)?.get,
|
|
1406
|
+
}));
|
|
1407
|
+
|
|
1408
|
+
const getIntrinsicArrayBufferViewBytes = (
|
|
1409
|
+
valueArg: ArrayBufferView,
|
|
1410
|
+
labelArg: string,
|
|
1411
|
+
): Uint8Array => {
|
|
1412
|
+
const readView = (
|
|
1413
|
+
bufferGetterArg: ((this: object) => ArrayBufferLike) | undefined,
|
|
1414
|
+
byteOffsetGetterArg: ((this: object) => number) | undefined,
|
|
1415
|
+
byteLengthGetterArg: ((this: object) => number) | undefined,
|
|
1416
|
+
): Uint8Array | undefined => {
|
|
1417
|
+
if (
|
|
1418
|
+
!bufferGetterArg
|
|
1419
|
+
|| !byteOffsetGetterArg
|
|
1420
|
+
|| !byteLengthGetterArg
|
|
1421
|
+
) {
|
|
1422
|
+
return undefined;
|
|
1423
|
+
}
|
|
1424
|
+
try {
|
|
1425
|
+
const buffer = Reflect.apply(bufferGetterArg, valueArg, []);
|
|
1426
|
+
const byteOffset = Reflect.apply(byteOffsetGetterArg, valueArg, []);
|
|
1427
|
+
const byteLength = Reflect.apply(byteLengthGetterArg, valueArg, []);
|
|
1428
|
+
return new Uint8Array(buffer, byteOffset, byteLength);
|
|
1429
|
+
} catch {
|
|
1430
|
+
return undefined;
|
|
1431
|
+
}
|
|
1432
|
+
};
|
|
1433
|
+
const bytes =
|
|
1434
|
+
readView(
|
|
1435
|
+
typedArrayBufferGetter,
|
|
1436
|
+
typedArrayByteOffsetGetter,
|
|
1437
|
+
typedArrayByteLengthGetter,
|
|
1438
|
+
)
|
|
1439
|
+
|| readView(
|
|
1440
|
+
dataViewBufferGetter,
|
|
1441
|
+
dataViewByteOffsetGetter,
|
|
1442
|
+
dataViewByteLengthGetter,
|
|
1443
|
+
);
|
|
1444
|
+
if (!bytes) {
|
|
1445
|
+
throw new SmartdataPersistenceError(
|
|
1446
|
+
'invalid_argument',
|
|
1447
|
+
`${labelArg} contains an invalid ArrayBuffer view.`,
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
return bytes;
|
|
1451
|
+
};
|
|
1452
|
+
|
|
1453
|
+
const createInertAtomicBsonScalarSnapshot = (
|
|
1454
|
+
valueArg: unknown,
|
|
1455
|
+
labelArg: string,
|
|
1456
|
+
): unknown => {
|
|
1457
|
+
if (valueArg instanceof Date) {
|
|
1458
|
+
if (
|
|
1459
|
+
Object.getPrototypeOf(valueArg) !== Date.prototype ||
|
|
1460
|
+
Reflect.ownKeys(valueArg).length !== 0
|
|
1461
|
+
) {
|
|
1462
|
+
throw new SmartdataPersistenceError(
|
|
1463
|
+
'invalid_argument',
|
|
1464
|
+
`${labelArg} may not use a customized Date value.`,
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
const snapshot = new Date(valueArg.getTime());
|
|
1468
|
+
Object.defineProperty(snapshot, 'toBSON', {
|
|
1469
|
+
value: undefined,
|
|
1470
|
+
enumerable: false,
|
|
1471
|
+
});
|
|
1472
|
+
return snapshot;
|
|
1473
|
+
}
|
|
1474
|
+
if (ArrayBuffer.isView(valueArg)) {
|
|
1475
|
+
assertOnlyDataProperties(valueArg, labelArg, false);
|
|
1476
|
+
const snapshot = Uint8Array.from(
|
|
1477
|
+
getIntrinsicArrayBufferViewBytes(valueArg, labelArg),
|
|
1478
|
+
);
|
|
1479
|
+
Object.defineProperty(snapshot, 'toBSON', {
|
|
1480
|
+
value: undefined,
|
|
1481
|
+
enumerable: false,
|
|
1482
|
+
});
|
|
1483
|
+
return snapshot;
|
|
1484
|
+
}
|
|
1485
|
+
const originalBsonType = getTrustedAtomicBsonScalarType(valueArg);
|
|
1486
|
+
if (!originalBsonType) {
|
|
1487
|
+
throw new SmartdataPersistenceError(
|
|
1488
|
+
'invalid_argument',
|
|
1489
|
+
`${labelArg} contains an untrusted BSON scalar.`,
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
assertOnlyDataProperties(valueArg as object, labelArg, false);
|
|
1493
|
+
for (
|
|
1494
|
+
let prototype = valueArg as object | null;
|
|
1495
|
+
prototype;
|
|
1496
|
+
prototype = Object.getPrototypeOf(prototype)
|
|
1497
|
+
) {
|
|
1498
|
+
if (Object.getOwnPropertyDescriptor(prototype, 'toBSON')) {
|
|
1499
|
+
throw new SmartdataPersistenceError(
|
|
1500
|
+
'invalid_argument',
|
|
1501
|
+
`${labelArg} may not use a custom BSON encoder.`,
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
try {
|
|
1506
|
+
const snapshotDocument = plugins.mongodb.BSON.deserialize(
|
|
1507
|
+
plugins.mongodb.BSON.serialize({ value: valueArg }),
|
|
1508
|
+
{
|
|
1509
|
+
promoteBuffers: false,
|
|
1510
|
+
promoteValues: false,
|
|
1511
|
+
},
|
|
1512
|
+
);
|
|
1513
|
+
const snapshot = snapshotDocument.value as object;
|
|
1514
|
+
if (getTrustedAtomicBsonScalarType(snapshot) !== originalBsonType) {
|
|
1515
|
+
throw new SmartdataPersistenceError(
|
|
1516
|
+
'invalid_argument',
|
|
1517
|
+
`${labelArg} changed BSON scalar type while being captured.`,
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
Object.defineProperty(snapshot, 'toBSON', {
|
|
1521
|
+
value: undefined,
|
|
1522
|
+
enumerable: false,
|
|
1523
|
+
});
|
|
1524
|
+
return snapshot;
|
|
1525
|
+
} catch (cause) {
|
|
1526
|
+
throw new SmartdataPersistenceError(
|
|
1527
|
+
'invalid_argument',
|
|
1528
|
+
`${labelArg} contains an invalid BSON scalar.`,
|
|
1529
|
+
{ cause },
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
const createSafeAtomicExactObjectEqualitySnapshot = (
|
|
1535
|
+
valueArg: unknown,
|
|
1536
|
+
labelArg: string,
|
|
1537
|
+
seenArg = new WeakSet<object>(),
|
|
1538
|
+
): unknown => {
|
|
1539
|
+
if (valueArg === undefined) {
|
|
1540
|
+
throw new SmartdataPersistenceError(
|
|
1541
|
+
'invalid_argument',
|
|
1542
|
+
`${labelArg} may not contain undefined.`,
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
if (
|
|
1546
|
+
typeof valueArg === 'function'
|
|
1547
|
+
|| typeof valueArg === 'symbol'
|
|
1548
|
+
) {
|
|
1549
|
+
throw new SmartdataPersistenceError(
|
|
1550
|
+
'invalid_argument',
|
|
1551
|
+
`${labelArg} contains a value that cannot be encoded as safe BSON.`,
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
if (typeof valueArg !== 'object' || valueArg === null) {
|
|
1555
|
+
return valueArg;
|
|
1556
|
+
}
|
|
1557
|
+
if (plugins.nodeUtil.types.isProxy(valueArg)) {
|
|
1558
|
+
throw new SmartdataPersistenceError(
|
|
1559
|
+
'invalid_argument',
|
|
1560
|
+
`${labelArg} may not contain Proxy objects.`,
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
if (seenArg.has(valueArg)) {
|
|
1564
|
+
throw new SmartdataPersistenceError(
|
|
1565
|
+
'invalid_argument',
|
|
1566
|
+
`${labelArg} may not contain a circular object graph.`,
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
if (isAtomicMongoScalar(valueArg)) {
|
|
1570
|
+
requireSafeAtomicEqualityScalar(valueArg, labelArg);
|
|
1571
|
+
return createInertAtomicBsonScalarSnapshot(valueArg, labelArg);
|
|
1572
|
+
}
|
|
1573
|
+
if (!Array.isArray(valueArg) && !isPlainObject(valueArg)) {
|
|
1574
|
+
throw new SmartdataPersistenceError(
|
|
1575
|
+
'invalid_argument',
|
|
1576
|
+
`${labelArg} contains an object with a custom BSON encoding boundary.`,
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
if (
|
|
1580
|
+
Array.isArray(valueArg) &&
|
|
1581
|
+
Object.getPrototypeOf(valueArg) !== Array.prototype
|
|
1582
|
+
) {
|
|
1583
|
+
throw new SmartdataPersistenceError(
|
|
1584
|
+
'invalid_argument',
|
|
1585
|
+
`${labelArg} may not use an Array subclass.`,
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
assertOnlyDataProperties(valueArg, labelArg, Array.isArray(valueArg));
|
|
1589
|
+
seenArg.add(valueArg);
|
|
1590
|
+
if (Array.isArray(valueArg)) {
|
|
1591
|
+
const snapshot: unknown[] = [];
|
|
1592
|
+
for (let index = 0; index < valueArg.length; index++) {
|
|
1593
|
+
if (!Object.prototype.hasOwnProperty.call(valueArg, index)) {
|
|
1594
|
+
throw new SmartdataPersistenceError(
|
|
1595
|
+
'invalid_argument',
|
|
1596
|
+
`${labelArg} may not contain sparse array positions.`,
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
snapshot.push(
|
|
1600
|
+
createSafeAtomicExactObjectEqualitySnapshot(
|
|
1601
|
+
valueArg[index],
|
|
1602
|
+
labelArg,
|
|
1603
|
+
seenArg,
|
|
1604
|
+
),
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
seenArg.delete(valueArg);
|
|
1608
|
+
Object.defineProperty(snapshot, 'toBSON', {
|
|
1609
|
+
value: undefined,
|
|
1610
|
+
enumerable: false,
|
|
1611
|
+
});
|
|
1612
|
+
return snapshot;
|
|
1613
|
+
}
|
|
1614
|
+
const snapshot: Record<string, unknown> = {};
|
|
1615
|
+
for (const [keyArg, entryArg] of Object.entries(valueArg)) {
|
|
1616
|
+
if (
|
|
1617
|
+
unsafePersistedObjectKeys.has(keyArg)
|
|
1618
|
+
|| keyArg === 'toBSON'
|
|
1619
|
+
|| keyArg === '_bsontype'
|
|
1620
|
+
|| keyArg.startsWith('$')
|
|
1621
|
+
|| keyArg.includes('.')
|
|
1622
|
+
|| keyArg.includes('\0')
|
|
1623
|
+
) {
|
|
1624
|
+
throw new SmartdataPersistenceError(
|
|
1625
|
+
'invalid_argument',
|
|
1626
|
+
`${labelArg} contains unsafe object key "${keyArg}".`,
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
Object.defineProperty(snapshot, keyArg, {
|
|
1630
|
+
value: createSafeAtomicExactObjectEqualitySnapshot(
|
|
1631
|
+
entryArg,
|
|
1632
|
+
labelArg,
|
|
1633
|
+
seenArg,
|
|
1634
|
+
),
|
|
1635
|
+
enumerable: true,
|
|
1636
|
+
writable: false,
|
|
1637
|
+
configurable: false,
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
seenArg.delete(valueArg);
|
|
1641
|
+
Object.defineProperty(snapshot, 'toBSON', {
|
|
1642
|
+
value: undefined,
|
|
1643
|
+
enumerable: false,
|
|
1644
|
+
});
|
|
1645
|
+
return snapshot;
|
|
1646
|
+
};
|
|
1647
|
+
|
|
1648
|
+
const createSafeAtomicFilterValueSnapshot = (
|
|
1649
|
+
valueArg: unknown,
|
|
1650
|
+
labelArg: string,
|
|
1651
|
+
): unknown => {
|
|
1652
|
+
if (
|
|
1653
|
+
valueArg === undefined
|
|
1654
|
+
|| typeof valueArg === 'function'
|
|
1655
|
+
|| typeof valueArg === 'symbol'
|
|
1656
|
+
) {
|
|
1657
|
+
throw new SmartdataPersistenceError(
|
|
1658
|
+
'invalid_argument',
|
|
1659
|
+
`${labelArg} contains a value that cannot be encoded as safe BSON.`,
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
if (
|
|
1663
|
+
typeof valueArg === 'object'
|
|
1664
|
+
&& valueArg !== null
|
|
1665
|
+
&& plugins.nodeUtil.types.isProxy(valueArg)
|
|
1666
|
+
) {
|
|
1667
|
+
throw new SmartdataPersistenceError(
|
|
1668
|
+
'invalid_argument',
|
|
1669
|
+
`${labelArg} may not use a Proxy object.`,
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
if (Array.isArray(valueArg)) {
|
|
1673
|
+
if (Object.getPrototypeOf(valueArg) !== Array.prototype) {
|
|
1674
|
+
throw new SmartdataPersistenceError(
|
|
1675
|
+
'invalid_argument',
|
|
1676
|
+
`${labelArg} may not use an Array subclass.`,
|
|
1677
|
+
);
|
|
1678
|
+
}
|
|
1679
|
+
assertOnlyDataProperties(valueArg, labelArg, true);
|
|
1680
|
+
const snapshot: unknown[] = [];
|
|
1681
|
+
for (let index = 0; index < valueArg.length; index++) {
|
|
1682
|
+
if (!Object.prototype.hasOwnProperty.call(valueArg, index)) {
|
|
1683
|
+
throw new SmartdataPersistenceError(
|
|
1684
|
+
'invalid_argument',
|
|
1685
|
+
`${labelArg} may not contain sparse array positions.`,
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
snapshot.push(
|
|
1689
|
+
createSafeAtomicFilterValueSnapshot(valueArg[index], labelArg),
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
return snapshot;
|
|
1693
|
+
}
|
|
1694
|
+
if (typeof valueArg !== 'object' || valueArg === null) {
|
|
1695
|
+
return valueArg;
|
|
1696
|
+
}
|
|
1697
|
+
requireSafeAtomicEqualityScalar(valueArg, labelArg);
|
|
1698
|
+
if (!isAtomicMongoScalar(valueArg)) {
|
|
1699
|
+
throw new SmartdataPersistenceError(
|
|
1700
|
+
'invalid_argument',
|
|
1701
|
+
`${labelArg} must be a scalar BSON value.`,
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
return createInertAtomicBsonScalarSnapshot(valueArg, labelArg);
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1707
|
+
const createSafeAtomicRegExpSnapshot = (
|
|
1708
|
+
valueArg: RegExp,
|
|
1709
|
+
labelArg: string,
|
|
1710
|
+
): RegExp => {
|
|
1711
|
+
if (
|
|
1712
|
+
plugins.nodeUtil.types.isProxy(valueArg)
|
|
1713
|
+
|| Object.getPrototypeOf(valueArg) !== RegExp.prototype
|
|
1714
|
+
|| !regExpSourceGetter
|
|
1715
|
+
) {
|
|
1716
|
+
throw new SmartdataPersistenceError(
|
|
1717
|
+
'invalid_argument',
|
|
1718
|
+
`${labelArg} may not use a Proxy or RegExp subclass.`,
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
const source = Reflect.apply(regExpSourceGetter, valueArg, []);
|
|
1722
|
+
const flags = regExpFlagGetters
|
|
1723
|
+
.filter(
|
|
1724
|
+
({ getter }) =>
|
|
1725
|
+
getter && Reflect.apply(getter, valueArg, []),
|
|
1726
|
+
)
|
|
1727
|
+
.map(({ flag }) => flag)
|
|
1728
|
+
.join('');
|
|
1729
|
+
return new RegExp(source, flags);
|
|
1730
|
+
};
|
|
1731
|
+
|
|
1732
|
+
const normalizeAtomicFilterCondition = (
|
|
1733
|
+
valueArg: unknown,
|
|
1734
|
+
labelArg: string,
|
|
1735
|
+
identityValueTypeArg?: TSmartdataIdentityValueType,
|
|
1736
|
+
): unknown => {
|
|
1737
|
+
if (valueArg === undefined) {
|
|
1738
|
+
throw new SmartdataPersistenceError(
|
|
1739
|
+
'invalid_argument',
|
|
1740
|
+
`${labelArg} may not contain undefined.`,
|
|
1741
|
+
);
|
|
1742
|
+
}
|
|
1743
|
+
if (
|
|
1744
|
+
typeof valueArg === 'object'
|
|
1745
|
+
&& valueArg !== null
|
|
1746
|
+
&& plugins.nodeUtil.types.isProxy(valueArg)
|
|
1747
|
+
) {
|
|
1748
|
+
throw new SmartdataPersistenceError(
|
|
1749
|
+
'invalid_argument',
|
|
1750
|
+
`${labelArg} may not use a Proxy object.`,
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
if (!isPlainObject(valueArg) || isAtomicMongoScalar(valueArg)) {
|
|
1754
|
+
requireSafeAtomicEqualityScalar(valueArg, labelArg);
|
|
1755
|
+
if (identityValueTypeArg) {
|
|
1756
|
+
requireUsableIdentityValue(
|
|
1757
|
+
valueArg,
|
|
1758
|
+
labelArg,
|
|
1759
|
+
identityValueTypeArg,
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
return createSafeAtomicFilterValueSnapshot(valueArg, labelArg);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
assertOnlyDataProperties(valueArg, labelArg, false);
|
|
1766
|
+
const entries = Object.entries(valueArg);
|
|
1767
|
+
if (entries.length === 0) {
|
|
1768
|
+
throw new SmartdataPersistenceError(
|
|
1769
|
+
'invalid_argument',
|
|
1770
|
+
`${labelArg} may not be an empty object.`,
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
const operatorEntries = entries.filter(([key]) => key.startsWith('$'));
|
|
1774
|
+
if (operatorEntries.length === 0) {
|
|
1775
|
+
throw new SmartdataPersistenceError(
|
|
1776
|
+
'invalid_argument',
|
|
1777
|
+
`${labelArg} must use supported field operators instead of a nested object selector.`,
|
|
1778
|
+
);
|
|
1779
|
+
}
|
|
1780
|
+
if (operatorEntries.length !== entries.length) {
|
|
1781
|
+
throw new SmartdataPersistenceError(
|
|
1782
|
+
'invalid_argument',
|
|
1783
|
+
`${labelArg} may not mix operator and literal object keys.`,
|
|
1784
|
+
);
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
const normalized: Record<string, unknown> = {};
|
|
1788
|
+
for (const [operator, operand] of operatorEntries) {
|
|
1789
|
+
if (!atomicFilterOperators.has(operator)) {
|
|
1790
|
+
throw new SmartdataPersistenceError(
|
|
1791
|
+
'invalid_argument',
|
|
1792
|
+
`${labelArg} uses unsupported operator "${operator}".`,
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
if (atomicFilterComparisonOperators.has(operator)) {
|
|
1796
|
+
if (
|
|
1797
|
+
operator === '$eq'
|
|
1798
|
+
&& typeof operand === 'object'
|
|
1799
|
+
&& operand !== null
|
|
1800
|
+
&& !isAtomicMongoScalar(operand)
|
|
1801
|
+
) {
|
|
1802
|
+
if (identityValueTypeArg) {
|
|
1803
|
+
requireUsableIdentityValue(
|
|
1804
|
+
operand,
|
|
1805
|
+
`${labelArg} ${operator}`,
|
|
1806
|
+
identityValueTypeArg,
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
normalized[operator] =
|
|
1810
|
+
createSafeAtomicExactObjectEqualitySnapshot(
|
|
1811
|
+
operand,
|
|
1812
|
+
`${labelArg} ${operator}`,
|
|
1813
|
+
);
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
if (identityValueTypeArg) {
|
|
1817
|
+
requireUsableIdentityValue(
|
|
1818
|
+
operand,
|
|
1819
|
+
`${labelArg} ${operator}`,
|
|
1820
|
+
identityValueTypeArg,
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
normalized[operator] = createSafeAtomicFilterValueSnapshot(
|
|
1824
|
+
operand,
|
|
1825
|
+
`${labelArg} ${operator}`,
|
|
1826
|
+
);
|
|
1827
|
+
continue;
|
|
1828
|
+
}
|
|
1829
|
+
if (atomicFilterArrayOperators.has(operator)) {
|
|
1830
|
+
if (
|
|
1831
|
+
!Array.isArray(operand)
|
|
1832
|
+
|| plugins.nodeUtil.types.isProxy(operand)
|
|
1833
|
+
|| Object.getPrototypeOf(operand) !== Array.prototype
|
|
1834
|
+
) {
|
|
1835
|
+
throw new SmartdataPersistenceError(
|
|
1836
|
+
'invalid_argument',
|
|
1837
|
+
`${labelArg} ${operator} must be an ordinary non-empty array.`,
|
|
1838
|
+
);
|
|
1839
|
+
}
|
|
1840
|
+
assertOnlyDataProperties(
|
|
1841
|
+
operand,
|
|
1842
|
+
`${labelArg} ${operator}`,
|
|
1843
|
+
true,
|
|
1844
|
+
);
|
|
1845
|
+
if (operand.length === 0) {
|
|
1846
|
+
throw new SmartdataPersistenceError(
|
|
1847
|
+
'invalid_argument',
|
|
1848
|
+
`${labelArg} ${operator} must be an ordinary non-empty array.`,
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
if (identityValueTypeArg) {
|
|
1852
|
+
for (const entry of operand) {
|
|
1853
|
+
requireUsableIdentityValue(
|
|
1854
|
+
entry,
|
|
1855
|
+
`${labelArg} ${operator}`,
|
|
1856
|
+
identityValueTypeArg,
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
normalized[operator] = createSafeAtomicFilterValueSnapshot(
|
|
1861
|
+
operand,
|
|
1862
|
+
`${labelArg} ${operator}`,
|
|
1863
|
+
);
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
if (operator === '$exists') {
|
|
1867
|
+
if (typeof operand !== 'boolean') {
|
|
1868
|
+
throw new SmartdataPersistenceError(
|
|
1869
|
+
'invalid_argument',
|
|
1870
|
+
`${labelArg} $exists must be a boolean.`,
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
normalized[operator] = operand;
|
|
1874
|
+
continue;
|
|
1875
|
+
}
|
|
1876
|
+
if (operator === '$type') {
|
|
1877
|
+
if (!atomicFilterBsonTypes.has(operand)) {
|
|
1878
|
+
throw new SmartdataPersistenceError(
|
|
1879
|
+
'invalid_argument',
|
|
1880
|
+
`${labelArg} $type must use a MongoDB BSON type alias or numeric code.`,
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
normalized[operator] = operand;
|
|
1884
|
+
continue;
|
|
1885
|
+
}
|
|
1886
|
+
if (operator === '$regex') {
|
|
1887
|
+
if (typeof operand === 'string' && operand.length > 0) {
|
|
1888
|
+
normalized[operator] = operand;
|
|
1889
|
+
continue;
|
|
1890
|
+
}
|
|
1891
|
+
if (typeof operand !== 'object' || operand === null) {
|
|
1892
|
+
throw new SmartdataPersistenceError(
|
|
1893
|
+
'invalid_argument',
|
|
1894
|
+
`${labelArg} $regex must be a non-empty string or RegExp.`,
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
normalized[operator] = createSafeAtomicRegExpSnapshot(
|
|
1898
|
+
operand as RegExp,
|
|
1899
|
+
`${labelArg} ${operator}`,
|
|
1900
|
+
);
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1903
|
+
if (operator === '$options') {
|
|
1904
|
+
if (
|
|
1905
|
+
typeof operand !== 'string' ||
|
|
1906
|
+
!/^[imsx]*$/.test(operand) ||
|
|
1907
|
+
!('$regex' in valueArg)
|
|
1908
|
+
) {
|
|
1909
|
+
throw new SmartdataPersistenceError(
|
|
1910
|
+
'invalid_argument',
|
|
1911
|
+
`${labelArg} $options must contain only imsx flags and accompany $regex.`,
|
|
1912
|
+
);
|
|
1913
|
+
}
|
|
1914
|
+
normalized[operator] = operand;
|
|
1915
|
+
continue;
|
|
1916
|
+
}
|
|
1917
|
+
if (operator === '$not') {
|
|
1918
|
+
if (
|
|
1919
|
+
(
|
|
1920
|
+
typeof operand === 'object'
|
|
1921
|
+
&& operand !== null
|
|
1922
|
+
&& plugins.nodeUtil.types.isProxy(operand)
|
|
1923
|
+
)
|
|
1924
|
+
||
|
|
1925
|
+
!isPlainObject(operand) ||
|
|
1926
|
+
Object.keys(operand).length === 0 ||
|
|
1927
|
+
Object.keys(operand).some((key) => !key.startsWith('$'))
|
|
1928
|
+
) {
|
|
1929
|
+
throw new SmartdataPersistenceError(
|
|
1930
|
+
'invalid_argument',
|
|
1931
|
+
`${labelArg} $not must contain a non-empty operator object.`,
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
assertOnlyDataProperties(operand, `${labelArg} $not`, false);
|
|
1935
|
+
normalized[operator] = normalizeAtomicFilterCondition(
|
|
1936
|
+
operand,
|
|
1937
|
+
`${labelArg} $not`,
|
|
1938
|
+
identityValueTypeArg,
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
return normalized;
|
|
1943
|
+
};
|
|
1944
|
+
|
|
1945
|
+
const atomicNumericExpressionMaximumDepth = 8;
|
|
1946
|
+
const atomicNumericExpressionMaximumAddOperands = 16;
|
|
1947
|
+
const atomicNumericExpressionMaximumLeafOperands = 64;
|
|
1948
|
+
|
|
1949
|
+
const getAtomicExpressionProperty = (
|
|
1950
|
+
valueArg: unknown,
|
|
1951
|
+
expectedKeyArg: '$lte' | '$add',
|
|
1952
|
+
labelArg: string,
|
|
1953
|
+
): unknown => {
|
|
1954
|
+
if (
|
|
1955
|
+
typeof valueArg !== 'object'
|
|
1956
|
+
|| valueArg === null
|
|
1957
|
+
|| Array.isArray(valueArg)
|
|
1958
|
+
|| plugins.nodeUtil.types.isProxy(valueArg)
|
|
1959
|
+
|| Object.getPrototypeOf(valueArg) !== Object.prototype
|
|
1960
|
+
) {
|
|
1961
|
+
throw new SmartdataPersistenceError(
|
|
1962
|
+
'invalid_argument',
|
|
1963
|
+
`${labelArg} must be an inert ordinary object containing only ${expectedKeyArg}.`,
|
|
1964
|
+
);
|
|
1965
|
+
}
|
|
1966
|
+
const keys = Reflect.ownKeys(valueArg);
|
|
1967
|
+
if (keys.length !== 1 || keys[0] !== expectedKeyArg) {
|
|
1968
|
+
throw new SmartdataPersistenceError(
|
|
1969
|
+
'invalid_argument',
|
|
1970
|
+
`${labelArg} must contain exactly the ${expectedKeyArg} operator.`,
|
|
1971
|
+
);
|
|
1972
|
+
}
|
|
1973
|
+
const descriptor = Object.getOwnPropertyDescriptor(valueArg, expectedKeyArg);
|
|
1974
|
+
if (
|
|
1975
|
+
!descriptor
|
|
1976
|
+
|| !('value' in descriptor)
|
|
1977
|
+
|| descriptor.enumerable !== true
|
|
1978
|
+
) {
|
|
1979
|
+
throw new SmartdataPersistenceError(
|
|
1980
|
+
'invalid_argument',
|
|
1981
|
+
`${labelArg} ${expectedKeyArg} must be an inert enumerable data property.`,
|
|
1982
|
+
);
|
|
1983
|
+
}
|
|
1984
|
+
return descriptor.value;
|
|
1985
|
+
};
|
|
1986
|
+
|
|
1987
|
+
const getAtomicExpressionArrayValues = (
|
|
1988
|
+
valueArg: unknown,
|
|
1989
|
+
minimumLengthArg: number,
|
|
1990
|
+
maximumLengthArg: number,
|
|
1991
|
+
labelArg: string,
|
|
1992
|
+
): unknown[] => {
|
|
1993
|
+
if (
|
|
1994
|
+
typeof valueArg !== 'object'
|
|
1995
|
+
|| valueArg === null
|
|
1996
|
+
|| plugins.nodeUtil.types.isProxy(valueArg)
|
|
1997
|
+
|| !Array.isArray(valueArg)
|
|
1998
|
+
|| Object.getPrototypeOf(valueArg) !== Array.prototype
|
|
1999
|
+
) {
|
|
2000
|
+
throw new SmartdataPersistenceError(
|
|
2001
|
+
'invalid_argument',
|
|
2002
|
+
`${labelArg} must be an inert ordinary array.`,
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
const lengthDescriptor = Object.getOwnPropertyDescriptor(valueArg, 'length');
|
|
2006
|
+
const length = lengthDescriptor && 'value' in lengthDescriptor
|
|
2007
|
+
? lengthDescriptor.value
|
|
2008
|
+
: undefined;
|
|
2009
|
+
if (
|
|
2010
|
+
!Number.isSafeInteger(length)
|
|
2011
|
+
|| length < minimumLengthArg
|
|
2012
|
+
|| length > maximumLengthArg
|
|
2013
|
+
) {
|
|
2014
|
+
throw new SmartdataPersistenceError(
|
|
2015
|
+
'invalid_argument',
|
|
2016
|
+
`${labelArg} must contain between ${minimumLengthArg} and ${maximumLengthArg} operands.`,
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
const ownKeys = Reflect.ownKeys(valueArg);
|
|
2020
|
+
if (ownKeys.length !== length + 1 || !ownKeys.includes('length')) {
|
|
2021
|
+
throw new SmartdataPersistenceError(
|
|
2022
|
+
'invalid_argument',
|
|
2023
|
+
`${labelArg} may not contain sparse positions or extra properties.`,
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
2026
|
+
const values: unknown[] = [];
|
|
2027
|
+
for (let index = 0; index < length; index++) {
|
|
2028
|
+
const key = String(index);
|
|
2029
|
+
const descriptor = Object.getOwnPropertyDescriptor(valueArg, key);
|
|
2030
|
+
if (
|
|
2031
|
+
!descriptor
|
|
2032
|
+
|| !('value' in descriptor)
|
|
2033
|
+
|| descriptor.enumerable !== true
|
|
2034
|
+
) {
|
|
2035
|
+
throw new SmartdataPersistenceError(
|
|
2036
|
+
'invalid_argument',
|
|
2037
|
+
`${labelArg} may contain only dense enumerable data operands.`,
|
|
2038
|
+
);
|
|
2039
|
+
}
|
|
2040
|
+
values.push(descriptor.value);
|
|
2041
|
+
}
|
|
2042
|
+
return values;
|
|
2043
|
+
};
|
|
2044
|
+
|
|
2045
|
+
interface INormalizedAtomicNumericExpressionOperand {
|
|
2046
|
+
mongoExpression: unknown;
|
|
2047
|
+
referencedFields: Set<string>;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
const normalizeAtomicNumericExpression = (
|
|
2051
|
+
expressionArg: unknown,
|
|
2052
|
+
declaredRootsArg: Set<string>,
|
|
2053
|
+
numericRootsArg: Set<string>,
|
|
2054
|
+
serializedRootsArg: Set<string>,
|
|
2055
|
+
operationLabelArg: string,
|
|
2056
|
+
): plugins.mongodb.Document => {
|
|
2057
|
+
let leafOperandCount = 0;
|
|
2058
|
+
const normalizeOperand = (
|
|
2059
|
+
operandArg: unknown,
|
|
2060
|
+
depthArg: number,
|
|
2061
|
+
): INormalizedAtomicNumericExpressionOperand => {
|
|
2062
|
+
if (typeof operandArg === 'number') {
|
|
2063
|
+
leafOperandCount++;
|
|
2064
|
+
if (
|
|
2065
|
+
!Number.isFinite(operandArg)
|
|
2066
|
+
|| leafOperandCount > atomicNumericExpressionMaximumLeafOperands
|
|
2067
|
+
) {
|
|
2068
|
+
throw new SmartdataPersistenceError(
|
|
2069
|
+
'invalid_argument',
|
|
2070
|
+
`${operationLabelArg} $expr requires finite numeric literals and no more than ${atomicNumericExpressionMaximumLeafOperands} leaf operands.`,
|
|
2071
|
+
);
|
|
2072
|
+
}
|
|
2073
|
+
return {
|
|
2074
|
+
mongoExpression: operandArg,
|
|
2075
|
+
referencedFields: new Set(),
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
if (typeof operandArg === 'string') {
|
|
2079
|
+
leafOperandCount++;
|
|
2080
|
+
if (leafOperandCount > atomicNumericExpressionMaximumLeafOperands) {
|
|
2081
|
+
throw new SmartdataPersistenceError(
|
|
2082
|
+
'invalid_argument',
|
|
2083
|
+
`${operationLabelArg} $expr may not exceed ${atomicNumericExpressionMaximumLeafOperands} leaf operands.`,
|
|
2084
|
+
);
|
|
2085
|
+
}
|
|
2086
|
+
if (
|
|
2087
|
+
!operandArg.startsWith('$')
|
|
2088
|
+
|| operandArg.startsWith('$$')
|
|
2089
|
+
|| operandArg.length < 2
|
|
2090
|
+
) {
|
|
2091
|
+
throw new SmartdataPersistenceError(
|
|
2092
|
+
'invalid_argument',
|
|
2093
|
+
`${operationLabelArg} $expr field references must use exactly one leading $.`,
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
const field = operandArg.slice(1);
|
|
2097
|
+
if (field === 'toBSON' || field === '_bsontype') {
|
|
2098
|
+
throw new SmartdataPersistenceError(
|
|
2099
|
+
'invalid_argument',
|
|
2100
|
+
`${operationLabelArg} $expr field "${field}" is reserved.`,
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
const root = requireDeclaredPath(
|
|
2104
|
+
field,
|
|
2105
|
+
declaredRootsArg,
|
|
2106
|
+
`${operationLabelArg} $expr`,
|
|
2107
|
+
);
|
|
2108
|
+
if (field !== root) {
|
|
2109
|
+
throw new SmartdataPersistenceError(
|
|
2110
|
+
'invalid_argument',
|
|
2111
|
+
`${operationLabelArg} $expr may reference only declared top-level fields.`,
|
|
2112
|
+
);
|
|
2113
|
+
}
|
|
2114
|
+
if (!numericRootsArg.has(root)) {
|
|
2115
|
+
throw new SmartdataPersistenceError(
|
|
2116
|
+
'invalid_argument',
|
|
2117
|
+
`${operationLabelArg} $expr field "${root}" is not declared numeric.`,
|
|
2118
|
+
);
|
|
2119
|
+
}
|
|
2120
|
+
if (serializedRootsArg.has(root)) {
|
|
2121
|
+
throw new SmartdataPersistenceError(
|
|
2122
|
+
'unsupported_operation',
|
|
2123
|
+
`${operationLabelArg} $expr cannot reference serialized field "${root}".`,
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
2126
|
+
return {
|
|
2127
|
+
mongoExpression: `$${root}`,
|
|
2128
|
+
referencedFields: new Set([root]),
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
if (depthArg >= atomicNumericExpressionMaximumDepth) {
|
|
2132
|
+
throw new SmartdataPersistenceError(
|
|
2133
|
+
'invalid_argument',
|
|
2134
|
+
`${operationLabelArg} $expr exceeds the maximum $add depth of ${atomicNumericExpressionMaximumDepth}.`,
|
|
2135
|
+
);
|
|
2136
|
+
}
|
|
2137
|
+
const rawOperands = getAtomicExpressionArrayValues(
|
|
2138
|
+
getAtomicExpressionProperty(
|
|
2139
|
+
operandArg,
|
|
2140
|
+
'$add',
|
|
2141
|
+
`${operationLabelArg} $expr operand`,
|
|
2142
|
+
),
|
|
2143
|
+
2,
|
|
2144
|
+
atomicNumericExpressionMaximumAddOperands,
|
|
2145
|
+
`${operationLabelArg} $expr $add`,
|
|
2146
|
+
);
|
|
2147
|
+
const normalizedOperands = rawOperands.map((entryArg) =>
|
|
2148
|
+
normalizeOperand(entryArg, depthArg + 1),
|
|
2149
|
+
);
|
|
2150
|
+
return {
|
|
2151
|
+
mongoExpression: {
|
|
2152
|
+
$add: normalizedOperands.map((entryArg) => entryArg.mongoExpression),
|
|
2153
|
+
},
|
|
2154
|
+
referencedFields: new Set(
|
|
2155
|
+
normalizedOperands.flatMap((entryArg) => [
|
|
2156
|
+
...entryArg.referencedFields,
|
|
2157
|
+
]),
|
|
2158
|
+
),
|
|
2159
|
+
};
|
|
2160
|
+
};
|
|
2161
|
+
|
|
2162
|
+
const lteOperands = getAtomicExpressionArrayValues(
|
|
2163
|
+
getAtomicExpressionProperty(
|
|
2164
|
+
expressionArg,
|
|
2165
|
+
'$lte',
|
|
2166
|
+
`${operationLabelArg} $expr`,
|
|
2167
|
+
),
|
|
2168
|
+
2,
|
|
2169
|
+
2,
|
|
2170
|
+
`${operationLabelArg} $expr $lte`,
|
|
2171
|
+
);
|
|
2172
|
+
const left = normalizeOperand(lteOperands[0], 0);
|
|
2173
|
+
const right = normalizeOperand(lteOperands[1], 0);
|
|
2174
|
+
const referencedFields = new Set([
|
|
2175
|
+
...left.referencedFields,
|
|
2176
|
+
...right.referencedFields,
|
|
2177
|
+
]);
|
|
2178
|
+
const fieldGuards = [...referencedFields].map((fieldArg) => ({
|
|
2179
|
+
$cond: [
|
|
2180
|
+
{ $isNumber: `$${fieldArg}` },
|
|
2181
|
+
{
|
|
2182
|
+
$eq: [
|
|
2183
|
+
{ $subtract: [`$${fieldArg}`, `$${fieldArg}`] },
|
|
2184
|
+
0,
|
|
2185
|
+
],
|
|
2186
|
+
},
|
|
2187
|
+
false,
|
|
2188
|
+
],
|
|
2189
|
+
}));
|
|
2190
|
+
const guardedComparison = {
|
|
2191
|
+
$let: {
|
|
2192
|
+
vars: {
|
|
2193
|
+
smartdataAtomicExprLeft: left.mongoExpression,
|
|
2194
|
+
smartdataAtomicExprRight: right.mongoExpression,
|
|
2195
|
+
},
|
|
2196
|
+
in: {
|
|
2197
|
+
$cond: [
|
|
2198
|
+
{
|
|
2199
|
+
$and: [
|
|
2200
|
+
{ $isNumber: '$$smartdataAtomicExprLeft' },
|
|
2201
|
+
{ $isNumber: '$$smartdataAtomicExprRight' },
|
|
2202
|
+
{
|
|
2203
|
+
$eq: [
|
|
2204
|
+
{
|
|
2205
|
+
$subtract: [
|
|
2206
|
+
'$$smartdataAtomicExprLeft',
|
|
2207
|
+
'$$smartdataAtomicExprLeft',
|
|
2208
|
+
],
|
|
2209
|
+
},
|
|
2210
|
+
0,
|
|
2211
|
+
],
|
|
2212
|
+
},
|
|
2213
|
+
{
|
|
2214
|
+
$eq: [
|
|
2215
|
+
{
|
|
2216
|
+
$subtract: [
|
|
2217
|
+
'$$smartdataAtomicExprRight',
|
|
2218
|
+
'$$smartdataAtomicExprRight',
|
|
2219
|
+
],
|
|
2220
|
+
},
|
|
2221
|
+
0,
|
|
2222
|
+
],
|
|
2223
|
+
},
|
|
2224
|
+
],
|
|
2225
|
+
},
|
|
2226
|
+
{
|
|
2227
|
+
$lte: [
|
|
2228
|
+
'$$smartdataAtomicExprLeft',
|
|
2229
|
+
'$$smartdataAtomicExprRight',
|
|
2230
|
+
],
|
|
2231
|
+
},
|
|
2232
|
+
false,
|
|
2233
|
+
],
|
|
2234
|
+
},
|
|
2235
|
+
},
|
|
2236
|
+
};
|
|
2237
|
+
return fieldGuards.length > 0
|
|
2238
|
+
? {
|
|
2239
|
+
$cond: [
|
|
2240
|
+
{ $and: fieldGuards },
|
|
2241
|
+
guardedComparison,
|
|
2242
|
+
false,
|
|
2243
|
+
],
|
|
2244
|
+
}
|
|
2245
|
+
: guardedComparison;
|
|
2246
|
+
};
|
|
2247
|
+
|
|
2248
|
+
const normalizeStrictFilter = (
|
|
2249
|
+
filterArg: Record<string, unknown>,
|
|
2250
|
+
declaredRootsArg: Set<string>,
|
|
2251
|
+
declaredNestedPathsArg: Set<string>,
|
|
2252
|
+
identityValueTypesArg: ReadonlyMap<string, TSmartdataIdentityValueType>,
|
|
2253
|
+
numericRootsArg: Set<string>,
|
|
2254
|
+
serializedRootsArg: Set<string>,
|
|
2255
|
+
operationLabelArg:
|
|
2256
|
+
| 'Atomic update'
|
|
2257
|
+
| 'Atomic update-many'
|
|
2258
|
+
| 'Atomic find-one-and-update'
|
|
2259
|
+
| 'Atomic delete'
|
|
2260
|
+
| 'Atomic delete-many',
|
|
2261
|
+
globallyConstrainingKeySetsArg?: ReadonlyArray<ReadonlySet<string>>,
|
|
2262
|
+
): plugins.mongodb.Filter<plugins.mongodb.Document> => {
|
|
2263
|
+
if (
|
|
2264
|
+
typeof filterArg !== 'object' ||
|
|
2265
|
+
filterArg === null ||
|
|
2266
|
+
Array.isArray(filterArg) ||
|
|
2267
|
+
plugins.nodeUtil.types.isProxy(filterArg) ||
|
|
2268
|
+
!isPlainObject(filterArg) ||
|
|
2269
|
+
Object.keys(filterArg).length === 0
|
|
2270
|
+
) {
|
|
2271
|
+
throw new SmartdataPersistenceError(
|
|
2272
|
+
'invalid_argument',
|
|
2273
|
+
`${operationLabelArg} requires a non-empty filter object.`,
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
assertOnlyDataProperties(
|
|
2277
|
+
filterArg,
|
|
2278
|
+
`${operationLabelArg} filter`,
|
|
2279
|
+
false,
|
|
2280
|
+
);
|
|
2281
|
+
const requireInertLogicalFilterArray: (
|
|
2282
|
+
valueArg: unknown,
|
|
2283
|
+
labelArg: string,
|
|
2284
|
+
) => asserts valueArg is Array<Record<string, unknown>> = (
|
|
2285
|
+
valueArg,
|
|
2286
|
+
labelArg,
|
|
2287
|
+
) => {
|
|
2288
|
+
if (
|
|
2289
|
+
!Array.isArray(valueArg)
|
|
2290
|
+
|| plugins.nodeUtil.types.isProxy(valueArg)
|
|
2291
|
+
|| Object.getPrototypeOf(valueArg) !== Array.prototype
|
|
2292
|
+
|| valueArg.length === 0
|
|
2293
|
+
) {
|
|
2294
|
+
throw new SmartdataPersistenceError(
|
|
2295
|
+
'invalid_argument',
|
|
2296
|
+
`${labelArg} must be a non-empty ordinary array of filters.`,
|
|
2297
|
+
);
|
|
2298
|
+
}
|
|
2299
|
+
assertOnlyDataProperties(valueArg, labelArg, true);
|
|
2300
|
+
for (let index = 0; index < valueArg.length; index++) {
|
|
2301
|
+
if (!Object.prototype.hasOwnProperty.call(valueArg, index)) {
|
|
2302
|
+
throw new SmartdataPersistenceError(
|
|
2303
|
+
'invalid_argument',
|
|
2304
|
+
`${labelArg} may not contain sparse array positions.`,
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
const entry = valueArg[index];
|
|
2308
|
+
if (
|
|
2309
|
+
typeof entry !== 'object'
|
|
2310
|
+
|| entry === null
|
|
2311
|
+
|| Array.isArray(entry)
|
|
2312
|
+
|| plugins.nodeUtil.types.isProxy(entry)
|
|
2313
|
+
|| !isPlainObject(entry)
|
|
2314
|
+
) {
|
|
2315
|
+
throw new SmartdataPersistenceError(
|
|
2316
|
+
'invalid_argument',
|
|
2317
|
+
`${labelArg} entries must be plain filter objects.`,
|
|
2318
|
+
);
|
|
2319
|
+
}
|
|
2320
|
+
assertOnlyDataProperties(entry, `${labelArg} entry`, false);
|
|
2321
|
+
}
|
|
2322
|
+
};
|
|
2323
|
+
const conditionIsEqualityAnchor = (valueArg: unknown): boolean => {
|
|
2324
|
+
if (Array.isArray(valueArg)) {
|
|
2325
|
+
return false;
|
|
2326
|
+
}
|
|
2327
|
+
if (typeof valueArg !== 'object' || valueArg === null) {
|
|
2328
|
+
return (
|
|
2329
|
+
valueArg !== undefined
|
|
2330
|
+
&& typeof valueArg !== 'function'
|
|
2331
|
+
&& typeof valueArg !== 'symbol'
|
|
2332
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
if (plugins.nodeUtil.types.isProxy(valueArg)) {
|
|
2335
|
+
throw new SmartdataPersistenceError(
|
|
2336
|
+
'invalid_argument',
|
|
2337
|
+
`${operationLabelArg} filters may not contain Proxy values.`,
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
if (isAtomicMongoScalar(valueArg)) {
|
|
2341
|
+
return true;
|
|
2342
|
+
}
|
|
2343
|
+
if (!isPlainObject(valueArg)) {
|
|
2344
|
+
return false;
|
|
2345
|
+
}
|
|
2346
|
+
assertOnlyDataProperties(
|
|
2347
|
+
valueArg,
|
|
2348
|
+
`${operationLabelArg} filter condition`,
|
|
2349
|
+
false,
|
|
2350
|
+
);
|
|
2351
|
+
const entries = Object.entries(valueArg);
|
|
2352
|
+
if (entries.length !== 1 || entries[0][0] !== '$eq') {
|
|
2353
|
+
return false;
|
|
2354
|
+
}
|
|
2355
|
+
const operand = entries[0][1];
|
|
2356
|
+
if (Array.isArray(operand)) {
|
|
2357
|
+
return false;
|
|
2358
|
+
}
|
|
2359
|
+
if (typeof operand !== 'object' || operand === null) {
|
|
2360
|
+
return (
|
|
2361
|
+
operand !== undefined
|
|
2362
|
+
&& typeof operand !== 'function'
|
|
2363
|
+
&& typeof operand !== 'symbol'
|
|
2364
|
+
);
|
|
2365
|
+
}
|
|
2366
|
+
if (plugins.nodeUtil.types.isProxy(operand)) {
|
|
2367
|
+
throw new SmartdataPersistenceError(
|
|
2368
|
+
'invalid_argument',
|
|
2369
|
+
`${operationLabelArg} filters may not contain Proxy values.`,
|
|
2370
|
+
);
|
|
2371
|
+
}
|
|
2372
|
+
return isAtomicMongoScalar(operand);
|
|
2373
|
+
};
|
|
2374
|
+
const filterHasEqualityAnchor = (
|
|
2375
|
+
filterObjectArg: Record<string, unknown>,
|
|
2376
|
+
): boolean => {
|
|
2377
|
+
for (const [key, value] of Object.entries(filterObjectArg)) {
|
|
2378
|
+
if (key === '$and' && Array.isArray(value)) {
|
|
2379
|
+
requireInertLogicalFilterArray(value, '$and');
|
|
2380
|
+
if (
|
|
2381
|
+
value.some(
|
|
2382
|
+
(entryArg) =>
|
|
2383
|
+
isPlainObject(entryArg) &&
|
|
2384
|
+
filterHasEqualityAnchor(entryArg),
|
|
2385
|
+
)
|
|
2386
|
+
) {
|
|
2387
|
+
return true;
|
|
2388
|
+
}
|
|
2389
|
+
continue;
|
|
2390
|
+
}
|
|
2391
|
+
if (key === '$or' && Array.isArray(value)) {
|
|
2392
|
+
requireInertLogicalFilterArray(value, '$or');
|
|
2393
|
+
if (
|
|
2394
|
+
value.length > 0 &&
|
|
2395
|
+
value.every(
|
|
2396
|
+
(entryArg) =>
|
|
2397
|
+
isPlainObject(entryArg) &&
|
|
2398
|
+
filterHasEqualityAnchor(entryArg),
|
|
2399
|
+
)
|
|
2400
|
+
) {
|
|
2401
|
+
return true;
|
|
2402
|
+
}
|
|
2403
|
+
continue;
|
|
2404
|
+
}
|
|
2405
|
+
if (!key.startsWith('$') && conditionIsEqualityAnchor(value)) {
|
|
2406
|
+
return true;
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
return false;
|
|
2410
|
+
};
|
|
2411
|
+
if (!filterHasEqualityAnchor(filterArg)) {
|
|
2412
|
+
throw new SmartdataPersistenceError(
|
|
2413
|
+
'invalid_argument',
|
|
2414
|
+
`${operationLabelArg} filters require at least one top-level equality anchor.`,
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
const collectEqualityAnchoredKeys = (
|
|
2418
|
+
filterObjectArg: Record<string, unknown>,
|
|
2419
|
+
anchoredKeysArg: Set<string>,
|
|
2420
|
+
): void => {
|
|
2421
|
+
for (const [key, value] of Object.entries(filterObjectArg)) {
|
|
2422
|
+
if (key === '$and' && Array.isArray(value)) {
|
|
2423
|
+
requireInertLogicalFilterArray(value, '$and');
|
|
2424
|
+
for (const entryArg of value) {
|
|
2425
|
+
if (isPlainObject(entryArg)) {
|
|
2426
|
+
collectEqualityAnchoredKeys(entryArg, anchoredKeysArg);
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
continue;
|
|
2430
|
+
}
|
|
2431
|
+
// $or branches are deliberately excluded: an equality inside one $or
|
|
2432
|
+
// branch does not globally constrain the matched document.
|
|
2433
|
+
if (!key.startsWith('$') && conditionIsEqualityAnchor(value)) {
|
|
2434
|
+
anchoredKeysArg.add(key);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
};
|
|
2438
|
+
if (globallyConstrainingKeySetsArg) {
|
|
2439
|
+
const anchoredKeys = new Set<string>();
|
|
2440
|
+
collectEqualityAnchoredKeys(filterArg, anchoredKeys);
|
|
2441
|
+
const filterIsGloballyConstraining = globallyConstrainingKeySetsArg.some(
|
|
2442
|
+
(keySetArg) =>
|
|
2443
|
+
keySetArg.size > 0 &&
|
|
2444
|
+
[...keySetArg].every((keyArg) => anchoredKeys.has(keyArg)),
|
|
2445
|
+
);
|
|
2446
|
+
if (!filterIsGloballyConstraining) {
|
|
2447
|
+
throw new SmartdataPersistenceError(
|
|
2448
|
+
'invalid_argument',
|
|
2449
|
+
`${operationLabelArg} filters require equality anchors that fully pin an identity field or a declared unqualified unique index.`,
|
|
2450
|
+
);
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
const normalizeFilterObject = (
|
|
2454
|
+
filterObjectArg: Record<string, unknown>,
|
|
2455
|
+
rootArg = false,
|
|
2456
|
+
): Record<string, unknown> => {
|
|
2457
|
+
assertOnlyDataProperties(
|
|
2458
|
+
filterObjectArg,
|
|
2459
|
+
`${operationLabelArg} filter`,
|
|
2460
|
+
false,
|
|
2461
|
+
);
|
|
2462
|
+
const normalized: Record<string, unknown> = {};
|
|
2463
|
+
for (const [key, value] of Object.entries(filterObjectArg)) {
|
|
2464
|
+
if (key === '$and' || key === '$or' || key === '$nor') {
|
|
2465
|
+
requireInertLogicalFilterArray(value, key);
|
|
2466
|
+
normalized[key] = value.map((entry) => {
|
|
2467
|
+
if (Object.keys(entry).length === 0) {
|
|
2468
|
+
throw new SmartdataPersistenceError(
|
|
2469
|
+
'invalid_argument',
|
|
2470
|
+
`${key} entries must be non-empty filter objects.`,
|
|
2471
|
+
);
|
|
2472
|
+
}
|
|
2473
|
+
return normalizeFilterObject(entry, false);
|
|
2474
|
+
});
|
|
2475
|
+
continue;
|
|
2476
|
+
}
|
|
2477
|
+
if (key === '$expr') {
|
|
2478
|
+
if (!rootArg) {
|
|
2479
|
+
throw new SmartdataPersistenceError(
|
|
2480
|
+
'invalid_argument',
|
|
2481
|
+
`${operationLabelArg} permits $expr only at the root of the filter.`,
|
|
2482
|
+
);
|
|
2483
|
+
}
|
|
2484
|
+
normalized[key] = normalizeAtomicNumericExpression(
|
|
2485
|
+
value,
|
|
2486
|
+
declaredRootsArg,
|
|
2487
|
+
numericRootsArg,
|
|
2488
|
+
serializedRootsArg,
|
|
2489
|
+
operationLabelArg,
|
|
2490
|
+
);
|
|
2491
|
+
continue;
|
|
2492
|
+
}
|
|
2493
|
+
const root = requireDeclaredPath(
|
|
2494
|
+
key,
|
|
2495
|
+
declaredRootsArg,
|
|
2496
|
+
`${operationLabelArg} filter`,
|
|
2497
|
+
declaredNestedPathsArg,
|
|
2498
|
+
);
|
|
2499
|
+
if (serializedRootsArg.has(root)) {
|
|
2500
|
+
throw new SmartdataPersistenceError(
|
|
2501
|
+
'unsupported_operation',
|
|
2502
|
+
`${operationLabelArg} filters cannot target serialized field "${key}".`,
|
|
2503
|
+
);
|
|
2504
|
+
}
|
|
2505
|
+
if (identityValueTypesArg.has(root) && key !== root) {
|
|
2506
|
+
throw new SmartdataPersistenceError(
|
|
2507
|
+
'invalid_argument',
|
|
2508
|
+
`${operationLabelArg} filters cannot target a nested path below immutable @unI() field "${root}".`,
|
|
2509
|
+
);
|
|
2510
|
+
}
|
|
2511
|
+
normalized[key] = normalizeAtomicFilterCondition(
|
|
2512
|
+
value,
|
|
2513
|
+
`${operationLabelArg} filter path "${key}"`,
|
|
2514
|
+
identityValueTypesArg.get(root),
|
|
2515
|
+
);
|
|
2516
|
+
}
|
|
2517
|
+
return normalized;
|
|
2518
|
+
};
|
|
2519
|
+
return normalizeFilterObject(
|
|
2520
|
+
filterArg,
|
|
2521
|
+
true,
|
|
2522
|
+
) as plugins.mongodb.Filter<plugins.mongodb.Document>;
|
|
2523
|
+
};
|
|
2524
|
+
|
|
2525
|
+
const pathsConflict = (leftArg: string, rightArg: string): boolean =>
|
|
2526
|
+
leftArg === rightArg ||
|
|
2527
|
+
leftArg.startsWith(`${rightArg}.`) ||
|
|
2528
|
+
rightArg.startsWith(`${leftArg}.`);
|
|
2529
|
+
|
|
2530
|
+
const normalizeAtomicUpdate = <T>(
|
|
2531
|
+
updateArg: ISmartdataAtomicUpdate<T>,
|
|
2532
|
+
declaredRootsArg: Set<string>,
|
|
2533
|
+
declaredNestedPathsArg: Set<string>,
|
|
2534
|
+
uniqueRootsArg: Set<string>,
|
|
2535
|
+
serializedRootsArg: Set<string>,
|
|
2536
|
+
): plugins.mongodb.UpdateFilter<plugins.mongodb.Document> => {
|
|
2537
|
+
if (
|
|
2538
|
+
typeof updateArg !== 'object' ||
|
|
2539
|
+
updateArg === null ||
|
|
2540
|
+
Array.isArray(updateArg)
|
|
2541
|
+
) {
|
|
2542
|
+
throw new SmartdataPersistenceError(
|
|
2543
|
+
'invalid_argument',
|
|
2544
|
+
'Atomic update must be an update object.',
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
const allowedOperators = new Set([
|
|
2548
|
+
'$set',
|
|
2549
|
+
'$unset',
|
|
2550
|
+
'$inc',
|
|
2551
|
+
'$setOnInsert',
|
|
2552
|
+
'$push',
|
|
2553
|
+
'$addToSet',
|
|
2554
|
+
'$pull',
|
|
2555
|
+
]);
|
|
2556
|
+
const arrayOperators = new Set(['$push', '$addToSet', '$pull']);
|
|
2557
|
+
for (const operator of Object.keys(updateArg)) {
|
|
2558
|
+
if (!allowedOperators.has(operator)) {
|
|
2559
|
+
throw new SmartdataPersistenceError(
|
|
2560
|
+
'invalid_argument',
|
|
2561
|
+
`Atomic update operator "${operator}" is not supported.`,
|
|
2562
|
+
);
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
const normalized: Record<string, Record<string, unknown>> = {};
|
|
2567
|
+
const occupiedPaths: Array<{ path: string; operator: string }> = [];
|
|
2568
|
+
for (const operator of allowedOperators) {
|
|
2569
|
+
const operation = (updateArg as Record<string, unknown>)[operator];
|
|
2570
|
+
if (operation === undefined) {
|
|
2571
|
+
continue;
|
|
2572
|
+
}
|
|
2573
|
+
if (!isPlainObject(operation)) {
|
|
2574
|
+
throw new SmartdataPersistenceError(
|
|
2575
|
+
'invalid_argument',
|
|
2576
|
+
`${operator} must be a plain object.`,
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
const entries = Object.entries(operation);
|
|
2580
|
+
if (entries.length === 0) {
|
|
2581
|
+
continue;
|
|
2582
|
+
}
|
|
2583
|
+
const normalizedOperation: Record<string, unknown> = {};
|
|
2584
|
+
for (const [path, value] of entries) {
|
|
2585
|
+
const root = requireDeclaredPath(
|
|
2586
|
+
path,
|
|
2587
|
+
declaredRootsArg,
|
|
2588
|
+
operator,
|
|
2589
|
+
declaredNestedPathsArg,
|
|
2590
|
+
);
|
|
2591
|
+
if (serializedRootsArg.has(root)) {
|
|
2592
|
+
throw new SmartdataPersistenceError(
|
|
2593
|
+
'unsupported_operation',
|
|
2594
|
+
`${operator} cannot target serialized field "${path}".`,
|
|
2595
|
+
);
|
|
2596
|
+
}
|
|
2597
|
+
if (uniqueRootsArg.has(root)) {
|
|
2598
|
+
throw new SmartdataPersistenceError(
|
|
2599
|
+
'invalid_argument',
|
|
2600
|
+
`${operator} may not modify immutable @unI() field "${root}".`,
|
|
2601
|
+
);
|
|
2602
|
+
}
|
|
2603
|
+
const conflict = occupiedPaths.find((occupiedPath) =>
|
|
2604
|
+
pathsConflict(path, occupiedPath.path),
|
|
2605
|
+
);
|
|
2606
|
+
if (conflict) {
|
|
2607
|
+
throw new SmartdataPersistenceError(
|
|
2608
|
+
'invalid_argument',
|
|
2609
|
+
`${operator} path "${path}" conflicts with ${conflict.operator} path "${conflict.path}".`,
|
|
2610
|
+
);
|
|
2611
|
+
}
|
|
2612
|
+
occupiedPaths.push({ path, operator });
|
|
2613
|
+
if (arrayOperators.has(operator)) {
|
|
2614
|
+
if (path !== root) {
|
|
2615
|
+
throw new SmartdataPersistenceError(
|
|
2616
|
+
'invalid_argument',
|
|
2617
|
+
`${operator} may only target declared top-level array fields; "${path}" is a nested path.`,
|
|
2618
|
+
);
|
|
2619
|
+
}
|
|
2620
|
+
if (value === undefined) {
|
|
2621
|
+
throw new SmartdataPersistenceError(
|
|
2622
|
+
'invalid_argument',
|
|
2623
|
+
`${operator} path "${path}" may not be undefined.`,
|
|
2624
|
+
);
|
|
2625
|
+
}
|
|
2626
|
+
if (operator === '$pull') {
|
|
2627
|
+
if (
|
|
2628
|
+
value === null
|
|
2629
|
+
|| typeof value === 'string'
|
|
2630
|
+
|| typeof value === 'boolean'
|
|
2631
|
+
|| (typeof value === 'number' && Number.isFinite(value))
|
|
2632
|
+
) {
|
|
2633
|
+
normalizedOperation[path] = value;
|
|
2634
|
+
continue;
|
|
2635
|
+
}
|
|
2636
|
+
if (
|
|
2637
|
+
typeof value !== 'object'
|
|
2638
|
+
|| Array.isArray(value)
|
|
2639
|
+
|| isPlainObject(value)
|
|
2640
|
+
|| value instanceof RegExp
|
|
2641
|
+
) {
|
|
2642
|
+
throw new SmartdataPersistenceError(
|
|
2643
|
+
'invalid_argument',
|
|
2644
|
+
`$pull path "${path}" must use a scalar equality operand.`,
|
|
2645
|
+
);
|
|
2646
|
+
}
|
|
2647
|
+
// Scalar BSON objects are stored as inert snapshots so a custom
|
|
2648
|
+
// toBSON encoder can never rewrite the equality operand into an
|
|
2649
|
+
// operator document at serialization time.
|
|
2650
|
+
normalizedOperation[path] = createInertAtomicBsonScalarSnapshot(
|
|
2651
|
+
value,
|
|
2652
|
+
`$pull path "${path}"`,
|
|
2653
|
+
);
|
|
2654
|
+
continue;
|
|
2655
|
+
}
|
|
2656
|
+
if (
|
|
2657
|
+
typeof value === 'object'
|
|
2658
|
+
&& value !== null
|
|
2659
|
+
&& !Array.isArray(value)
|
|
2660
|
+
&& !isPlainObject(value)
|
|
2661
|
+
) {
|
|
2662
|
+
// Non-plain element objects (dates, binary views, BSON scalars)
|
|
2663
|
+
// must be inert: a custom toBSON encoder could otherwise smuggle a
|
|
2664
|
+
// modifier document such as $each past validation.
|
|
2665
|
+
normalizedOperation[path] = createInertAtomicBsonScalarSnapshot(
|
|
2666
|
+
value,
|
|
2667
|
+
`${operator} path "${path}"`,
|
|
2668
|
+
);
|
|
2669
|
+
continue;
|
|
2670
|
+
}
|
|
2671
|
+
assertSafePersistedValue(value, `${operator} path "${path}"`);
|
|
2672
|
+
normalizedOperation[path] = stripUndefinedValues(value);
|
|
2673
|
+
continue;
|
|
2674
|
+
}
|
|
2675
|
+
if (operator === '$unset') {
|
|
2676
|
+
if (value !== '' && value !== true && value !== 1) {
|
|
2677
|
+
throw new SmartdataPersistenceError(
|
|
2678
|
+
'invalid_argument',
|
|
2679
|
+
`$unset path "${path}" must use "", true, or 1.`,
|
|
2680
|
+
);
|
|
2681
|
+
}
|
|
2682
|
+
normalizedOperation[path] = '';
|
|
2683
|
+
continue;
|
|
2684
|
+
}
|
|
2685
|
+
if (value === undefined) {
|
|
2686
|
+
throw new SmartdataPersistenceError(
|
|
2687
|
+
'invalid_argument',
|
|
2688
|
+
`${operator} path "${path}" may not be undefined.`,
|
|
2689
|
+
);
|
|
2690
|
+
}
|
|
2691
|
+
if (
|
|
2692
|
+
operator === '$inc' &&
|
|
2693
|
+
(typeof value !== 'number' || !Number.isFinite(value))
|
|
2694
|
+
) {
|
|
2695
|
+
throw new SmartdataPersistenceError(
|
|
2696
|
+
'invalid_argument',
|
|
2697
|
+
`$inc path "${path}" must be a finite number.`,
|
|
2698
|
+
);
|
|
2699
|
+
}
|
|
2700
|
+
if (operator !== '$inc') {
|
|
2701
|
+
assertSafePersistedValue(value, `${operator} path "${path}"`);
|
|
2702
|
+
}
|
|
2703
|
+
normalizedOperation[path] =
|
|
2704
|
+
operator === '$inc' ? value : stripUndefinedValues(value);
|
|
2705
|
+
}
|
|
2706
|
+
normalized[operator] = normalizedOperation;
|
|
2707
|
+
}
|
|
2708
|
+
if (Object.keys(normalized).length === 0) {
|
|
2709
|
+
throw new SmartdataPersistenceError(
|
|
2710
|
+
'invalid_argument',
|
|
2711
|
+
'Atomic update must contain at least one non-empty operation.',
|
|
2712
|
+
);
|
|
2713
|
+
}
|
|
2714
|
+
return normalized as plugins.mongodb.UpdateFilter<plugins.mongodb.Document>;
|
|
2715
|
+
};
|
|
2716
|
+
|
|
2717
|
+
const normalizeProjection = <T>(
|
|
2718
|
+
projectionArg: TSmartdataProjection<T> | undefined,
|
|
2719
|
+
declaredRootsArg: Set<string>,
|
|
2720
|
+
declaredNestedPathsArg?: Set<string>,
|
|
2721
|
+
): plugins.mongodb.Document | undefined => {
|
|
2722
|
+
if (projectionArg === undefined) {
|
|
2723
|
+
return undefined;
|
|
2724
|
+
}
|
|
2725
|
+
if (!isPlainObject(projectionArg) || Object.keys(projectionArg).length === 0) {
|
|
2726
|
+
throw new SmartdataPersistenceError(
|
|
2727
|
+
'invalid_argument',
|
|
2728
|
+
'Projection must be a non-empty plain object.',
|
|
2729
|
+
);
|
|
2730
|
+
}
|
|
2731
|
+
let inclusionMode: 0 | 1 | undefined;
|
|
2732
|
+
const normalized: Record<string, 0 | 1> = {};
|
|
2733
|
+
for (const [path, value] of Object.entries(projectionArg)) {
|
|
2734
|
+
if (path !== '_id') {
|
|
2735
|
+
requireDeclaredPath(path, declaredRootsArg, 'Projection', declaredNestedPathsArg);
|
|
2736
|
+
if (inclusionMode === undefined) {
|
|
2737
|
+
inclusionMode = value as 0 | 1;
|
|
2738
|
+
} else if (inclusionMode !== value) {
|
|
2739
|
+
throw new SmartdataPersistenceError(
|
|
2740
|
+
'invalid_argument',
|
|
2741
|
+
'Projection may not mix included and excluded fields except for _id.',
|
|
2742
|
+
);
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
if (value !== 0 && value !== 1) {
|
|
2746
|
+
throw new SmartdataPersistenceError(
|
|
2747
|
+
'invalid_argument',
|
|
2748
|
+
`Projection path "${path}" must use 0 or 1.`,
|
|
2749
|
+
);
|
|
2750
|
+
}
|
|
2751
|
+
normalized[path] = value;
|
|
2752
|
+
}
|
|
2753
|
+
return normalized;
|
|
2754
|
+
};
|
|
2755
|
+
|
|
2756
|
+
const normalizeSort = <T>(
|
|
2757
|
+
sortArg: TSmartdataSort<T> | undefined,
|
|
2758
|
+
declaredRootsArg: Set<string>,
|
|
2759
|
+
declaredNestedPathsArg?: Set<string>,
|
|
2760
|
+
): plugins.mongodb.Sort | undefined => {
|
|
2761
|
+
if (sortArg === undefined) {
|
|
2762
|
+
return undefined;
|
|
2763
|
+
}
|
|
2764
|
+
if (!isPlainObject(sortArg) || Object.keys(sortArg).length === 0) {
|
|
2765
|
+
throw new SmartdataPersistenceError(
|
|
2766
|
+
'invalid_argument',
|
|
2767
|
+
'Sort must be a non-empty ordered object.',
|
|
2768
|
+
);
|
|
2769
|
+
}
|
|
2770
|
+
const normalized: Record<string, 1 | -1> = {};
|
|
2771
|
+
for (const [path, direction] of Object.entries(sortArg)) {
|
|
2772
|
+
if (path !== '_id') {
|
|
2773
|
+
requireDeclaredPath(path, declaredRootsArg, 'Sort', declaredNestedPathsArg);
|
|
2774
|
+
}
|
|
2775
|
+
if (direction !== 1 && direction !== -1) {
|
|
2776
|
+
throw new SmartdataPersistenceError(
|
|
2777
|
+
'invalid_argument',
|
|
2778
|
+
`Sort path "${path}" must use 1 or -1.`,
|
|
2779
|
+
);
|
|
2780
|
+
}
|
|
2781
|
+
normalized[path] = direction;
|
|
2782
|
+
}
|
|
2783
|
+
return normalized;
|
|
2784
|
+
};
|
|
2785
|
+
|
|
2786
|
+
const normalizeQueryHint = (
|
|
2787
|
+
hintArg: string | undefined,
|
|
2788
|
+
modelArg: Parameters<typeof getExpectedCollectionTopologyForModel>[0],
|
|
2789
|
+
): string | undefined => {
|
|
2790
|
+
if (hintArg === undefined) {
|
|
2791
|
+
return undefined;
|
|
2792
|
+
}
|
|
2793
|
+
if (
|
|
2794
|
+
typeof hintArg !== 'string' ||
|
|
2795
|
+
!getExpectedCollectionTopologyForModel(modelArg).indexes.some(
|
|
2796
|
+
(indexArg) => indexArg.name === hintArg,
|
|
2797
|
+
)
|
|
2798
|
+
) {
|
|
2799
|
+
throw new SmartdataPersistenceError(
|
|
2800
|
+
'invalid_argument',
|
|
2801
|
+
'Query hint must name an index declared by this model or the built-in _id_ index.',
|
|
2802
|
+
);
|
|
2803
|
+
}
|
|
2804
|
+
return hintArg;
|
|
2805
|
+
};
|
|
2806
|
+
|
|
2807
|
+
export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
|
|
2808
|
+
// SECURITY: Block $where to prevent server-side JS execution
|
|
2809
|
+
if (filterArg.$where !== undefined) {
|
|
2810
|
+
throw new Error('$where operator is not allowed for security reasons');
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
// Handle logical operators recursively
|
|
2814
|
+
const logicalOperators = ['$and', '$or', '$nor', '$not'];
|
|
2815
|
+
const processedFilter: { [key: string]: any } = {};
|
|
2816
|
+
|
|
2817
|
+
for (const key of Object.keys(filterArg)) {
|
|
2818
|
+
if (logicalOperators.includes(key)) {
|
|
2819
|
+
if (key === '$not') {
|
|
2820
|
+
processedFilter[key] = convertFilterForMongoDb(filterArg[key]);
|
|
2821
|
+
} else if (Array.isArray(filterArg[key])) {
|
|
2822
|
+
processedFilter[key] = filterArg[key].map((subFilter: any) => convertFilterForMongoDb(subFilter));
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
// If only logical operators, return them
|
|
2828
|
+
const hasOnlyLogicalOperators = Object.keys(filterArg).every(key => logicalOperators.includes(key));
|
|
2829
|
+
if (hasOnlyLogicalOperators) {
|
|
2830
|
+
return processedFilter;
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
// Original conversion logic for non-MongoDB query objects
|
|
2834
|
+
const convertedFilter: { [key: string]: any } = {};
|
|
2835
|
+
|
|
2836
|
+
// Helper to merge operator objects
|
|
2837
|
+
const mergeIntoConverted = (path: string, value: any) => {
|
|
2838
|
+
const existing = convertedFilter[path];
|
|
2839
|
+
if (!existing) {
|
|
2840
|
+
convertedFilter[path] = value;
|
|
2841
|
+
} else if (
|
|
2842
|
+
typeof existing === 'object' && !Array.isArray(existing) &&
|
|
2843
|
+
typeof value === 'object' && !Array.isArray(value) &&
|
|
2844
|
+
(Object.keys(existing).some(k => k.startsWith('$')) || Object.keys(value).some(k => k.startsWith('$')))
|
|
2845
|
+
) {
|
|
2846
|
+
// Both have operators, merge them
|
|
2847
|
+
convertedFilter[path] = { ...existing, ...value };
|
|
2848
|
+
} else {
|
|
2849
|
+
// Otherwise later wins
|
|
2850
|
+
convertedFilter[path] = value;
|
|
2851
|
+
}
|
|
2852
|
+
};
|
|
2853
|
+
|
|
2854
|
+
const isAtomicMongoValue = (valueArg: unknown): boolean => {
|
|
2855
|
+
if (valueArg instanceof Date || valueArg instanceof RegExp) {
|
|
2856
|
+
return true;
|
|
2857
|
+
}
|
|
2858
|
+
if (ArrayBuffer.isView(valueArg)) {
|
|
2859
|
+
return true;
|
|
2860
|
+
}
|
|
2861
|
+
return (
|
|
2862
|
+
typeof valueArg === 'object' &&
|
|
2863
|
+
valueArg !== null &&
|
|
2864
|
+
typeof (valueArg as { _bsontype?: unknown })._bsontype === 'string'
|
|
2865
|
+
);
|
|
2866
|
+
};
|
|
2867
|
+
|
|
2868
|
+
const convertFilterArgument = (keyPathArg2: string, filterArg2: any) => {
|
|
2869
|
+
if (Array.isArray(filterArg2)) {
|
|
2870
|
+
// Arrays are typically used as values for operators like $in or as direct equality matches
|
|
2871
|
+
mergeIntoConverted(keyPathArg2, filterArg2);
|
|
2872
|
+
return;
|
|
2873
|
+
} else if (isAtomicMongoValue(filterArg2)) {
|
|
2874
|
+
// BSON values, dates, regexes, and binary views are scalar query values.
|
|
2875
|
+
// Recursing into them can silently turn an _id filter into an empty filter.
|
|
2876
|
+
mergeIntoConverted(keyPathArg2, filterArg2);
|
|
2877
|
+
return;
|
|
2878
|
+
} else if (typeof filterArg2 === 'object' && filterArg2 !== null) {
|
|
2879
|
+
// Check if this is an object with MongoDB operators
|
|
2880
|
+
const keys = Object.keys(filterArg2);
|
|
2881
|
+
const hasOperators = keys.some(key => key.startsWith('$'));
|
|
2882
|
+
|
|
2883
|
+
if (hasOperators) {
|
|
2884
|
+
// This object contains MongoDB operators
|
|
2885
|
+
// Validate and pass through allowed operators
|
|
2886
|
+
const allowedOperators = [
|
|
2887
|
+
// Comparison operators
|
|
2888
|
+
'$eq', '$ne', '$gt', '$gte', '$lt', '$lte',
|
|
2889
|
+
// Array operators
|
|
2890
|
+
'$in', '$nin', '$all', '$elemMatch', '$size',
|
|
2891
|
+
// Element operators
|
|
2892
|
+
'$exists', '$type',
|
|
2893
|
+
// Evaluation operators (safe ones only)
|
|
2894
|
+
'$regex', '$options', '$text', '$mod',
|
|
2895
|
+
// Logical operators (nested)
|
|
2896
|
+
'$and', '$or', '$nor', '$not'
|
|
2897
|
+
];
|
|
2898
|
+
|
|
2899
|
+
// Check for dangerous operators
|
|
2900
|
+
if (keys.includes('$where')) {
|
|
2901
|
+
throw new Error('$where operator is not allowed for security reasons');
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
// Validate all operators are in the allowed list
|
|
2905
|
+
const invalidOperators = keys.filter(key =>
|
|
2906
|
+
key.startsWith('$') && !allowedOperators.includes(key)
|
|
2907
|
+
);
|
|
2908
|
+
|
|
2909
|
+
if (invalidOperators.length > 0) {
|
|
2910
|
+
console.warn(`Warning: Unknown MongoDB operators detected: ${invalidOperators.join(', ')}`);
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
// For array operators, ensure the values are appropriate
|
|
2914
|
+
if (filterArg2.$in && !Array.isArray(filterArg2.$in)) {
|
|
2915
|
+
throw new Error('$in operator requires an array value');
|
|
2916
|
+
}
|
|
2917
|
+
if (filterArg2.$nin && !Array.isArray(filterArg2.$nin)) {
|
|
2918
|
+
throw new Error('$nin operator requires an array value');
|
|
2919
|
+
}
|
|
2920
|
+
if (filterArg2.$all && !Array.isArray(filterArg2.$all)) {
|
|
2921
|
+
throw new Error('$all operator requires an array value');
|
|
2922
|
+
}
|
|
2923
|
+
if (filterArg2.$size && typeof filterArg2.$size !== 'number') {
|
|
2924
|
+
throw new Error('$size operator requires a numeric value');
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
// Use merge helper to handle duplicate paths
|
|
2928
|
+
mergeIntoConverted(keyPathArg2, filterArg2);
|
|
2929
|
+
return;
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
// No operators, check for dots in keys
|
|
2933
|
+
for (const key of keys) {
|
|
2934
|
+
if (key.includes('.')) {
|
|
2935
|
+
throw new Error('keys cannot contain dots');
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
// Recursively process nested objects
|
|
2940
|
+
for (const key of keys) {
|
|
2941
|
+
convertFilterArgument(`${keyPathArg2}.${key}`, filterArg2[key]);
|
|
2942
|
+
}
|
|
2943
|
+
} else {
|
|
2944
|
+
// Primitive values
|
|
2945
|
+
mergeIntoConverted(keyPathArg2, filterArg2);
|
|
2946
|
+
}
|
|
2947
|
+
};
|
|
2948
|
+
|
|
2949
|
+
for (const key of Object.keys(filterArg)) {
|
|
2950
|
+
// Skip logical operators, they were already processed
|
|
2951
|
+
if (!logicalOperators.includes(key)) {
|
|
2952
|
+
convertFilterArgument(key, filterArg[key]);
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
// Add back processed logical operators
|
|
2957
|
+
Object.assign(convertedFilter, processedFilter);
|
|
2958
|
+
|
|
2959
|
+
return convertedFilter;
|
|
2960
|
+
};
|
|
2961
|
+
|
|
2962
|
+
export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends IManager = any> {
|
|
2963
|
+
/**
|
|
2964
|
+
* the collection object an Doc belongs to
|
|
2965
|
+
*/
|
|
2966
|
+
public static collection: SmartdataCollection<any>;
|
|
2967
|
+
public static get exact(): unknown {
|
|
2968
|
+
throw new Error('Model is not configured for exact persistence.');
|
|
2969
|
+
}
|
|
2970
|
+
declare public collection: SmartdataCollection<any>;
|
|
2971
|
+
public static defaultManager;
|
|
2972
|
+
public static manager;
|
|
2973
|
+
declare public manager: TManager;
|
|
2974
|
+
|
|
2975
|
+
/**
|
|
2976
|
+
* Helper to get collection with fallback to static for Deno compatibility
|
|
2977
|
+
*/
|
|
2978
|
+
private getCollectionSafe(): SmartdataCollection<any> {
|
|
2979
|
+
return this.collection || (this.constructor as any).collection;
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
// STATIC
|
|
2983
|
+
/** Insert a strict primary-only bootstrap record without separate collection/index DDL. */
|
|
2984
|
+
public static async insertBootstrapRecord(
|
|
2985
|
+
dbArg: SmartdataDb,
|
|
2986
|
+
recordArg: unknown,
|
|
2987
|
+
optionsArg?: ISmartdataBootstrapRecordOptions,
|
|
2988
|
+
): Promise<ISmartdataBootstrapRecordResult> {
|
|
2989
|
+
return insertBootstrapRecord(this, dbArg, recordArg, optionsArg);
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
/** Inspect bounded stored BSON without initializing or hydrating the model. */
|
|
2993
|
+
public static async inspectStoredDocuments(
|
|
2994
|
+
dbArg: SmartdataDb,
|
|
2995
|
+
optionsArg?: ISmartdataStoredInspectionOptions,
|
|
2996
|
+
): Promise<ISmartdataStoredInspection> {
|
|
2997
|
+
return inspectStoredDocuments(this, dbArg, optionsArg);
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
/** Diagnose stored BSON with explicit ordinary validation evidence, without hydration. */
|
|
3001
|
+
public static async inspectStoredDocumentEvidence(
|
|
3002
|
+
dbArg: SmartdataDb,
|
|
3003
|
+
optionsArg: ISmartdataStoredInspectionOptions = {},
|
|
3004
|
+
): Promise<ISmartdataStoredEvidenceInspection> {
|
|
3005
|
+
return inspectStoredDocumentEvidence(this, dbArg, optionsArg);
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
public static createInstanceFromMongoDbNativeDoc<T>(
|
|
3009
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
3010
|
+
mongoDbNativeDocArg: any,
|
|
3011
|
+
): T {
|
|
3012
|
+
const policy = getOrdinaryPersistencePolicy(this);
|
|
3013
|
+
if (policy) {
|
|
3014
|
+
mongoDbNativeDocArg = validateOrdinaryStoredDocument(
|
|
3015
|
+
mongoDbNativeDocArg, policy,
|
|
3016
|
+
[...(this as any).getDeclaredPersistedRoots()],
|
|
3017
|
+
);
|
|
3018
|
+
}
|
|
3019
|
+
const newInstance = new this() as T & Record<string, any>;
|
|
3020
|
+
if (policy) {
|
|
3021
|
+
for (const field of (this as any).getDeclaredPersistedRoots() as Set<string>) {
|
|
3022
|
+
if (!Object.prototype.hasOwnProperty.call(mongoDbNativeDocArg, field)) {
|
|
3023
|
+
delete newInstance[field];
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
if (policy?.timestamps === 'none') {
|
|
3028
|
+
delete newInstance['_createdAt'];
|
|
3029
|
+
delete newInstance['_updatedAt'];
|
|
3030
|
+
}
|
|
3031
|
+
(newInstance as any).creationStatus = 'db';
|
|
3032
|
+
for (const key of Object.keys(mongoDbNativeDocArg)) {
|
|
3033
|
+
const rawValue = mongoDbNativeDocArg[key];
|
|
3034
|
+
const optionsMap = (this as any)._svDbOptions || {};
|
|
3035
|
+
const opts = optionsMap[key];
|
|
3036
|
+
(newInstance as any)[key] = opts && typeof opts.deserialize === 'function'
|
|
3037
|
+
? opts.deserialize(rawValue)
|
|
3038
|
+
: rawValue;
|
|
3039
|
+
}
|
|
3040
|
+
return newInstance;
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
private static getDeclaredPersistedRoots(): Set<string> {
|
|
3044
|
+
const prototype = this.prototype as {
|
|
3045
|
+
globalSaveableProperties?: string[];
|
|
3046
|
+
saveableProperties?: string[];
|
|
3047
|
+
};
|
|
3048
|
+
const roots = new Set([
|
|
3049
|
+
...(prototype.globalSaveableProperties || []),
|
|
3050
|
+
...(prototype.saveableProperties || []),
|
|
3051
|
+
]);
|
|
3052
|
+
if (getOrdinaryPersistencePolicy(this)?.idType === 'string') {
|
|
3053
|
+
roots.add('_id');
|
|
3054
|
+
stringIdRootSets.add(roots);
|
|
3055
|
+
}
|
|
3056
|
+
return roots;
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
private static getDeclaredUniqueRoots(): Set<string> {
|
|
3060
|
+
const prototype = this.prototype as {
|
|
3061
|
+
uniqueIndexes?: string[];
|
|
3062
|
+
};
|
|
3063
|
+
return new Set([
|
|
3064
|
+
...(prototype.uniqueIndexes || []),
|
|
3065
|
+
...(getOrdinaryPersistencePolicy(this)?.idType === 'string' ? ['_id'] : []),
|
|
3066
|
+
]);
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
private static getDeclaredIdentityValueTypes(): Map<
|
|
3070
|
+
string,
|
|
3071
|
+
TSmartdataIdentityValueType
|
|
3072
|
+
> {
|
|
3073
|
+
const prototype = this.prototype as {
|
|
3074
|
+
uniqueIndexes?: string[];
|
|
3075
|
+
identityValueTypes?: Record<string, TSmartdataIdentityValueType>;
|
|
3076
|
+
};
|
|
3077
|
+
return new Map(
|
|
3078
|
+
[...(this as any).getDeclaredUniqueRoots() as Set<string>].map((identityFieldArg) => [
|
|
3079
|
+
identityFieldArg,
|
|
3080
|
+
prototype.identityValueTypes?.[identityFieldArg] ?? 'string',
|
|
3081
|
+
]),
|
|
3082
|
+
);
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
private static getDeclaredNumericRoots(): Set<string> {
|
|
3086
|
+
const collection: SmartdataCollection<any> = (this as any).collection;
|
|
3087
|
+
return new Set(collection.getBoundModelSchema()?.numericFields || []);
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
/**
|
|
3091
|
+
* Returns every equality key set that globally constrains a single document
|
|
3092
|
+
* of this model: each declared identity field on its own, plus the full key
|
|
3093
|
+
* set of every declared unqualified unique index. Unique indexes that carry
|
|
3094
|
+
* any option beyond `unique` (sparse, partial, or otherwise uninterpretable)
|
|
3095
|
+
* or a non-numeric key direction do not qualify — such indexes do not
|
|
3096
|
+
* guarantee at most one matching document for an equality filter.
|
|
3097
|
+
*/
|
|
3098
|
+
private static getGloballyConstrainingKeySets(): ReadonlyArray<
|
|
3099
|
+
ReadonlySet<string>
|
|
3100
|
+
> {
|
|
3101
|
+
const keySets: Array<ReadonlySet<string>> = [];
|
|
3102
|
+
for (const identityField of (this as any).getDeclaredUniqueRoots() as Set<string>) {
|
|
3103
|
+
keySets.push(new Set([identityField]));
|
|
3104
|
+
}
|
|
3105
|
+
const collection: SmartdataCollection<any> = (this as any).collection;
|
|
3106
|
+
const modelSchema = collection.getBoundModelSchema();
|
|
3107
|
+
if (modelSchema) {
|
|
3108
|
+
for (const index of modelSchema.indexes) {
|
|
3109
|
+
if (index.options.unique !== true) {
|
|
3110
|
+
continue;
|
|
3111
|
+
}
|
|
3112
|
+
const hasUnqualifiedOptions = Object.keys(index.options).every(
|
|
3113
|
+
(optionKeyArg) =>
|
|
3114
|
+
optionKeyArg === 'unique' || optionKeyArg === 'background',
|
|
3115
|
+
);
|
|
3116
|
+
if (!hasUnqualifiedOptions) {
|
|
3117
|
+
continue;
|
|
3118
|
+
}
|
|
3119
|
+
if (
|
|
3120
|
+
index.key.length === 0 ||
|
|
3121
|
+
index.key.some(
|
|
3122
|
+
([, directionArg]) => directionArg !== 1 && directionArg !== -1,
|
|
3123
|
+
)
|
|
3124
|
+
) {
|
|
3125
|
+
continue;
|
|
3126
|
+
}
|
|
3127
|
+
keySets.push(new Set(index.key.map(([fieldArg]) => fieldArg)));
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
return keySets;
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
private static getDeclaredAtomicPaths(): Set<string> {
|
|
3134
|
+
const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
|
|
3135
|
+
const uniqueRoots = (this as any).getDeclaredUniqueRoots() as Set<string>;
|
|
3136
|
+
const options = (this as any)._svDbOptions as
|
|
3137
|
+
| Record<string, SvDbOptions>
|
|
3138
|
+
| undefined;
|
|
3139
|
+
const atomicPaths = new Set<string>();
|
|
3140
|
+
for (const [root, fieldOptions] of Object.entries(options || {})) {
|
|
3141
|
+
if (fieldOptions.atomicPaths === undefined) {
|
|
3142
|
+
continue;
|
|
3143
|
+
}
|
|
3144
|
+
if (!Array.isArray(fieldOptions.atomicPaths)) {
|
|
3145
|
+
throw new SmartdataPersistenceError(
|
|
3146
|
+
'invalid_configuration',
|
|
3147
|
+
`Atomic paths for persisted field "${root}" must be an array.`,
|
|
3148
|
+
);
|
|
3149
|
+
}
|
|
3150
|
+
if (
|
|
3151
|
+
typeof fieldOptions.serialize === 'function'
|
|
3152
|
+
|| typeof fieldOptions.deserialize === 'function'
|
|
3153
|
+
) {
|
|
3154
|
+
throw new SmartdataPersistenceError(
|
|
3155
|
+
'invalid_configuration',
|
|
3156
|
+
`Serialized persisted field "${root}" may not declare atomic nested paths.`,
|
|
3157
|
+
);
|
|
3158
|
+
}
|
|
3159
|
+
if (uniqueRoots.has(root)) {
|
|
3160
|
+
throw new SmartdataPersistenceError(
|
|
3161
|
+
'invalid_configuration',
|
|
3162
|
+
`Immutable @unI() field "${root}" may not declare atomic nested paths.`,
|
|
3163
|
+
);
|
|
3164
|
+
}
|
|
3165
|
+
for (const relativePath of fieldOptions.atomicPaths) {
|
|
3166
|
+
if (typeof relativePath !== 'string' || relativePath.length === 0) {
|
|
3167
|
+
throw new SmartdataPersistenceError(
|
|
3168
|
+
'invalid_configuration',
|
|
3169
|
+
`Atomic paths for persisted field "${root}" must be non-empty strings.`,
|
|
3170
|
+
);
|
|
3171
|
+
}
|
|
3172
|
+
const fullPath = `${root}.${relativePath}`;
|
|
3173
|
+
try {
|
|
3174
|
+
requireDeclaredPath(
|
|
3175
|
+
fullPath,
|
|
3176
|
+
declaredRoots,
|
|
3177
|
+
'Atomic path declaration',
|
|
3178
|
+
new Set([fullPath]),
|
|
3179
|
+
);
|
|
3180
|
+
} catch (errorArg) {
|
|
3181
|
+
throw new SmartdataPersistenceError(
|
|
3182
|
+
'invalid_configuration',
|
|
3183
|
+
`Atomic path declaration "${fullPath}" is invalid.`,
|
|
3184
|
+
{ cause: errorArg },
|
|
3185
|
+
);
|
|
3186
|
+
}
|
|
3187
|
+
atomicPaths.add(fullPath);
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
return atomicPaths;
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
private static getSerializedPersistedRoots(): Set<string> {
|
|
3194
|
+
const options = (this as any)._svDbOptions as
|
|
3195
|
+
| Record<
|
|
3196
|
+
string,
|
|
3197
|
+
{
|
|
3198
|
+
serialize?: unknown;
|
|
3199
|
+
deserialize?: unknown;
|
|
3200
|
+
}
|
|
3201
|
+
>
|
|
3202
|
+
| undefined;
|
|
3203
|
+
return new Set(
|
|
3204
|
+
Object.entries(options || {})
|
|
3205
|
+
.filter(
|
|
3206
|
+
([, fieldOptions]) =>
|
|
3207
|
+
typeof fieldOptions.serialize === 'function' ||
|
|
3208
|
+
typeof fieldOptions.deserialize === 'function',
|
|
3209
|
+
)
|
|
3210
|
+
.map(([field]) => field),
|
|
3211
|
+
);
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
private static getDeclaredQueryPaths(): Set<string> {
|
|
3215
|
+
return new Set([
|
|
3216
|
+
...(this as any).getDeclaredAtomicPaths() as Set<string>,
|
|
3217
|
+
...getExpectedCollectionTopologyForModel(this).indexes.flatMap(
|
|
3218
|
+
(indexArg) => indexArg.keys.map((keyArg) => keyArg.path),
|
|
3219
|
+
),
|
|
3220
|
+
]);
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
private static assertHydratedProjection(projectionArg?: unknown, modifierArg?: unknown): void {
|
|
3224
|
+
if (getOrdinaryPersistencePolicy(this) && (projectionArg !== undefined || modifierArg !== undefined)) {
|
|
3225
|
+
throw new SmartdataPersistenceError('unsupported_operation',
|
|
3226
|
+
'Whole-stored-document validation requires an unprojected read without native modifiers.');
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
/**
|
|
3231
|
+
* Initializes the model collection and all model-owned indexes. Failed
|
|
3232
|
+
* initialization is retryable after the underlying conflict is repaired.
|
|
3233
|
+
*/
|
|
3234
|
+
public static async init(): Promise<void> {
|
|
3235
|
+
const collection: SmartdataCollection<any> = (this as any).collection;
|
|
3236
|
+
await collection.init();
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
public static async ensureInitialized(): Promise<void> {
|
|
3240
|
+
await this.init();
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
/** Verify or install the model's exact topology within one explicit deadline. */
|
|
3244
|
+
public static async prepareCollection(opts: ISmartdataCollectionPreparationOptions): Promise<void> {
|
|
3245
|
+
validateCollectionPreparationOptions(opts);
|
|
3246
|
+
const collection: SmartdataCollection<any> = (this as any).collection;
|
|
3247
|
+
await collection.prepareCollection(opts);
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
/**
|
|
3251
|
+
* Returns a sanitized view of the indexes installed for this model.
|
|
3252
|
+
*/
|
|
3253
|
+
public static async getIndexInfo(): Promise<ISmartdataIndexInfo[]> {
|
|
3254
|
+
const collection: SmartdataCollection<any> = (this as any).collection;
|
|
3255
|
+
return collection.getIndexInfo();
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
/**
|
|
3259
|
+
* Returns the model's immutable collection and index contract without
|
|
3260
|
+
* resolving a manager, database, or SmartdataCollection.
|
|
3261
|
+
*/
|
|
3262
|
+
public static getExpectedCollectionTopology(): ISmartdataExpectedCollectionTopology {
|
|
3263
|
+
return getExpectedCollectionTopologyForModel(this as any);
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
/**
|
|
3267
|
+
* Inspects an explicitly supplied connected database without initializing or
|
|
3268
|
+
* mutating the model collection.
|
|
3269
|
+
*/
|
|
3270
|
+
public static async inspectCollectionTopology(
|
|
3271
|
+
dbArg: SmartdataDb,
|
|
3272
|
+
): Promise<ISmartdataCollectionTopologyInspection> {
|
|
3273
|
+
return inspectCollectionTopologyForModel(this as any, dbArg);
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
/**
|
|
3277
|
+
* Inserts a newly constructed ordinary model through SmartData.
|
|
3278
|
+
*/
|
|
3279
|
+
public static async insert<T extends SmartDataDbDoc<any, any>>(
|
|
3280
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
3281
|
+
documentArg: T,
|
|
3282
|
+
opts?: { session?: TSmartdataOrdinarySession },
|
|
3283
|
+
): Promise<plugins.mongodb.InsertOneResult<{ _id: TSmartdataPrimaryId<T> }>> {
|
|
3284
|
+
if ((this as any)[exactPersistencePolicySymbol]) {
|
|
3285
|
+
throw new SmartdataPersistenceError(
|
|
3286
|
+
'unsupported_operation',
|
|
3287
|
+
'Ordinary insert is unavailable for exact-persistence models.',
|
|
3288
|
+
);
|
|
3289
|
+
}
|
|
3290
|
+
if (!(documentArg instanceof this)) {
|
|
3291
|
+
throw new SmartdataPersistenceError(
|
|
3292
|
+
'invalid_argument',
|
|
3293
|
+
`Model insert requires an instance of "${this.name}".`,
|
|
3294
|
+
);
|
|
3295
|
+
}
|
|
3296
|
+
if (documentArg.creationStatus !== 'new') {
|
|
3297
|
+
throw new SmartdataPersistenceError(
|
|
3298
|
+
'invalid_argument',
|
|
3299
|
+
'Model insert requires a newly constructed document.',
|
|
3300
|
+
);
|
|
3301
|
+
}
|
|
3302
|
+
assertUsableIdentityValues(
|
|
3303
|
+
documentArg as unknown as Record<string, unknown>,
|
|
3304
|
+
(this.prototype as { uniqueIndexes?: string[] }).uniqueIndexes || [],
|
|
3305
|
+
(this.prototype as {
|
|
3306
|
+
identityValueTypes?: Record<string, TSmartdataIdentityValueType>;
|
|
3307
|
+
}).identityValueTypes,
|
|
3308
|
+
);
|
|
3309
|
+
const now = new Date().toISOString();
|
|
3310
|
+
if (getOrdinaryPersistencePolicy(this)?.timestamps !== 'none') {
|
|
3311
|
+
documentArg._createdAt = now;
|
|
3312
|
+
documentArg._updatedAt = now;
|
|
3313
|
+
}
|
|
3314
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
3315
|
+
const result = await collection.insert(documentArg as any, opts);
|
|
3316
|
+
if (!isOrdinarySessionInTransaction(opts?.session)) {
|
|
3317
|
+
documentArg.creationStatus = 'db';
|
|
3318
|
+
}
|
|
3319
|
+
return result as unknown as plugins.mongodb.InsertOneResult<{ _id: TSmartdataPrimaryId<T> }>;
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
/**
|
|
3323
|
+
* Inserts a batch of newly constructed ordinary models through SmartData.
|
|
3324
|
+
* Each document inserts atomically; the batch as a whole is not isolated
|
|
3325
|
+
* unless the caller supplies a transaction session. With ordered semantics
|
|
3326
|
+
* (the default) MongoDB stops at the first failure; documents MongoDB
|
|
3327
|
+
* reports as inserted are marked database-backed even when the batch fails
|
|
3328
|
+
* part-way, so callers can compensate precisely.
|
|
3329
|
+
*/
|
|
3330
|
+
public static async insertMany<T extends SmartDataDbDoc<any, any>>(
|
|
3331
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
3332
|
+
documentsArg: T[],
|
|
3333
|
+
opts?: { session?: TSmartdataOrdinarySession; ordered?: boolean },
|
|
3334
|
+
): Promise<plugins.mongodb.InsertManyResult<{ _id: TSmartdataPrimaryId<T> }>> {
|
|
3335
|
+
if ((this as any)[exactPersistencePolicySymbol]) {
|
|
3336
|
+
throw new SmartdataPersistenceError(
|
|
3337
|
+
'unsupported_operation',
|
|
3338
|
+
'Ordinary insertMany is unavailable for exact-persistence models.',
|
|
3339
|
+
);
|
|
3340
|
+
}
|
|
3341
|
+
if (!Array.isArray(documentsArg) || documentsArg.length === 0) {
|
|
3342
|
+
throw new SmartdataPersistenceError(
|
|
3343
|
+
'invalid_argument',
|
|
3344
|
+
'Model insertMany requires a non-empty array of documents.',
|
|
3345
|
+
);
|
|
3346
|
+
}
|
|
3347
|
+
if (
|
|
3348
|
+
opts?.ordered !== undefined
|
|
3349
|
+
&& typeof opts.ordered !== 'boolean'
|
|
3350
|
+
) {
|
|
3351
|
+
throw new SmartdataPersistenceError(
|
|
3352
|
+
'invalid_argument',
|
|
3353
|
+
'Model insertMany ordered must be a boolean.',
|
|
3354
|
+
);
|
|
3355
|
+
}
|
|
3356
|
+
const seenDocuments = new Set<T>();
|
|
3357
|
+
for (const documentArg of documentsArg) {
|
|
3358
|
+
if (!(documentArg instanceof this)) {
|
|
3359
|
+
throw new SmartdataPersistenceError(
|
|
3360
|
+
'invalid_argument',
|
|
3361
|
+
`Model insertMany requires instances of "${this.name}".`,
|
|
3362
|
+
);
|
|
3363
|
+
}
|
|
3364
|
+
if (documentArg.creationStatus !== 'new') {
|
|
3365
|
+
throw new SmartdataPersistenceError(
|
|
3366
|
+
'invalid_argument',
|
|
3367
|
+
'Model insertMany requires newly constructed documents.',
|
|
3368
|
+
);
|
|
3369
|
+
}
|
|
3370
|
+
if (seenDocuments.has(documentArg)) {
|
|
3371
|
+
throw new SmartdataPersistenceError(
|
|
3372
|
+
'invalid_argument',
|
|
3373
|
+
'Model insertMany may not contain the same document instance twice.',
|
|
3374
|
+
);
|
|
3375
|
+
}
|
|
3376
|
+
seenDocuments.add(documentArg);
|
|
3377
|
+
assertUsableIdentityValues(
|
|
3378
|
+
documentArg as unknown as Record<string, unknown>,
|
|
3379
|
+
(this.prototype as { uniqueIndexes?: string[] }).uniqueIndexes || [],
|
|
3380
|
+
(this.prototype as {
|
|
3381
|
+
identityValueTypes?: Record<string, TSmartdataIdentityValueType>;
|
|
3382
|
+
}).identityValueTypes,
|
|
3383
|
+
);
|
|
3384
|
+
}
|
|
3385
|
+
const now = new Date().toISOString();
|
|
3386
|
+
for (const documentArg of documentsArg) {
|
|
3387
|
+
if (getOrdinaryPersistencePolicy(this)?.timestamps !== 'none') {
|
|
3388
|
+
documentArg._createdAt = now;
|
|
3389
|
+
documentArg._updatedAt = now;
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
3393
|
+
const inTransaction = isOrdinarySessionInTransaction(opts?.session);
|
|
3394
|
+
const markInsertedDocuments = (
|
|
3395
|
+
insertedIdsArg: Record<number, unknown> | undefined,
|
|
3396
|
+
): void => {
|
|
3397
|
+
if (inTransaction || !insertedIdsArg) {
|
|
3398
|
+
return;
|
|
3399
|
+
}
|
|
3400
|
+
for (const indexKey of Object.keys(insertedIdsArg)) {
|
|
3401
|
+
const insertedDocument = documentsArg[Number(indexKey)];
|
|
3402
|
+
if (insertedDocument) {
|
|
3403
|
+
insertedDocument.creationStatus = 'db';
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
};
|
|
3407
|
+
try {
|
|
3408
|
+
const result = await collection.insertMany(documentsArg as any, opts);
|
|
3409
|
+
markInsertedDocuments(
|
|
3410
|
+
result.insertedIds as unknown as Record<number, unknown>,
|
|
3411
|
+
);
|
|
3412
|
+
return result as unknown as plugins.mongodb.InsertManyResult<{ _id: TSmartdataPrimaryId<T> }>;
|
|
3413
|
+
} catch (error) {
|
|
3414
|
+
const bulkError = error instanceof SmartdataPersistenceError
|
|
3415
|
+
? error.cause
|
|
3416
|
+
: error;
|
|
3417
|
+
if (bulkError instanceof plugins.mongodb.MongoBulkWriteError) {
|
|
3418
|
+
markInsertedDocuments(
|
|
3419
|
+
bulkError.result?.insertedIds as unknown as
|
|
3420
|
+
| Record<number, unknown>
|
|
3421
|
+
| undefined,
|
|
3422
|
+
);
|
|
3423
|
+
}
|
|
3424
|
+
throw error;
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
/**
|
|
3429
|
+
* Inserts absent identities without changing existing documents, including
|
|
3430
|
+
* their timestamps. The unordered batch is bounded to 1000 documents and
|
|
3431
|
+
* 16 MiB of serialized BSON. New-model timestamps are preserved. Inputs stay
|
|
3432
|
+
* new so the same batch can be retried after an ambiguous result; read a
|
|
3433
|
+
* stored instance before updating it because an existing body may differ.
|
|
3434
|
+
*/
|
|
3435
|
+
public static async insertManyIfAbsent<T extends SmartDataDbDoc<any, any>>(
|
|
3436
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
3437
|
+
documentsArg: T[],
|
|
3438
|
+
opts: ISmartdataInsertManyIfAbsentOptions<T>,
|
|
3439
|
+
): Promise<ISmartdataInsertManyIfAbsentResult> {
|
|
3440
|
+
if ((this as any)[exactPersistencePolicySymbol]) {
|
|
3441
|
+
throw new SmartdataPersistenceError('unsupported_operation',
|
|
3442
|
+
'insertManyIfAbsent is unavailable for exact-persistence models.');
|
|
3443
|
+
}
|
|
3444
|
+
if (!Array.isArray(documentsArg) || documentsArg.length < 1 || documentsArg.length > 1000) {
|
|
3445
|
+
throw new SmartdataPersistenceError('invalid_argument',
|
|
3446
|
+
'insertManyIfAbsent requires 1 to 1000 newly constructed documents.');
|
|
3447
|
+
}
|
|
3448
|
+
const identityTypes = (this as any).getDeclaredIdentityValueTypes() as Map<string, TSmartdataIdentityValueType>;
|
|
3449
|
+
const serializedRoots = (this as any).getSerializedPersistedRoots() as Set<string>;
|
|
3450
|
+
const identityField = opts?.identityField;
|
|
3451
|
+
if (typeof identityField !== 'string' || !identityTypes.has(identityField) || serializedRoots.has(identityField)) {
|
|
3452
|
+
throw new SmartdataPersistenceError('invalid_argument',
|
|
3453
|
+
'insertManyIfAbsent requires a declared, non-serialized @unI identity field.');
|
|
3454
|
+
}
|
|
3455
|
+
const timeoutMS = requireBoundedInteger(opts?.timeoutMS, 'insertManyIfAbsent timeoutMS', 120_000);
|
|
3456
|
+
const identities = new Set<string | number>();
|
|
3457
|
+
for (const document of documentsArg) {
|
|
3458
|
+
if (!(document instanceof this) || document.creationStatus !== 'new') {
|
|
3459
|
+
throw new SmartdataPersistenceError('invalid_argument',
|
|
3460
|
+
'insertManyIfAbsent requires newly constructed instances of the receiving model.');
|
|
3461
|
+
}
|
|
3462
|
+
assertUsableIdentityValues(document as unknown as Record<string, unknown>,
|
|
3463
|
+
(this.prototype as { uniqueIndexes?: string[] }).uniqueIndexes || [],
|
|
3464
|
+
(this.prototype as { identityValueTypes?: Record<string, TSmartdataIdentityValueType> }).identityValueTypes);
|
|
3465
|
+
const identity = document[identityField] as string | number;
|
|
3466
|
+
if (identities.has(identity)) {
|
|
3467
|
+
throw new SmartdataPersistenceError('invalid_argument',
|
|
3468
|
+
'insertManyIfAbsent may not repeat an identity within one batch.');
|
|
3469
|
+
}
|
|
3470
|
+
identities.add(identity);
|
|
3471
|
+
if (getOrdinaryPersistencePolicy(this)?.timestamps !== 'none') {
|
|
3472
|
+
for (const timestamp of [document._createdAt, document._updatedAt]) {
|
|
3473
|
+
if (typeof timestamp !== 'string' || !Number.isFinite(Date.parse(timestamp))) {
|
|
3474
|
+
throw new SmartdataPersistenceError('invalid_argument',
|
|
3475
|
+
'insertManyIfAbsent requires valid creation and update timestamps.');
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
3481
|
+
return await collection.insertManyIfAbsent(documentsArg as any, identityField, {
|
|
3482
|
+
session: opts.session,
|
|
3483
|
+
timeoutMS,
|
|
3484
|
+
});
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
/**
|
|
3488
|
+
* Atomically updates one ordinary model document using a strict selector
|
|
3489
|
+
* and a bounded set of MongoDB update operators.
|
|
3490
|
+
*/
|
|
3491
|
+
public static async atomicUpdate<T>(
|
|
3492
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
3493
|
+
filterArg: TSmartdataAtomicFilter<T>,
|
|
3494
|
+
updateArg: ISmartdataAtomicUpdate<T>,
|
|
3495
|
+
opts?: ISmartdataAtomicUpdateOptions,
|
|
3496
|
+
): Promise<ISmartdataAtomicUpdateResult> {
|
|
3497
|
+
if (getOrdinaryPersistencePolicy(this)) {
|
|
3498
|
+
throw new SmartdataPersistenceError('unsupported_operation',
|
|
3499
|
+
'Validated ordinary models require a transactional postimage update.');
|
|
3500
|
+
}
|
|
3501
|
+
if ((this as any)[exactPersistencePolicySymbol]) {
|
|
3502
|
+
throw new SmartdataPersistenceError(
|
|
3503
|
+
'unsupported_operation',
|
|
3504
|
+
'atomicUpdate is unavailable for exact-persistence models.',
|
|
3505
|
+
);
|
|
3506
|
+
}
|
|
3507
|
+
const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
|
|
3508
|
+
const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
|
|
3509
|
+
const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
|
|
3510
|
+
string,
|
|
3511
|
+
TSmartdataIdentityValueType
|
|
3512
|
+
>;
|
|
3513
|
+
const numericRoots = (this as any).getDeclaredNumericRoots() as Set<string>;
|
|
3514
|
+
const uniqueRoots = new Set(identityValueTypes.keys());
|
|
3515
|
+
const serializedRoots = (this as any).getSerializedPersistedRoots() as Set<string>;
|
|
3516
|
+
const normalizedFilter = normalizeStrictFilter(
|
|
3517
|
+
filterArg as Record<string, unknown>,
|
|
3518
|
+
declaredRoots,
|
|
3519
|
+
declaredAtomicPaths,
|
|
3520
|
+
identityValueTypes,
|
|
3521
|
+
numericRoots,
|
|
3522
|
+
serializedRoots,
|
|
3523
|
+
'Atomic update',
|
|
3524
|
+
);
|
|
3525
|
+
const normalizedOptions = '$expr' in normalizedFilter
|
|
3526
|
+
? normalizeAtomicExpressionUpdateOptions(opts)
|
|
3527
|
+
: opts;
|
|
3528
|
+
if (normalizedOptions?.upsert === true && '$expr' in normalizedFilter) {
|
|
3529
|
+
throw new SmartdataPersistenceError(
|
|
3530
|
+
'invalid_argument',
|
|
3531
|
+
'Atomic update does not support upsert when the filter contains $expr.',
|
|
3532
|
+
);
|
|
3533
|
+
}
|
|
3534
|
+
const normalizedUpdate = normalizeAtomicUpdate(
|
|
3535
|
+
updateArg,
|
|
3536
|
+
declaredRoots,
|
|
3537
|
+
declaredAtomicPaths,
|
|
3538
|
+
uniqueRoots,
|
|
3539
|
+
serializedRoots,
|
|
3540
|
+
);
|
|
3541
|
+
const now = new Date().toISOString();
|
|
3542
|
+
normalizedUpdate.$set = {
|
|
3543
|
+
...(normalizedUpdate.$set || {}),
|
|
3544
|
+
_updatedAt: now,
|
|
3545
|
+
};
|
|
3546
|
+
if (normalizedOptions?.upsert === true) {
|
|
3547
|
+
normalizedUpdate.$setOnInsert = {
|
|
3548
|
+
...(normalizedUpdate.$setOnInsert || {}),
|
|
3549
|
+
_createdAt: now,
|
|
3550
|
+
};
|
|
3551
|
+
}
|
|
3552
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
3553
|
+
const result = await executeAtomicUpdate(
|
|
3554
|
+
collection,
|
|
3555
|
+
normalizedFilter,
|
|
3556
|
+
normalizedUpdate,
|
|
3557
|
+
normalizedOptions,
|
|
3558
|
+
);
|
|
3559
|
+
return {
|
|
3560
|
+
acknowledged: result.acknowledged,
|
|
3561
|
+
matchedCount: result.matchedCount,
|
|
3562
|
+
modifiedCount: result.modifiedCount,
|
|
3563
|
+
upsertedId: result.upsertedId as plugins.mongodb.ObjectId | null,
|
|
3564
|
+
};
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
/**
|
|
3568
|
+
* Atomically updates one ordinary model document and returns a hydrated
|
|
3569
|
+
* before or after image with a sanitized persistence outcome.
|
|
3570
|
+
*/
|
|
3571
|
+
public static async atomicFindOneAndUpdate<
|
|
3572
|
+
T,
|
|
3573
|
+
TReturnDocument extends TSmartdataAtomicReturnDocument,
|
|
3574
|
+
>(
|
|
3575
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
3576
|
+
filterArg: TSmartdataAtomicFilter<T>,
|
|
3577
|
+
updateArg: ISmartdataAtomicUpdate<T>,
|
|
3578
|
+
optionsArg: ISmartdataAtomicFindOneAndUpdateOptions<TReturnDocument, T>,
|
|
3579
|
+
): Promise<TSmartdataAtomicFindOneAndUpdateResult<T, TReturnDocument>> {
|
|
3580
|
+
if ((this as any)[exactPersistencePolicySymbol]) {
|
|
3581
|
+
throw new SmartdataPersistenceError(
|
|
3582
|
+
'unsupported_operation',
|
|
3583
|
+
'atomicFindOneAndUpdate is unavailable for exact-persistence models.',
|
|
3584
|
+
);
|
|
3585
|
+
}
|
|
3586
|
+
const normalizedOptions = normalizeAtomicFindOneAndUpdateOptions(optionsArg);
|
|
3587
|
+
const policy = getOrdinaryPersistencePolicy(this);
|
|
3588
|
+
const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
|
|
3589
|
+
const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
|
|
3590
|
+
const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
|
|
3591
|
+
string,
|
|
3592
|
+
TSmartdataIdentityValueType
|
|
3593
|
+
>;
|
|
3594
|
+
const numericRoots = (this as any).getDeclaredNumericRoots() as Set<string>;
|
|
3595
|
+
const uniqueRoots = new Set(identityValueTypes.keys());
|
|
3596
|
+
const serializedRoots = (this as any).getSerializedPersistedRoots() as Set<string>;
|
|
3597
|
+
const normalizedFilter = normalizeStrictFilter(
|
|
3598
|
+
filterArg as Record<string, unknown>,
|
|
3599
|
+
declaredRoots,
|
|
3600
|
+
declaredAtomicPaths,
|
|
3601
|
+
identityValueTypes,
|
|
3602
|
+
numericRoots,
|
|
3603
|
+
serializedRoots,
|
|
3604
|
+
'Atomic find-one-and-update',
|
|
3605
|
+
);
|
|
3606
|
+
if (normalizedOptions.upsert && '$expr' in normalizedFilter) {
|
|
3607
|
+
throw new SmartdataPersistenceError(
|
|
3608
|
+
'invalid_argument',
|
|
3609
|
+
'Atomic find-one-and-update does not support upsert when the filter contains $expr.',
|
|
3610
|
+
);
|
|
3611
|
+
}
|
|
3612
|
+
const normalizedUpdate = normalizeAtomicUpdate(
|
|
3613
|
+
updateArg,
|
|
3614
|
+
declaredRoots,
|
|
3615
|
+
declaredAtomicPaths,
|
|
3616
|
+
uniqueRoots,
|
|
3617
|
+
serializedRoots,
|
|
3618
|
+
);
|
|
3619
|
+
if (policy?.timestamps !== 'none') {
|
|
3620
|
+
const now = new Date().toISOString();
|
|
3621
|
+
normalizedUpdate.$set = { ...(normalizedUpdate.$set || {}), _updatedAt: now };
|
|
3622
|
+
if (normalizedOptions.upsert) {
|
|
3623
|
+
normalizedUpdate.$setOnInsert = { ...(normalizedUpdate.$setOnInsert || {}), _createdAt: now };
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
const normalizedSort = normalizeSort(
|
|
3627
|
+
normalizedOptions.sort as TSmartdataSort<T> | undefined,
|
|
3628
|
+
declaredRoots,
|
|
3629
|
+
(this as any).getDeclaredQueryPaths() as Set<string>,
|
|
3630
|
+
);
|
|
3631
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
3632
|
+
const result = await executeAtomicFindOneAndUpdate(
|
|
3633
|
+
collection,
|
|
3634
|
+
normalizedFilter,
|
|
3635
|
+
normalizedUpdate,
|
|
3636
|
+
{
|
|
3637
|
+
returnDocument: normalizedOptions.returnDocument,
|
|
3638
|
+
upsert: normalizedOptions.upsert,
|
|
3639
|
+
...(normalizedSort ? { sort: normalizedSort as Record<string, 1 | -1> } : {}),
|
|
3640
|
+
...(normalizedOptions.session ? { session: normalizedOptions.session } : {}),
|
|
3641
|
+
},
|
|
3642
|
+
);
|
|
3643
|
+
if (result.document === null) {
|
|
3644
|
+
return result as TSmartdataAtomicFindOneAndUpdateResult<T, TReturnDocument>;
|
|
3645
|
+
}
|
|
3646
|
+
return {
|
|
3647
|
+
status: result.status,
|
|
3648
|
+
document: (this as any).createInstanceFromMongoDbNativeDoc(result.document) as T,
|
|
3649
|
+
} as TSmartdataAtomicFindOneAndUpdateResult<T, TReturnDocument>;
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
/**
|
|
3653
|
+
* Atomically deletes one ordinary model document using a strict selector.
|
|
3654
|
+
*
|
|
3655
|
+
* This static operation intentionally does not hydrate an instance or run
|
|
3656
|
+
* instance deletion hooks. Callers that require those hooks must use the
|
|
3657
|
+
* instance `delete()` method instead.
|
|
3658
|
+
*/
|
|
3659
|
+
public static async atomicDelete<T>(
|
|
3660
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
3661
|
+
filterArg: TSmartdataAtomicFilter<T>,
|
|
3662
|
+
opts?: ISmartdataAtomicDeleteOptions,
|
|
3663
|
+
): Promise<ISmartdataAtomicDeleteResult> {
|
|
3664
|
+
if ((this as any)[exactPersistencePolicySymbol]) {
|
|
3665
|
+
throw new SmartdataPersistenceError(
|
|
3666
|
+
'unsupported_operation',
|
|
3667
|
+
'atomicDelete is unavailable for exact-persistence models.',
|
|
3668
|
+
);
|
|
3669
|
+
}
|
|
3670
|
+
const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
|
|
3671
|
+
const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
|
|
3672
|
+
const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
|
|
3673
|
+
string,
|
|
3674
|
+
TSmartdataIdentityValueType
|
|
3675
|
+
>;
|
|
3676
|
+
const numericRoots = (this as any).getDeclaredNumericRoots() as Set<string>;
|
|
3677
|
+
const serializedRoots = (this as any).getSerializedPersistedRoots() as Set<string>;
|
|
3678
|
+
const normalizedFilter = normalizeStrictFilter(
|
|
3679
|
+
filterArg as Record<string, unknown>,
|
|
3680
|
+
declaredRoots,
|
|
3681
|
+
declaredAtomicPaths,
|
|
3682
|
+
identityValueTypes,
|
|
3683
|
+
numericRoots,
|
|
3684
|
+
serializedRoots,
|
|
3685
|
+
'Atomic delete',
|
|
3686
|
+
(this as any).getGloballyConstrainingKeySets(),
|
|
3687
|
+
);
|
|
3688
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
3689
|
+
const result = await executeAtomicDelete(
|
|
3690
|
+
collection,
|
|
3691
|
+
normalizedFilter,
|
|
3692
|
+
{ session: opts?.session, timeoutMS: requireBoundedInteger(opts?.timeoutMS, 'Atomic delete timeoutMS', 120_000) },
|
|
3693
|
+
);
|
|
3694
|
+
return {
|
|
3695
|
+
acknowledged: result.acknowledged,
|
|
3696
|
+
deletedCount: result.deletedCount,
|
|
3697
|
+
};
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
/**
|
|
3701
|
+
* Atomically deletes every ordinary model document matching a strict
|
|
3702
|
+
* selector. Each matched document is deleted atomically; the batch as a
|
|
3703
|
+
* whole is not isolated. Unlike `atomicDelete()`, the filter does not need
|
|
3704
|
+
* to pin a unique index — plural intent is explicit in this API — but it
|
|
3705
|
+
* still requires declared fields and at least one top-level equality
|
|
3706
|
+
* anchor.
|
|
3707
|
+
*/
|
|
3708
|
+
public static async atomicDeleteMany<T>(
|
|
3709
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
3710
|
+
filterArg: TSmartdataAtomicFilter<T>,
|
|
3711
|
+
opts?: ISmartdataAtomicDeleteOptions,
|
|
3712
|
+
): Promise<ISmartdataAtomicDeleteResult> {
|
|
3713
|
+
if ((this as any)[exactPersistencePolicySymbol]) {
|
|
3714
|
+
throw new SmartdataPersistenceError(
|
|
3715
|
+
'unsupported_operation',
|
|
3716
|
+
'atomicDeleteMany is unavailable for exact-persistence models.',
|
|
3717
|
+
);
|
|
3718
|
+
}
|
|
3719
|
+
const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
|
|
3720
|
+
const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
|
|
3721
|
+
const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
|
|
3722
|
+
string,
|
|
3723
|
+
TSmartdataIdentityValueType
|
|
3724
|
+
>;
|
|
3725
|
+
const numericRoots = (this as any).getDeclaredNumericRoots() as Set<string>;
|
|
3726
|
+
const serializedRoots = (this as any).getSerializedPersistedRoots() as Set<string>;
|
|
3727
|
+
const normalizedFilter = normalizeStrictFilter(
|
|
3728
|
+
filterArg as Record<string, unknown>,
|
|
3729
|
+
declaredRoots,
|
|
3730
|
+
declaredAtomicPaths,
|
|
3731
|
+
identityValueTypes,
|
|
3732
|
+
numericRoots,
|
|
3733
|
+
serializedRoots,
|
|
3734
|
+
'Atomic delete-many',
|
|
3735
|
+
);
|
|
3736
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
3737
|
+
const result = await executeAtomicDeleteMany(
|
|
3738
|
+
collection,
|
|
3739
|
+
normalizedFilter,
|
|
3740
|
+
{ session: opts?.session, timeoutMS: requireBoundedInteger(opts?.timeoutMS, 'Atomic delete-many timeoutMS', 120_000) },
|
|
3741
|
+
);
|
|
3742
|
+
return {
|
|
3743
|
+
acknowledged: result.acknowledged,
|
|
3744
|
+
deletedCount: result.deletedCount,
|
|
3745
|
+
};
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
/**
|
|
3749
|
+
* Atomically updates every ordinary model document matching a strict
|
|
3750
|
+
* selector. Each matched document is updated atomically; the batch as a
|
|
3751
|
+
* whole is not isolated. No upsert is possible, so `$setOnInsert` is
|
|
3752
|
+
* rejected.
|
|
3753
|
+
*/
|
|
3754
|
+
public static async atomicUpdateMany<T>(
|
|
3755
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
3756
|
+
filterArg: TSmartdataAtomicFilter<T>,
|
|
3757
|
+
updateArg: Omit<ISmartdataAtomicUpdate<T>, '$setOnInsert'>,
|
|
3758
|
+
opts?: ISmartdataAtomicUpdateManyOptions,
|
|
3759
|
+
): Promise<ISmartdataAtomicUpdateManyResult> {
|
|
3760
|
+
if (getOrdinaryPersistencePolicy(this)) {
|
|
3761
|
+
throw new SmartdataPersistenceError('unsupported_operation',
|
|
3762
|
+
'Validated ordinary models require transactional postimage updates.');
|
|
3763
|
+
}
|
|
3764
|
+
if ((this as any)[exactPersistencePolicySymbol]) {
|
|
3765
|
+
throw new SmartdataPersistenceError(
|
|
3766
|
+
'unsupported_operation',
|
|
3767
|
+
'atomicUpdateMany is unavailable for exact-persistence models.',
|
|
3768
|
+
);
|
|
3769
|
+
}
|
|
3770
|
+
if (
|
|
3771
|
+
typeof updateArg === 'object'
|
|
3772
|
+
&& updateArg !== null
|
|
3773
|
+
&& '$setOnInsert' in updateArg
|
|
3774
|
+
) {
|
|
3775
|
+
throw new SmartdataPersistenceError(
|
|
3776
|
+
'invalid_argument',
|
|
3777
|
+
'Atomic update-many does not support $setOnInsert.',
|
|
3778
|
+
);
|
|
3779
|
+
}
|
|
3780
|
+
const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
|
|
3781
|
+
const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
|
|
3782
|
+
const uniqueRoots = (this as any).getDeclaredUniqueRoots() as Set<string>;
|
|
3783
|
+
const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
|
|
3784
|
+
string,
|
|
3785
|
+
TSmartdataIdentityValueType
|
|
3786
|
+
>;
|
|
3787
|
+
const numericRoots = (this as any).getDeclaredNumericRoots() as Set<string>;
|
|
3788
|
+
const serializedRoots = (this as any).getSerializedPersistedRoots() as Set<string>;
|
|
3789
|
+
const normalizedFilter = normalizeStrictFilter(
|
|
3790
|
+
filterArg as Record<string, unknown>,
|
|
3791
|
+
declaredRoots,
|
|
3792
|
+
declaredAtomicPaths,
|
|
3793
|
+
identityValueTypes,
|
|
3794
|
+
numericRoots,
|
|
3795
|
+
serializedRoots,
|
|
3796
|
+
'Atomic update-many',
|
|
3797
|
+
);
|
|
3798
|
+
const normalizedUpdate = normalizeAtomicUpdate(
|
|
3799
|
+
updateArg,
|
|
3800
|
+
declaredRoots,
|
|
3801
|
+
declaredAtomicPaths,
|
|
3802
|
+
uniqueRoots,
|
|
3803
|
+
serializedRoots,
|
|
3804
|
+
);
|
|
3805
|
+
const now = new Date().toISOString();
|
|
3806
|
+
normalizedUpdate.$set = {
|
|
3807
|
+
...(normalizedUpdate.$set || {}),
|
|
3808
|
+
_updatedAt: now,
|
|
3809
|
+
};
|
|
3810
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
3811
|
+
const result = await executeAtomicUpdateMany(
|
|
3812
|
+
collection,
|
|
3813
|
+
normalizedFilter,
|
|
3814
|
+
normalizedUpdate,
|
|
3815
|
+
opts,
|
|
3816
|
+
);
|
|
3817
|
+
return {
|
|
3818
|
+
acknowledged: result.acknowledged,
|
|
3819
|
+
matchedCount: result.matchedCount,
|
|
3820
|
+
modifiedCount: result.modifiedCount,
|
|
3821
|
+
};
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
/**
|
|
3825
|
+
* Computes bounded grouped counts and optional numeric sums server-side.
|
|
3826
|
+
* Groups by one or two declared top-level fields, always returns a `count`
|
|
3827
|
+
* per group, and sums only declared numeric fields. The `$limit` follows
|
|
3828
|
+
* the group stage without a sort, so when more groups exist than the limit
|
|
3829
|
+
* the returned subset is arbitrary. Use `getGroupedTotalsPage()` when the
|
|
3830
|
+
* caller must detect overflow. The aggregation cursor never leaves SmartData.
|
|
3831
|
+
*/
|
|
3832
|
+
public static async getGroupedTotals<T>(
|
|
3833
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
3834
|
+
optionsArg: ISmartdataGroupedTotalsOptions<T>,
|
|
3835
|
+
): Promise<Array<ISmartdataGroupedTotalsRow<T>>> {
|
|
3836
|
+
const page = await (this as any).getGroupedTotalsPage(optionsArg) as
|
|
3837
|
+
ISmartdataGroupedTotalsPage<T>;
|
|
3838
|
+
return page.rows;
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3841
|
+
/**
|
|
3842
|
+
* Computes the same bounded grouped totals as `getGroupedTotals()` and
|
|
3843
|
+
* reports whether at least one additional group exists beyond the requested
|
|
3844
|
+
* row limit.
|
|
3845
|
+
*/
|
|
3846
|
+
public static async getGroupedTotalsPage<T>(
|
|
3847
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
3848
|
+
optionsArg: ISmartdataGroupedTotalsOptions<T>,
|
|
3849
|
+
): Promise<ISmartdataGroupedTotalsPage<T>> {
|
|
3850
|
+
if ((this as any)[exactPersistencePolicySymbol]) {
|
|
3851
|
+
throw new SmartdataPersistenceError(
|
|
3852
|
+
'unsupported_operation',
|
|
3853
|
+
'getGroupedTotals is unavailable for exact-persistence models.',
|
|
3854
|
+
);
|
|
3855
|
+
}
|
|
3856
|
+
if (
|
|
3857
|
+
typeof optionsArg !== 'object'
|
|
3858
|
+
|| optionsArg === null
|
|
3859
|
+
|| plugins.nodeUtil.types.isProxy(optionsArg)
|
|
3860
|
+
|| !isPlainObject(optionsArg)
|
|
3861
|
+
) {
|
|
3862
|
+
throw new SmartdataPersistenceError(
|
|
3863
|
+
'invalid_argument',
|
|
3864
|
+
'Grouped totals options must be an inert plain object.',
|
|
3865
|
+
);
|
|
3866
|
+
}
|
|
3867
|
+
const allowedKeys = new Set([
|
|
3868
|
+
'groupBy',
|
|
3869
|
+
'sums',
|
|
3870
|
+
'filter',
|
|
3871
|
+
'limit',
|
|
3872
|
+
'maxTimeMS',
|
|
3873
|
+
'timeoutMS',
|
|
3874
|
+
'signal',
|
|
3875
|
+
'session',
|
|
3876
|
+
]);
|
|
3877
|
+
for (const key of Reflect.ownKeys(optionsArg)) {
|
|
3878
|
+
if (typeof key !== 'string' || !allowedKeys.has(key)) {
|
|
3879
|
+
throw new SmartdataPersistenceError(
|
|
3880
|
+
'invalid_argument',
|
|
3881
|
+
`Grouped totals received unsupported option "${String(key)}".`,
|
|
3882
|
+
);
|
|
3883
|
+
}
|
|
3884
|
+
const descriptor = Object.getOwnPropertyDescriptor(optionsArg, key);
|
|
3885
|
+
if (
|
|
3886
|
+
!descriptor
|
|
3887
|
+
|| !('value' in descriptor)
|
|
3888
|
+
|| descriptor.enumerable !== true
|
|
3889
|
+
) {
|
|
3890
|
+
throw new SmartdataPersistenceError(
|
|
3891
|
+
'invalid_argument',
|
|
3892
|
+
`Grouped totals option "${key}" must be an inert enumerable data property.`,
|
|
3893
|
+
);
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
const groupByOption = optionsArg.groupBy;
|
|
3897
|
+
const sumsOption = optionsArg.sums;
|
|
3898
|
+
const filterOption = optionsArg.filter;
|
|
3899
|
+
const limitOption = optionsArg.limit;
|
|
3900
|
+
const maxTimeMSOption = optionsArg.maxTimeMS;
|
|
3901
|
+
const timeoutMSOption = optionsArg.timeoutMS;
|
|
3902
|
+
const signalOption = optionsArg.signal;
|
|
3903
|
+
if (signalOption !== undefined && !(signalOption instanceof AbortSignal)) {
|
|
3904
|
+
throw new SmartdataPersistenceError('invalid_argument', 'Grouped totals signal must be an AbortSignal.');
|
|
3905
|
+
}
|
|
3906
|
+
signalOption?.throwIfAborted();
|
|
3907
|
+
const sessionOption = optionsArg.session;
|
|
3908
|
+
const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
|
|
3909
|
+
const serializedRoots = (this as any).getSerializedPersistedRoots() as Set<string>;
|
|
3910
|
+
const requireDeclaredGroupField = (
|
|
3911
|
+
fieldArg: unknown,
|
|
3912
|
+
labelArg: string,
|
|
3913
|
+
): string => {
|
|
3914
|
+
if (typeof fieldArg !== 'string') {
|
|
3915
|
+
throw new SmartdataPersistenceError(
|
|
3916
|
+
'invalid_argument',
|
|
3917
|
+
`${labelArg} must name a declared top-level field.`,
|
|
3918
|
+
);
|
|
3919
|
+
}
|
|
3920
|
+
const root = requireDeclaredPath(fieldArg, declaredRoots, labelArg);
|
|
3921
|
+
if (root !== fieldArg) {
|
|
3922
|
+
throw new SmartdataPersistenceError(
|
|
3923
|
+
'invalid_argument',
|
|
3924
|
+
`${labelArg} must name a declared top-level field, not a nested path.`,
|
|
3925
|
+
);
|
|
3926
|
+
}
|
|
3927
|
+
if (serializedRoots.has(root)) {
|
|
3928
|
+
throw new SmartdataPersistenceError(
|
|
3929
|
+
'unsupported_operation',
|
|
3930
|
+
`${labelArg} cannot target serialized field "${root}".`,
|
|
3931
|
+
);
|
|
3932
|
+
}
|
|
3933
|
+
return root;
|
|
3934
|
+
};
|
|
3935
|
+
if (
|
|
3936
|
+
!Array.isArray(groupByOption)
|
|
3937
|
+
|| groupByOption.length < 1
|
|
3938
|
+
|| groupByOption.length > 2
|
|
3939
|
+
) {
|
|
3940
|
+
throw new SmartdataPersistenceError(
|
|
3941
|
+
'invalid_argument',
|
|
3942
|
+
'Grouped totals require one or two groupBy fields.',
|
|
3943
|
+
);
|
|
3944
|
+
}
|
|
3945
|
+
const groupByFields = [...groupByOption].map((fieldArg) =>
|
|
3946
|
+
requireDeclaredGroupField(fieldArg, 'Grouped totals groupBy field'),
|
|
3947
|
+
);
|
|
3948
|
+
if (
|
|
3949
|
+
groupByFields.length < 1
|
|
3950
|
+
|| groupByFields.length > 2
|
|
3951
|
+
|| new Set(groupByFields).size !== groupByFields.length
|
|
3952
|
+
) {
|
|
3953
|
+
throw new SmartdataPersistenceError(
|
|
3954
|
+
'invalid_argument',
|
|
3955
|
+
'Grouped totals groupBy fields must be one or two distinct fields.',
|
|
3956
|
+
);
|
|
3957
|
+
}
|
|
3958
|
+
if (sumsOption !== undefined && !Array.isArray(sumsOption)) {
|
|
3959
|
+
throw new SmartdataPersistenceError(
|
|
3960
|
+
'invalid_argument',
|
|
3961
|
+
'Grouped totals sums must be an array of declared numeric fields.',
|
|
3962
|
+
);
|
|
3963
|
+
}
|
|
3964
|
+
const sumFields = [...(sumsOption ?? [])].map((fieldArg) =>
|
|
3965
|
+
requireDeclaredGroupField(fieldArg, 'Grouped totals sum field'),
|
|
3966
|
+
);
|
|
3967
|
+
if (new Set(sumFields).size !== sumFields.length) {
|
|
3968
|
+
throw new SmartdataPersistenceError(
|
|
3969
|
+
'invalid_argument',
|
|
3970
|
+
'Grouped totals sum fields must be distinct.',
|
|
3971
|
+
);
|
|
3972
|
+
}
|
|
3973
|
+
const limit = requireBoundedInteger(
|
|
3974
|
+
limitOption,
|
|
3975
|
+
'Grouped totals limit',
|
|
3976
|
+
10_000,
|
|
3977
|
+
);
|
|
3978
|
+
if (limit === undefined) {
|
|
3979
|
+
throw new SmartdataPersistenceError(
|
|
3980
|
+
'invalid_argument',
|
|
3981
|
+
'Grouped totals require an explicit limit.',
|
|
3982
|
+
);
|
|
3983
|
+
}
|
|
3984
|
+
const maxTimeMS = requireBoundedInteger(
|
|
3985
|
+
maxTimeMSOption,
|
|
3986
|
+
'Grouped totals maxTimeMS',
|
|
3987
|
+
120_000,
|
|
3988
|
+
);
|
|
3989
|
+
const timeoutMS = requireBoundedInteger(timeoutMSOption, 'Grouped totals timeoutMS', 120_000);
|
|
3990
|
+
const groupStage: Record<string, unknown> = {
|
|
3991
|
+
_id: groupByFields.length === 1
|
|
3992
|
+
? '$' + groupByFields[0]
|
|
3993
|
+
: Object.fromEntries(
|
|
3994
|
+
groupByFields.map((fieldArg) => [fieldArg, '$' + fieldArg]),
|
|
3995
|
+
),
|
|
3996
|
+
__groupedTotalsCount: { $sum: 1 },
|
|
3997
|
+
};
|
|
3998
|
+
for (const [index, sumField] of sumFields.entries()) {
|
|
3999
|
+
groupStage['__groupedTotalsSum' + index] = { $sum: '$' + sumField };
|
|
4000
|
+
}
|
|
4001
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
4002
|
+
const rows = await collection.aggregateGroupedTotals(
|
|
4003
|
+
filterOption !== undefined
|
|
4004
|
+
? convertFilterForMongoDb(filterOption)
|
|
4005
|
+
: undefined,
|
|
4006
|
+
groupStage,
|
|
4007
|
+
limit + 1,
|
|
4008
|
+
{
|
|
4009
|
+
...(sessionOption ? { session: sessionOption } : {}),
|
|
4010
|
+
...(maxTimeMS !== undefined ? { maxTimeMS } : {}),
|
|
4011
|
+
...(timeoutMS !== undefined ? { timeoutMS } : {}),
|
|
4012
|
+
...(signalOption !== undefined ? { signal: signalOption } : {}),
|
|
4013
|
+
},
|
|
4014
|
+
);
|
|
4015
|
+
const hasMore = rows.length > limit;
|
|
4016
|
+
const returnedRows = rows.slice(0, limit);
|
|
4017
|
+
const requireFiniteNumber = (
|
|
4018
|
+
valueArg: unknown,
|
|
4019
|
+
labelArg: string,
|
|
4020
|
+
): number => {
|
|
4021
|
+
if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
|
|
4022
|
+
throw new SmartdataPersistenceError(
|
|
4023
|
+
'unsupported_operation',
|
|
4024
|
+
`${labelArg} produced a non-numeric total; store safe-integer values in summed fields.`,
|
|
4025
|
+
);
|
|
4026
|
+
}
|
|
4027
|
+
return valueArg;
|
|
4028
|
+
};
|
|
4029
|
+
const mappedRows = returnedRows.map((rowArg) => {
|
|
4030
|
+
const group: Record<string, unknown> = {};
|
|
4031
|
+
if (groupByFields.length === 1) {
|
|
4032
|
+
group[groupByFields[0]] = rowArg._id ?? null;
|
|
4033
|
+
} else {
|
|
4034
|
+
const compoundKey = (rowArg._id ?? {}) as Record<string, unknown>;
|
|
4035
|
+
for (const fieldArg of groupByFields) {
|
|
4036
|
+
// Missing and explicit-null values merge into one group that is
|
|
4037
|
+
// reported as null in both the single-field and compound shapes.
|
|
4038
|
+
group[fieldArg] = compoundKey[fieldArg] ?? null;
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
const sums: Record<string, number> = {};
|
|
4042
|
+
for (const [index, sumField] of sumFields.entries()) {
|
|
4043
|
+
sums[sumField] = requireFiniteNumber(
|
|
4044
|
+
rowArg['__groupedTotalsSum' + index],
|
|
4045
|
+
`Grouped totals sum field "${sumField}"`,
|
|
4046
|
+
);
|
|
4047
|
+
}
|
|
4048
|
+
return {
|
|
4049
|
+
group,
|
|
4050
|
+
count: requireFiniteNumber(
|
|
4051
|
+
rowArg.__groupedTotalsCount,
|
|
4052
|
+
'Grouped totals count',
|
|
4053
|
+
),
|
|
4054
|
+
sums,
|
|
4055
|
+
} as ISmartdataGroupedTotalsRow<T>;
|
|
4056
|
+
});
|
|
4057
|
+
return {
|
|
4058
|
+
rows: mappedRows,
|
|
4059
|
+
hasMore,
|
|
4060
|
+
};
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
/**
|
|
4064
|
+
* gets all instances as array
|
|
4065
|
+
* @param this
|
|
4066
|
+
* @param filterArg - Type-safe MongoDB filter with nested object support and operators
|
|
4067
|
+
* @returns
|
|
4068
|
+
*/
|
|
4069
|
+
public static async getInstances<T>(
|
|
4070
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4071
|
+
filterArg: MongoFilter<T>,
|
|
4072
|
+
opts?: { session?: TSmartdataOrdinarySession }
|
|
4073
|
+
): Promise<T[]> {
|
|
4074
|
+
// Pass session through to findAll for transactional queries
|
|
4075
|
+
const foundDocs = await (this as any).collection.findAll(
|
|
4076
|
+
convertFilterForMongoDb(filterArg),
|
|
4077
|
+
{ session: opts?.session },
|
|
4078
|
+
);
|
|
4079
|
+
const returnArray: T[] = [];
|
|
4080
|
+
for (const foundDoc of foundDocs) {
|
|
4081
|
+
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
|
|
4082
|
+
returnArray.push(newInstance);
|
|
4083
|
+
}
|
|
4084
|
+
return returnArray;
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
/**
|
|
4088
|
+
* Cursor-paged query with stable seek pagination — the org-standard way to
|
|
4089
|
+
* list unbounded collections. Orders by a comparable (ideally indexed)
|
|
4090
|
+
* sort field with a unique tiebreaker, returns one page plus the cursor
|
|
4091
|
+
* for the next, and never materializes the full result set.
|
|
4092
|
+
* Owned sessions require a prepared model and remain leased through optional
|
|
4093
|
+
* counting, model hydration and cursor cleanup.
|
|
4094
|
+
*/
|
|
4095
|
+
public static async getPagedInstances<T>(
|
|
4096
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4097
|
+
optionsArg: {
|
|
4098
|
+
filter?: MongoFilter<T>;
|
|
4099
|
+
/** Comparable, ideally indexed field that orders the pages (e.g. a numeric timestamp). */
|
|
4100
|
+
sortField: string;
|
|
4101
|
+
sortDirection?: 'asc' | 'desc';
|
|
4102
|
+
/** Unique tiebreaker field present on every document. Defaults to 'id'. */
|
|
4103
|
+
uniqueField?: string;
|
|
4104
|
+
/** Page size, default 100, capped at 1000. */
|
|
4105
|
+
limit?: number;
|
|
4106
|
+
/** Seek position returned as nextCursor by the previous page. */
|
|
4107
|
+
cursor?: { sortValue: number | string; uniqueValue: string };
|
|
4108
|
+
/** Also count all filter matches (extra query). */
|
|
4109
|
+
withTotal?: boolean;
|
|
4110
|
+
session?: TSmartdataOrdinarySession;
|
|
4111
|
+
},
|
|
4112
|
+
): Promise<{
|
|
4113
|
+
documents: T[];
|
|
4114
|
+
nextCursor?: { sortValue: number | string; uniqueValue: string };
|
|
4115
|
+
total?: number;
|
|
4116
|
+
}> {
|
|
4117
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
4118
|
+
optionsArg = { ...optionsArg, cursor: optionsArg.cursor && { ...optionsArg.cursor } };
|
|
4119
|
+
const sortDirection = optionsArg.sortDirection === 'asc' ? 1 : -1;
|
|
4120
|
+
const uniqueField = optionsArg.uniqueField || 'id';
|
|
4121
|
+
const requestedLimit = Number.isSafeInteger(optionsArg.limit) && optionsArg.limit! > 0
|
|
4122
|
+
? optionsArg.limit!
|
|
4123
|
+
: 100;
|
|
4124
|
+
const limit = Math.min(requestedLimit, 1000);
|
|
4125
|
+
const baseSelector = convertFilterForMongoDb(optionsArg.filter || {});
|
|
4126
|
+
|
|
4127
|
+
let selector: any = baseSelector;
|
|
4128
|
+
if (optionsArg.cursor) {
|
|
4129
|
+
const seekOperator = sortDirection === 1 ? '$gt' : '$lt';
|
|
4130
|
+
const seekSelector = {
|
|
4131
|
+
$or: [
|
|
4132
|
+
{ [optionsArg.sortField]: { [seekOperator]: optionsArg.cursor.sortValue } },
|
|
4133
|
+
{
|
|
4134
|
+
[optionsArg.sortField]: optionsArg.cursor.sortValue,
|
|
4135
|
+
[uniqueField]: { [seekOperator]: optionsArg.cursor.uniqueValue },
|
|
4136
|
+
},
|
|
4137
|
+
],
|
|
4138
|
+
};
|
|
4139
|
+
selector = Object.keys(baseSelector).length > 0
|
|
4140
|
+
? { $and: [baseSelector, seekSelector] }
|
|
4141
|
+
: seekSelector;
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
return runWithOrdinarySmartdataSession(optionsArg.session, collection.smartdataDb, {
|
|
4145
|
+
ordinaryWrite: false,
|
|
4146
|
+
prepared: collection.isInitializedForCurrentDatabase(),
|
|
4147
|
+
}, async (rawSessionArg) => {
|
|
4148
|
+
await collection.init();
|
|
4149
|
+
const rawCursor = collection.mongoDbCollection
|
|
4150
|
+
.find(selector, { session: rawSessionArg })
|
|
4151
|
+
.sort({ [optionsArg.sortField]: sortDirection, [uniqueField]: sortDirection })
|
|
4152
|
+
.limit(limit + 1);
|
|
4153
|
+
return runCursorOperation(rawCursor, async (cursorArg) => {
|
|
4154
|
+
const rawDocuments = await cursorArg.toArray();
|
|
4155
|
+
const hasMore = rawDocuments.length > limit;
|
|
4156
|
+
const pageRows = rawDocuments.slice(0, limit);
|
|
4157
|
+
const documents = pageRows.map(
|
|
4158
|
+
(rawDocument: any) => (this as any).createInstanceFromMongoDbNativeDoc(rawDocument) as T,
|
|
4159
|
+
);
|
|
4160
|
+
const lastRow: any = pageRows[pageRows.length - 1];
|
|
4161
|
+
const total = optionsArg.withTotal
|
|
4162
|
+
? await collection.mongoDbCollection.countDocuments(baseSelector, {
|
|
4163
|
+
session: rawSessionArg,
|
|
4164
|
+
})
|
|
4165
|
+
: undefined;
|
|
4166
|
+
return {
|
|
4167
|
+
documents,
|
|
4168
|
+
...(hasMore && lastRow
|
|
4169
|
+
? {
|
|
4170
|
+
nextCursor: {
|
|
4171
|
+
sortValue: lastRow[optionsArg.sortField],
|
|
4172
|
+
uniqueValue: lastRow[uniqueField],
|
|
4173
|
+
},
|
|
4174
|
+
}
|
|
4175
|
+
: {}),
|
|
4176
|
+
...(total !== undefined ? { total } : {}),
|
|
4177
|
+
};
|
|
4178
|
+
});
|
|
4179
|
+
});
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
/**
|
|
4183
|
+
* gets the first matching instance
|
|
4184
|
+
* @param this
|
|
4185
|
+
* @param filterArg
|
|
4186
|
+
* @returns
|
|
4187
|
+
*/
|
|
4188
|
+
public static async getInstance<T>(
|
|
4189
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4190
|
+
filterArg: MongoFilter<T>,
|
|
4191
|
+
opts?: ISmartdataFindOneOptions<T>,
|
|
4192
|
+
): Promise<T> {
|
|
4193
|
+
(this as any).assertHydratedProjection(opts?.projection);
|
|
4194
|
+
const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
|
|
4195
|
+
// Retrieve one document, with optional session for transactions
|
|
4196
|
+
const foundDoc = await (this as any).collection.findOne(
|
|
4197
|
+
convertFilterForMongoDb(filterArg),
|
|
4198
|
+
{
|
|
4199
|
+
projection: normalizeProjection(
|
|
4200
|
+
opts?.projection,
|
|
4201
|
+
declaredRoots,
|
|
4202
|
+
(this as any).getDeclaredQueryPaths(),
|
|
4203
|
+
),
|
|
4204
|
+
maxTimeMS: requireBoundedInteger(
|
|
4205
|
+
opts?.maxTimeMS,
|
|
4206
|
+
'maxTimeMS',
|
|
4207
|
+
120_000,
|
|
4208
|
+
),
|
|
4209
|
+
session: opts?.session,
|
|
4210
|
+
},
|
|
4211
|
+
);
|
|
4212
|
+
if (foundDoc) {
|
|
4213
|
+
const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
|
|
4214
|
+
return newInstance;
|
|
4215
|
+
} else {
|
|
4216
|
+
return null as any;
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4220
|
+
public static async exists<T>(
|
|
4221
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4222
|
+
filterArg: MongoFilter<T>,
|
|
4223
|
+
opts?: {
|
|
4224
|
+
maxTimeMS?: number;
|
|
4225
|
+
session?: TSmartdataOrdinarySession;
|
|
4226
|
+
},
|
|
4227
|
+
): Promise<boolean> {
|
|
4228
|
+
const foundDoc = await (this as any).collection.findOne(
|
|
4229
|
+
convertFilterForMongoDb(filterArg),
|
|
4230
|
+
{
|
|
4231
|
+
projection: { _id: 1 },
|
|
4232
|
+
maxTimeMS: requireBoundedInteger(
|
|
4233
|
+
opts?.maxTimeMS,
|
|
4234
|
+
'maxTimeMS',
|
|
4235
|
+
120_000,
|
|
4236
|
+
),
|
|
4237
|
+
session: opts?.session,
|
|
4238
|
+
},
|
|
4239
|
+
);
|
|
4240
|
+
return foundDoc !== null;
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
/**
|
|
4244
|
+
* get a unique id prefixed with the class name
|
|
4245
|
+
*/
|
|
4246
|
+
public static async getNewId<T = any>(
|
|
4247
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4248
|
+
lengthArg: number = 20,
|
|
4249
|
+
) {
|
|
4250
|
+
return `${(this as any).className}:${plugins.smartunique.shortId(lengthArg)}`;
|
|
4251
|
+
}
|
|
4252
|
+
|
|
4253
|
+
/**
|
|
4254
|
+
* Stream results using model-declared projections, sorts and index hints.
|
|
4255
|
+
* Offset pages require an explicit sort and limit. Include a unique final
|
|
4256
|
+
* tiebreaker; concurrent writes can still move rows between offset pages.
|
|
4257
|
+
*/
|
|
4258
|
+
public static async getCursor<T>(
|
|
4259
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4260
|
+
filterArg: MongoFilter<T>,
|
|
4261
|
+
opts?: ISmartdataCursorOptions<T>,
|
|
4262
|
+
): Promise<SmartdataDbCursor<T>> {
|
|
4263
|
+
(this as any).assertHydratedProjection(opts?.projection, opts?.modifier);
|
|
4264
|
+
const { session, modifier } = opts || {};
|
|
4265
|
+
const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
|
|
4266
|
+
const declaredQueryPaths = (this as any).getDeclaredQueryPaths() as Set<string>;
|
|
4267
|
+
const projection = normalizeProjection(opts?.projection, declaredRoots, declaredQueryPaths);
|
|
4268
|
+
const sort = normalizeSort(opts?.sort, declaredRoots, declaredQueryPaths);
|
|
4269
|
+
const hint = normalizeQueryHint(opts?.hint, this);
|
|
4270
|
+
const batchSize = requireBoundedInteger(
|
|
4271
|
+
opts?.batchSize,
|
|
4272
|
+
'batchSize',
|
|
4273
|
+
10_000,
|
|
4274
|
+
);
|
|
4275
|
+
const limit = requireBoundedInteger(opts?.limit, 'limit', 10_000);
|
|
4276
|
+
const maxTimeMS = requireBoundedInteger(
|
|
4277
|
+
opts?.maxTimeMS,
|
|
4278
|
+
'maxTimeMS',
|
|
4279
|
+
120_000,
|
|
4280
|
+
);
|
|
4281
|
+
const skip = opts?.skip;
|
|
4282
|
+
if (skip !== undefined && (
|
|
4283
|
+
!Number.isSafeInteger(skip) || skip < 0 || skip > 2_147_483_647 ||
|
|
4284
|
+
!sort || limit === undefined
|
|
4285
|
+
)) {
|
|
4286
|
+
throw new SmartdataPersistenceError(
|
|
4287
|
+
'invalid_argument',
|
|
4288
|
+
'Cursor skip must be an integer from 0 through 2147483647 and requires sort and limit.',
|
|
4289
|
+
);
|
|
4290
|
+
}
|
|
4291
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
4292
|
+
await collection.init();
|
|
4293
|
+
let rawCursor: plugins.mongodb.FindCursor<any> =
|
|
4294
|
+
collection.mongoDbCollection.find(convertFilterForMongoDb(filterArg), {
|
|
4295
|
+
projection,
|
|
4296
|
+
session,
|
|
4297
|
+
hint,
|
|
4298
|
+
skip,
|
|
4299
|
+
});
|
|
4300
|
+
if (sort) {
|
|
4301
|
+
rawCursor = rawCursor.sort(sort);
|
|
4302
|
+
}
|
|
4303
|
+
if (batchSize) {
|
|
4304
|
+
rawCursor = rawCursor.batchSize(batchSize);
|
|
4305
|
+
}
|
|
4306
|
+
if (limit) {
|
|
4307
|
+
rawCursor = rawCursor.limit(limit);
|
|
4308
|
+
}
|
|
4309
|
+
if (maxTimeMS) {
|
|
4310
|
+
rawCursor = rawCursor.maxTimeMS(maxTimeMS);
|
|
4311
|
+
}
|
|
4312
|
+
if (modifier) {
|
|
4313
|
+
const originalCursor = rawCursor;
|
|
4314
|
+
let modifiedCursor: plugins.mongodb.FindCursor<any>;
|
|
4315
|
+
try {
|
|
4316
|
+
modifiedCursor = modifier(originalCursor);
|
|
4317
|
+
} catch (modifierError) {
|
|
4318
|
+
try {
|
|
4319
|
+
await originalCursor.close();
|
|
4320
|
+
} catch {
|
|
4321
|
+
// Preserve the modifier error when best-effort cleanup also fails.
|
|
4322
|
+
}
|
|
4323
|
+
throw modifierError;
|
|
4324
|
+
}
|
|
4325
|
+
if (modifiedCursor !== originalCursor) {
|
|
4326
|
+
try {
|
|
4327
|
+
await originalCursor.close();
|
|
4328
|
+
} catch (originalCursorCloseError) {
|
|
4329
|
+
try {
|
|
4330
|
+
await modifiedCursor.close();
|
|
4331
|
+
} catch {
|
|
4332
|
+
// Preserve the original cursor's cleanup error.
|
|
4333
|
+
}
|
|
4334
|
+
throw originalCursorCloseError;
|
|
4335
|
+
}
|
|
4336
|
+
}
|
|
4337
|
+
rawCursor = modifiedCursor;
|
|
4338
|
+
}
|
|
4339
|
+
return new SmartdataDbCursor<T>(rawCursor, this as any as typeof SmartDataDbDoc);
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
/**
|
|
4343
|
+
* Read a bounded offset page while retaining an owned session through cursor
|
|
4344
|
+
* cleanup. Initialize the model before calling; this operation never creates
|
|
4345
|
+
* collections or indexes. Count separately with the same filter and hint.
|
|
4346
|
+
*/
|
|
4347
|
+
public static async getOffsetPage<T>(
|
|
4348
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4349
|
+
filterArg: MongoFilter<T>,
|
|
4350
|
+
opts: ISmartdataOffsetPageOptions<T>,
|
|
4351
|
+
): Promise<T[]> {
|
|
4352
|
+
const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
|
|
4353
|
+
(this as any).assertHydratedProjection(opts?.projection);
|
|
4354
|
+
const declaredQueryPaths = (this as any).getDeclaredQueryPaths() as Set<string>;
|
|
4355
|
+
const projection = normalizeProjection(opts?.projection, declaredRoots, declaredQueryPaths);
|
|
4356
|
+
const sort = normalizeSort(opts?.sort, declaredRoots, declaredQueryPaths);
|
|
4357
|
+
const hint = normalizeQueryHint(opts?.hint, this);
|
|
4358
|
+
const limit = requireBoundedInteger(opts?.limit, 'limit', 10_000);
|
|
4359
|
+
const skip = opts?.skip;
|
|
4360
|
+
if (!sort || limit === undefined || !Number.isSafeInteger(skip) || skip < 0 || skip > 2_147_483_647) {
|
|
4361
|
+
throw new SmartdataPersistenceError(
|
|
4362
|
+
'invalid_argument',
|
|
4363
|
+
'Offset pages require sort, limit and an integer skip from 0 through 2147483647.',
|
|
4364
|
+
);
|
|
4365
|
+
}
|
|
4366
|
+
const timeoutMS = requireBoundedInteger(opts?.timeoutMS === undefined ? 5_000 : opts.timeoutMS, 'timeoutMS', 120_000);
|
|
4367
|
+
const maxTimeMS = requireBoundedInteger(opts?.maxTimeMS, 'maxTimeMS', 120_000);
|
|
4368
|
+
const filter = convertFilterForMongoDb(filterArg);
|
|
4369
|
+
opts.signal?.throwIfAborted();
|
|
4370
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
4371
|
+
if (!collection.isInitializedForCurrentDatabase()) {
|
|
4372
|
+
throw new SmartdataPersistenceError(
|
|
4373
|
+
'unsupported_operation',
|
|
4374
|
+
'Initialize the model before reading an offset page.',
|
|
4375
|
+
);
|
|
4376
|
+
}
|
|
4377
|
+
return runWithOrdinarySmartdataSession(opts.session, collection.smartdataDb, {
|
|
4378
|
+
ordinaryWrite: false,
|
|
4379
|
+
prepared: true,
|
|
4380
|
+
}, async (rawSessionArg) => {
|
|
4381
|
+
opts.signal?.throwIfAborted();
|
|
4382
|
+
const rawCursor = collection.mongoDbCollection.find(filter, {
|
|
4383
|
+
projection, sort, hint, skip, limit, maxTimeMS, timeoutMS,
|
|
4384
|
+
timeoutMode: 'cursorLifetime', session: rawSessionArg, signal: opts.signal,
|
|
4385
|
+
});
|
|
4386
|
+
return runCursorOperation(rawCursor, async (cursorArg) => {
|
|
4387
|
+
const documents = await cursorArg.toArray();
|
|
4388
|
+
return documents.map((documentArg) =>
|
|
4389
|
+
(this as any).createInstanceFromMongoDbNativeDoc(documentArg) as T,
|
|
4390
|
+
);
|
|
4391
|
+
}, opts.signal);
|
|
4392
|
+
});
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
/**
|
|
4396
|
+
* watch the collection
|
|
4397
|
+
* @param this
|
|
4398
|
+
* @param filterArg
|
|
4399
|
+
* @param forEachFunction
|
|
4400
|
+
*/
|
|
4401
|
+
/**
|
|
4402
|
+
* Watch the collection for changes, with optional buffering and change stream options.
|
|
4403
|
+
* @param filterArg MongoDB filter to select which changes to observe
|
|
4404
|
+
* @param opts optional ChangeStreamOptions plus bufferTimeMs
|
|
4405
|
+
*/
|
|
4406
|
+
public static async watch<T>(
|
|
4407
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4408
|
+
filterArg: MongoFilter<T>,
|
|
4409
|
+
opts?: plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number },
|
|
4410
|
+
): Promise<SmartdataDbWatcher<T>> {
|
|
4411
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
4412
|
+
const watcher: SmartdataDbWatcher<T> = await collection.watch(
|
|
4413
|
+
convertFilterForMongoDb(filterArg),
|
|
4414
|
+
opts || {},
|
|
4415
|
+
this as any,
|
|
4416
|
+
);
|
|
4417
|
+
return watcher;
|
|
4418
|
+
}
|
|
4419
|
+
|
|
4420
|
+
/**
|
|
4421
|
+
* run a function for all instances
|
|
4422
|
+
* @returns
|
|
4423
|
+
*/
|
|
4424
|
+
public static async forEach<T>(
|
|
4425
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4426
|
+
filterArg: MongoFilter<T>,
|
|
4427
|
+
forEachFunction: (itemArg: T) => Promise<any>,
|
|
4428
|
+
) {
|
|
4429
|
+
const cursor: SmartdataDbCursor<T> = await (this as any).getCursor(filterArg);
|
|
4430
|
+
await cursor.forEach(forEachFunction);
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
/**
|
|
4434
|
+
* returns a count of the documents in the collection
|
|
4435
|
+
*/
|
|
4436
|
+
public static async getCount<T>(
|
|
4437
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4438
|
+
filterArg: MongoFilter<T> = {} as any,
|
|
4439
|
+
opts?: ISmartdataCountOptions,
|
|
4440
|
+
) {
|
|
4441
|
+
const hint = normalizeQueryHint(opts?.hint, this);
|
|
4442
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
4443
|
+
return await collection.getCount(convertFilterForMongoDb(filterArg), {
|
|
4444
|
+
hint,
|
|
4445
|
+
limit: requireBoundedInteger(opts?.limit, 'limit', 10_000),
|
|
4446
|
+
maxTimeMS: requireBoundedInteger(
|
|
4447
|
+
opts?.maxTimeMS,
|
|
4448
|
+
'maxTimeMS',
|
|
4449
|
+
120_000,
|
|
4450
|
+
),
|
|
4451
|
+
timeoutMS: requireBoundedInteger(opts?.timeoutMS, 'timeoutMS', 120_000),
|
|
4452
|
+
signal: opts?.signal,
|
|
4453
|
+
session: opts?.session,
|
|
4454
|
+
});
|
|
4455
|
+
}
|
|
4456
|
+
|
|
4457
|
+
/**
|
|
4458
|
+
* Runs an integrity check on this collection.
|
|
4459
|
+
* Returns a summary with estimated vs actual counts and any duplicate unique fields.
|
|
4460
|
+
*/
|
|
4461
|
+
public static async checkCollectionIntegrity<T>(
|
|
4462
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4463
|
+
) {
|
|
4464
|
+
const collection: SmartdataCollection<T> = (this as any).collection;
|
|
4465
|
+
return await collection.checkCollectionIntegrity();
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4468
|
+
/**
|
|
4469
|
+
* Create a MongoDB filter from a Lucene query string
|
|
4470
|
+
* @param luceneQuery Lucene query string
|
|
4471
|
+
* @returns MongoDB query object
|
|
4472
|
+
*/
|
|
4473
|
+
public static createSearchFilter<T>(
|
|
4474
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4475
|
+
luceneQuery: string,
|
|
4476
|
+
): any {
|
|
4477
|
+
const searchableFields = (this as any).getSearchableFields();
|
|
4478
|
+
if (searchableFields.length === 0) {
|
|
4479
|
+
throw new Error(`No searchable fields defined for class ${this.name}`);
|
|
4480
|
+
}
|
|
4481
|
+
const adapter = new SmartdataLuceneAdapter(searchableFields);
|
|
4482
|
+
return adapter.convert(luceneQuery);
|
|
4483
|
+
}
|
|
4484
|
+
/**
|
|
4485
|
+
* List all searchable fields defined on this class
|
|
4486
|
+
*/
|
|
4487
|
+
public static getSearchableFields(): string[] {
|
|
4488
|
+
const ctor = this as any;
|
|
4489
|
+
return Array.isArray(ctor.searchableFields) ? ctor.searchableFields : [];
|
|
4490
|
+
}
|
|
4491
|
+
/**
|
|
4492
|
+
* Execute a query with optional hard filter and post-fetch validation
|
|
4493
|
+
*/
|
|
4494
|
+
private static async execQuery<T>(
|
|
4495
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4496
|
+
baseFilter: Record<string, any>,
|
|
4497
|
+
opts?: SearchOptions<T>
|
|
4498
|
+
): Promise<T[]> {
|
|
4499
|
+
let mongoFilter = baseFilter || {};
|
|
4500
|
+
if (opts?.filter) {
|
|
4501
|
+
mongoFilter = { $and: [mongoFilter, opts.filter] };
|
|
4502
|
+
}
|
|
4503
|
+
// Fetch with optional session for transactions
|
|
4504
|
+
// Fetch within optional session
|
|
4505
|
+
let docs: T[] = await (this as any).getInstances(mongoFilter, { session: opts?.session });
|
|
4506
|
+
if (opts?.validate) {
|
|
4507
|
+
const out: T[] = [];
|
|
4508
|
+
for (const d of docs) {
|
|
4509
|
+
if (await opts.validate(d)) out.push(d);
|
|
4510
|
+
}
|
|
4511
|
+
docs = out;
|
|
4512
|
+
}
|
|
4513
|
+
return docs;
|
|
4514
|
+
}
|
|
4515
|
+
|
|
4516
|
+
/**
|
|
4517
|
+
* Search documents by text or field:value syntax, with safe regex fallback
|
|
4518
|
+
* Supports additional filtering and post-fetch validation via opts
|
|
4519
|
+
* @param query A search term or field:value expression
|
|
4520
|
+
* @param opts Optional filter and validate hooks
|
|
4521
|
+
* @returns Array of matching documents
|
|
4522
|
+
*/
|
|
4523
|
+
public static async search<T>(
|
|
4524
|
+
this: plugins.tsclass.typeFest.Class<T>,
|
|
4525
|
+
query: string,
|
|
4526
|
+
opts?: SearchOptions<T>,
|
|
4527
|
+
): Promise<T[]> {
|
|
4528
|
+
const searchableFields = (this as any).getSearchableFields();
|
|
4529
|
+
if (searchableFields.length === 0) {
|
|
4530
|
+
throw new Error(`No searchable fields defined for class ${this.name}`);
|
|
4531
|
+
}
|
|
4532
|
+
// empty query -> return all
|
|
4533
|
+
const q = query.trim();
|
|
4534
|
+
if (!q) {
|
|
4535
|
+
// empty query: fetch all, apply opts
|
|
4536
|
+
return await (this as any).execQuery({}, opts);
|
|
4537
|
+
}
|
|
4538
|
+
// simple exact field:value (no spaces, no wildcards, no quotes)
|
|
4539
|
+
// simple exact field:value (no spaces, wildcards, quotes)
|
|
4540
|
+
const simpleExact = q.match(/^(\w+):([^"'\*\?\s]+)$/);
|
|
4541
|
+
if (simpleExact) {
|
|
4542
|
+
const field = simpleExact[1];
|
|
4543
|
+
const value = simpleExact[2];
|
|
4544
|
+
if (!searchableFields.includes(field)) {
|
|
4545
|
+
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
|
4546
|
+
}
|
|
4547
|
+
// simple field:value search
|
|
4548
|
+
return await (this as any).execQuery({ [field]: value }, opts);
|
|
4549
|
+
}
|
|
4550
|
+
// quoted phrase across all searchable fields: exact match of phrase
|
|
4551
|
+
const quoted = q.match(/^"(.+)"$|^'(.+)'$/);
|
|
4552
|
+
if (quoted) {
|
|
4553
|
+
const phrase = quoted[1] || quoted[2] || '';
|
|
4554
|
+
const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
|
|
4555
|
+
const pattern = parts.join('\\s+');
|
|
4556
|
+
const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
|
|
4557
|
+
return await (this as any).execQuery({ $or: orConds }, opts);
|
|
4558
|
+
}
|
|
4559
|
+
// wildcard field:value (supports * and ?) -> direct regex on that field
|
|
4560
|
+
const wildcardField = q.match(/^(\w+):(.+[*?].*)$/);
|
|
4561
|
+
if (wildcardField) {
|
|
4562
|
+
const field = wildcardField[1];
|
|
4563
|
+
// Support quoted wildcard patterns: strip surrounding quotes
|
|
4564
|
+
let pattern = wildcardField[2];
|
|
4565
|
+
if ((pattern.startsWith('"') && pattern.endsWith('"')) ||
|
|
4566
|
+
(pattern.startsWith("'") && pattern.endsWith("'"))) {
|
|
4567
|
+
pattern = pattern.slice(1, -1);
|
|
4568
|
+
}
|
|
4569
|
+
if (!searchableFields.includes(field)) {
|
|
4570
|
+
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
|
4571
|
+
}
|
|
4572
|
+
// escape regex special chars except * and ?, then convert wildcards
|
|
4573
|
+
const escaped = pattern.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
|
4574
|
+
const regexPattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
4575
|
+
return await (this as any).execQuery({ [field]: { $regex: regexPattern, $options: 'i' } }, opts);
|
|
4576
|
+
}
|
|
4577
|
+
// wildcard plain term across all fields (supports * and ?)
|
|
4578
|
+
if (!q.includes(':') && (q.includes('*') || q.includes('?'))) {
|
|
4579
|
+
// build wildcard regex pattern: escape all except * and ? then convert
|
|
4580
|
+
const escaped = q.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
|
4581
|
+
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
4582
|
+
const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
|
|
4583
|
+
return await (this as any).execQuery({ $or: orConds }, opts);
|
|
4584
|
+
}
|
|
4585
|
+
// implicit AND for multiple tokens: free terms, quoted phrases, and field:values
|
|
4586
|
+
{
|
|
4587
|
+
// Split query into tokens, preserving quoted substrings
|
|
4588
|
+
const rawTokens = q.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
|
4589
|
+
// Only apply when more than one token and no boolean operators or grouping
|
|
4590
|
+
if (
|
|
4591
|
+
rawTokens.length > 1 &&
|
|
4592
|
+
!/(\bAND\b|\bOR\b|\bNOT\b|\(|\))/i.test(q) &&
|
|
4593
|
+
!/\[|\]/.test(q)
|
|
4594
|
+
) {
|
|
4595
|
+
const andConds: any[] = [];
|
|
4596
|
+
for (let token of rawTokens) {
|
|
4597
|
+
// field:value token
|
|
4598
|
+
const fv = token.match(/^(\w+):(.+)$/);
|
|
4599
|
+
if (fv) {
|
|
4600
|
+
const field = fv[1];
|
|
4601
|
+
let value = fv[2];
|
|
4602
|
+
if (!searchableFields.includes(field)) {
|
|
4603
|
+
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
|
4604
|
+
}
|
|
4605
|
+
// Strip surrounding quotes if present
|
|
4606
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
4607
|
+
value = value.slice(1, -1);
|
|
4608
|
+
}
|
|
4609
|
+
// Wildcard search?
|
|
4610
|
+
if (value.includes('*') || value.includes('?')) {
|
|
4611
|
+
const escaped = value.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
|
4612
|
+
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
4613
|
+
andConds.push({ [field]: { $regex: pattern, $options: 'i' } });
|
|
4614
|
+
} else {
|
|
4615
|
+
andConds.push({ [field]: value });
|
|
4616
|
+
}
|
|
4617
|
+
} else if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
|
|
4618
|
+
// Quoted free phrase across all fields
|
|
4619
|
+
const phrase = token.slice(1, -1);
|
|
4620
|
+
const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
|
|
4621
|
+
const pattern = parts.join('\\s+');
|
|
4622
|
+
andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } })) });
|
|
4623
|
+
} else {
|
|
4624
|
+
// Free term across all fields
|
|
4625
|
+
const esc = escapeForRegex(token);
|
|
4626
|
+
andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })) });
|
|
4627
|
+
}
|
|
4628
|
+
}
|
|
4629
|
+
return await (this as any).execQuery({ $and: andConds }, opts);
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
4632
|
+
// detect advanced Lucene syntax: field:value, wildcards, boolean, grouping
|
|
4633
|
+
const luceneSyntax = /(\w+:[^\s]+)|\*|\?|\bAND\b|\bOR\b|\bNOT\b|\(|\)/;
|
|
4634
|
+
if (luceneSyntax.test(q)) {
|
|
4635
|
+
const filter = (this as any).createSearchFilter(q);
|
|
4636
|
+
return await (this as any).execQuery(filter, opts);
|
|
4637
|
+
}
|
|
4638
|
+
// multi-term unquoted -> AND of regex across fields for each term
|
|
4639
|
+
const terms = q.split(/\s+/);
|
|
4640
|
+
if (terms.length > 1) {
|
|
4641
|
+
const andConds = terms.map((term) => {
|
|
4642
|
+
const esc = escapeForRegex(term);
|
|
4643
|
+
const ors = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
|
|
4644
|
+
return { $or: ors };
|
|
4645
|
+
});
|
|
4646
|
+
return await (this as any).execQuery({ $and: andConds }, opts);
|
|
4647
|
+
}
|
|
4648
|
+
// single term -> regex across all searchable fields
|
|
4649
|
+
const esc = escapeForRegex(q);
|
|
4650
|
+
const orConds = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
|
|
4651
|
+
return await (this as any).execQuery({ $or: orConds }, opts);
|
|
4652
|
+
}
|
|
4653
|
+
|
|
4654
|
+
|
|
4655
|
+
// INSTANCE
|
|
4656
|
+
|
|
4657
|
+
// INSTANCE
|
|
4658
|
+
|
|
4659
|
+
/**
|
|
4660
|
+
* how the Doc in memory was created, may prove useful later.
|
|
4661
|
+
*/
|
|
4662
|
+
public creationStatus: TDocCreation = 'new';
|
|
4663
|
+
|
|
4664
|
+
/**
|
|
4665
|
+
* updated from db in any case where doc comes from db
|
|
4666
|
+
*/
|
|
4667
|
+
@globalSvDb()
|
|
4668
|
+
_createdAt: string = new Date().toISOString();
|
|
4669
|
+
|
|
4670
|
+
/**
|
|
4671
|
+
* will be updated everytime the doc is saved
|
|
4672
|
+
*/
|
|
4673
|
+
@globalSvDb()
|
|
4674
|
+
_updatedAt: string = new Date().toISOString();
|
|
4675
|
+
|
|
4676
|
+
/**
|
|
4677
|
+
* an array of saveable properties of ALL doc
|
|
4678
|
+
* Note: Set by decorators on prototype - NOT declared as instance property to avoid shadowing in Deno
|
|
4679
|
+
* Declared with definite assignment assertion to satisfy TypeScript without creating instance property
|
|
4680
|
+
*/
|
|
4681
|
+
declare globalSaveableProperties: string[];
|
|
4682
|
+
|
|
4683
|
+
/**
|
|
4684
|
+
* unique indexes
|
|
4685
|
+
* Note: Set by decorators on prototype - NOT declared as instance property to avoid shadowing in Deno
|
|
4686
|
+
*/
|
|
4687
|
+
declare uniqueIndexes: string[];
|
|
4688
|
+
|
|
4689
|
+
/** Runtime value contracts for fields declared with @unI(). */
|
|
4690
|
+
declare identityValueTypes: Record<string, TSmartdataIdentityValueType>;
|
|
4691
|
+
|
|
4692
|
+
/**
|
|
4693
|
+
* regular indexes with their options
|
|
4694
|
+
* Note: Set by decorators on prototype - NOT declared as instance property to avoid shadowing in Deno
|
|
4695
|
+
*/
|
|
4696
|
+
declare regularIndexes: Array<{field: string, options: IIndexOptions}>;
|
|
4697
|
+
|
|
4698
|
+
/**
|
|
4699
|
+
* named compound indexes declared by @compoundIndex
|
|
4700
|
+
*/
|
|
4701
|
+
declare compoundIndexes: Array<{
|
|
4702
|
+
name: string;
|
|
4703
|
+
key: Record<string, 1 | -1>;
|
|
4704
|
+
options?: Omit<plugins.mongodb.CreateIndexesOptions, 'name'>;
|
|
4705
|
+
}>;
|
|
4706
|
+
|
|
4707
|
+
/**
|
|
4708
|
+
* an array of saveable properties of a specific doc
|
|
4709
|
+
* Note: Set by decorators on prototype - NOT declared as instance property to avoid shadowing in Deno
|
|
4710
|
+
*/
|
|
4711
|
+
declare saveableProperties: string[];
|
|
4712
|
+
|
|
4713
|
+
/**
|
|
4714
|
+
* name
|
|
4715
|
+
*/
|
|
4716
|
+
public name!: string;
|
|
4717
|
+
|
|
4718
|
+
/**
|
|
4719
|
+
* primary id in the database
|
|
4720
|
+
*/
|
|
4721
|
+
public dbDocUniqueId!: string;
|
|
4722
|
+
|
|
4723
|
+
/**
|
|
4724
|
+
* class constructor
|
|
4725
|
+
*/
|
|
4726
|
+
constructor() {
|
|
4727
|
+
if (getOrdinaryPersistencePolicy(this.constructor)?.timestamps === 'none') {
|
|
4728
|
+
delete (this as any)._createdAt;
|
|
4729
|
+
delete (this as any)._updatedAt;
|
|
4730
|
+
}
|
|
4731
|
+
}
|
|
4732
|
+
|
|
4733
|
+
/**
|
|
4734
|
+
* saves this instance (optionally within a transaction)
|
|
4735
|
+
*/
|
|
4736
|
+
public async save(opts?: { session?: plugins.mongodb.ClientSession }) {
|
|
4737
|
+
if (getOrdinaryPersistencePolicy(this.constructor)) {
|
|
4738
|
+
throw new SmartdataPersistenceError('unsupported_operation',
|
|
4739
|
+
'Validated ordinary models use insert() or transactional postimage updates.');
|
|
4740
|
+
}
|
|
4741
|
+
const exactPersistencePolicy = (this.constructor as any)[
|
|
4742
|
+
exactPersistencePolicySymbol
|
|
4743
|
+
] as { unsupportedSave?: () => Promise<never> } | undefined;
|
|
4744
|
+
if (exactPersistencePolicy) {
|
|
4745
|
+
if (exactPersistencePolicy.unsupportedSave) {
|
|
4746
|
+
return exactPersistencePolicy.unsupportedSave();
|
|
4747
|
+
}
|
|
4748
|
+
throw new Error('save() is unavailable for exact-persistence models.');
|
|
4749
|
+
}
|
|
4750
|
+
// allow hook before saving
|
|
4751
|
+
if (typeof (this as any).beforeSave === 'function') {
|
|
4752
|
+
await (this as any).beforeSave();
|
|
4753
|
+
}
|
|
4754
|
+
assertUsableIdentityValues(
|
|
4755
|
+
this as unknown as Record<string, unknown>,
|
|
4756
|
+
this.uniqueIndexes || [],
|
|
4757
|
+
this.identityValueTypes,
|
|
4758
|
+
);
|
|
4759
|
+
// tslint:disable-next-line: no-this-assignment
|
|
4760
|
+
const self: any = this;
|
|
4761
|
+
let dbResult: any;
|
|
4762
|
+
// update timestamp
|
|
4763
|
+
this._updatedAt = new Date().toISOString();
|
|
4764
|
+
// perform insert or update
|
|
4765
|
+
switch (this.creationStatus) {
|
|
4766
|
+
case 'db':
|
|
4767
|
+
dbResult = await this.getCollectionSafe().update(self, { session: opts?.session });
|
|
4768
|
+
break;
|
|
4769
|
+
case 'new':
|
|
4770
|
+
dbResult = await this.getCollectionSafe().insert(self, { session: opts?.session });
|
|
4771
|
+
this.creationStatus = 'db';
|
|
4772
|
+
break;
|
|
4773
|
+
default:
|
|
4774
|
+
logger.log('error', 'neither new nor in db?');
|
|
4775
|
+
}
|
|
4776
|
+
// allow hook after saving
|
|
4777
|
+
if (typeof (this as any).afterSave === 'function') {
|
|
4778
|
+
await (this as any).afterSave();
|
|
4779
|
+
}
|
|
4780
|
+
return dbResult;
|
|
4781
|
+
}
|
|
4782
|
+
|
|
4783
|
+
/**
|
|
4784
|
+
* deletes a document from the database (optionally within a transaction)
|
|
4785
|
+
*/
|
|
4786
|
+
public async delete(opts?: { session?: plugins.mongodb.ClientSession }) {
|
|
4787
|
+
const exactPersistencePolicy = (this.constructor as any)[
|
|
4788
|
+
exactPersistencePolicySymbol
|
|
4789
|
+
] as { forbiddenDelete?: () => Promise<never> } | undefined;
|
|
4790
|
+
if (exactPersistencePolicy) {
|
|
4791
|
+
if (exactPersistencePolicy.forbiddenDelete) {
|
|
4792
|
+
return exactPersistencePolicy.forbiddenDelete();
|
|
4793
|
+
}
|
|
4794
|
+
throw new Error('delete() is forbidden for exact-persistence models.');
|
|
4795
|
+
}
|
|
4796
|
+
// allow hook before deleting
|
|
4797
|
+
if (typeof (this as any).beforeDelete === 'function') {
|
|
4798
|
+
await (this as any).beforeDelete();
|
|
4799
|
+
}
|
|
4800
|
+
// perform deletion
|
|
4801
|
+
const result = await this.getCollectionSafe().delete(this, { session: opts?.session });
|
|
4802
|
+
// allow hook after delete
|
|
4803
|
+
if (typeof (this as any).afterDelete === 'function') {
|
|
4804
|
+
await (this as any).afterDelete();
|
|
4805
|
+
}
|
|
4806
|
+
return result;
|
|
4807
|
+
}
|
|
4808
|
+
|
|
4809
|
+
/**
|
|
4810
|
+
* also store any referenced objects to DB
|
|
4811
|
+
* better for data consistency
|
|
4812
|
+
*/
|
|
4813
|
+
public saveDeep(savedMapArg?: plugins.lik.ObjectMap<SmartDataDbDoc<any, any>>) {
|
|
4814
|
+
if (!savedMapArg) {
|
|
4815
|
+
savedMapArg = new plugins.lik.ObjectMap<SmartDataDbDoc<any, any>>();
|
|
4816
|
+
}
|
|
4817
|
+
savedMapArg.add(this);
|
|
4818
|
+
this.save();
|
|
4819
|
+
for (const propertyKey of Object.keys(this)) {
|
|
4820
|
+
const property: any = this[propertyKey];
|
|
4821
|
+
if (property instanceof SmartDataDbDoc && !savedMapArg.checkForObject(property)) {
|
|
4822
|
+
property.saveDeep(savedMapArg);
|
|
4823
|
+
}
|
|
4824
|
+
}
|
|
4825
|
+
}
|
|
4826
|
+
|
|
4827
|
+
/**
|
|
4828
|
+
* updates an object from db
|
|
4829
|
+
*/
|
|
4830
|
+
public async updateFromDb(): Promise<boolean> {
|
|
4831
|
+
const identifiableObject = await this.createIdentifiableObject();
|
|
4832
|
+
if (Object.keys(identifiableObject).length === 0) {
|
|
4833
|
+
throw new SmartdataPersistenceError(
|
|
4834
|
+
'invalid_configuration',
|
|
4835
|
+
'updateFromDb() requires declared identity fields; without them the lookup would read an arbitrary document.',
|
|
4836
|
+
);
|
|
4837
|
+
}
|
|
4838
|
+
const mongoDbNativeDoc = await this.getCollectionSafe().findOne(identifiableObject);
|
|
4839
|
+
if (!mongoDbNativeDoc) {
|
|
4840
|
+
return false; // Document not found in database
|
|
4841
|
+
}
|
|
4842
|
+
if (getOrdinaryPersistencePolicy(this.constructor)) {
|
|
4843
|
+
const hydrated = (this.constructor as any).createInstanceFromMongoDbNativeDoc(mongoDbNativeDoc);
|
|
4844
|
+
for (const field of [...(this.saveableProperties || []), '_id', '_createdAt', '_updatedAt']) {
|
|
4845
|
+
if (!Object.prototype.hasOwnProperty.call(hydrated, field)) delete this[field];
|
|
4846
|
+
}
|
|
4847
|
+
Object.assign(this, hydrated);
|
|
4848
|
+
return true;
|
|
4849
|
+
}
|
|
4850
|
+
for (const key of Object.keys(mongoDbNativeDoc)) {
|
|
4851
|
+
const rawValue = mongoDbNativeDoc[key];
|
|
4852
|
+
const optionsMap = (this.constructor as any)._svDbOptions || {};
|
|
4853
|
+
const opts = optionsMap[key];
|
|
4854
|
+
this[key] = opts && typeof opts.deserialize === 'function'
|
|
4855
|
+
? opts.deserialize(rawValue)
|
|
4856
|
+
: rawValue;
|
|
4857
|
+
}
|
|
4858
|
+
return true;
|
|
4859
|
+
}
|
|
4860
|
+
|
|
4861
|
+
/**
|
|
4862
|
+
* creates a saveable object so the instance can be persisted as json in the database
|
|
4863
|
+
*
|
|
4864
|
+
* Properties whose value is `undefined` are omitted entirely rather than
|
|
4865
|
+
* written as BSON null, so "absent" stays representable. Explicit `null` is
|
|
4866
|
+
* untouched and remains storable wherever the field type allows it.
|
|
4867
|
+
*/
|
|
4868
|
+
public async createSavableObject(): Promise<TImplements> {
|
|
4869
|
+
if (getOrdinaryPersistencePolicy(this.constructor)?.timestamps === 'none' &&
|
|
4870
|
+
(Object.prototype.hasOwnProperty.call(this, '_createdAt') ||
|
|
4871
|
+
Object.prototype.hasOwnProperty.call(this, '_updatedAt'))) {
|
|
4872
|
+
throw new SmartdataPersistenceError('invalid_document',
|
|
4873
|
+
'This ordinary persistence policy forbids managed timestamps.');
|
|
4874
|
+
}
|
|
4875
|
+
assertUsableIdentityValues(
|
|
4876
|
+
this as unknown as Record<string, unknown>,
|
|
4877
|
+
this.uniqueIndexes || [],
|
|
4878
|
+
this.identityValueTypes,
|
|
4879
|
+
);
|
|
4880
|
+
const saveableObject: unknown = {}; // is not exposed to outside, so any is ok here
|
|
4881
|
+
const globalProps = this.globalSaveableProperties || [];
|
|
4882
|
+
const specificProps = this.saveableProperties || [];
|
|
4883
|
+
const policy = getOrdinaryPersistencePolicy(this.constructor);
|
|
4884
|
+
const saveableProperties = [...new Set([
|
|
4885
|
+
...globalProps, ...specificProps, ...(policy ? ['_id'] : []),
|
|
4886
|
+
])].filter((fieldArg) => policy?.timestamps !== 'none' ||
|
|
4887
|
+
!['_createdAt', '_updatedAt'].includes(fieldArg));
|
|
4888
|
+
// apply custom serialization if configured
|
|
4889
|
+
const optionsMap = (this.constructor as any)._svDbOptions || {};
|
|
4890
|
+
for (const propertyNameString of saveableProperties) {
|
|
4891
|
+
const rawValue = (this as any)[propertyNameString];
|
|
4892
|
+
const opts = optionsMap[propertyNameString];
|
|
4893
|
+
const serializedValue = opts && typeof opts.serialize === 'function'
|
|
4894
|
+
? opts.serialize(rawValue)
|
|
4895
|
+
: rawValue;
|
|
4896
|
+
if (serializedValue === undefined) {
|
|
4897
|
+
// an undefined property means "absent" - skip it so the driver cannot
|
|
4898
|
+
// turn it into BSON null during serialization
|
|
4899
|
+
continue;
|
|
4900
|
+
}
|
|
4901
|
+
(saveableObject as any)[propertyNameString] = stripUndefinedValues(serializedValue);
|
|
4902
|
+
}
|
|
4903
|
+
return saveableObject as TImplements;
|
|
4904
|
+
}
|
|
4905
|
+
|
|
4906
|
+
/**
|
|
4907
|
+
* creates an identifiable object for operations that require filtering
|
|
4908
|
+
*/
|
|
4909
|
+
public async createIdentifiableObject() {
|
|
4910
|
+
assertUsableIdentityValues(
|
|
4911
|
+
this as unknown as Record<string, unknown>,
|
|
4912
|
+
this.uniqueIndexes || [],
|
|
4913
|
+
this.identityValueTypes,
|
|
4914
|
+
);
|
|
4915
|
+
const identifiableObject: any = {}; // is not exposed to outside, so any is ok here
|
|
4916
|
+
for (const propertyNameString of this.uniqueIndexes || []) {
|
|
4917
|
+
identifiableObject[propertyNameString] = this[propertyNameString];
|
|
4918
|
+
}
|
|
4919
|
+
if (getOrdinaryPersistencePolicy(this.constructor)?.idType === 'string') {
|
|
4920
|
+
const id = (this as any)._id;
|
|
4921
|
+
if (typeof id !== 'string' || id.trim().length === 0) {
|
|
4922
|
+
throw new SmartdataPersistenceError('invalid_argument', 'String primary ID is required.');
|
|
4923
|
+
}
|
|
4924
|
+
identifiableObject._id = id;
|
|
4925
|
+
}
|
|
4926
|
+
return identifiableObject;
|
|
4927
|
+
}
|
|
4928
|
+
}
|