@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.
Files changed (255) hide show
  1. package/.smartconfig.json +37 -0
  2. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  3. package/dist_ts/00_commitinfo_data.js +9 -0
  4. package/dist_ts/classes.losslessorgclient.d.ts +32 -0
  5. package/dist_ts/classes.losslessorgclient.js +103 -0
  6. package/dist_ts/core/classes.error.d.ts +16 -0
  7. package/dist_ts/core/classes.error.js +29 -0
  8. package/dist_ts/core/classes.operation.d.ts +17 -0
  9. package/dist_ts/core/classes.operation.js +70 -0
  10. package/dist_ts/core/interfaces.d.ts +18 -0
  11. package/dist_ts/core/interfaces.js +2 -0
  12. package/dist_ts/index.d.ts +4 -0
  13. package/dist_ts/index.js +3 -0
  14. package/dist_ts/nosqldb/classes.atomicdelete.d.ts +26 -0
  15. package/dist_ts/nosqldb/classes.atomicdelete.js +44 -0
  16. package/dist_ts/nosqldb/classes.atomicfindoneandupdate.d.ts +27 -0
  17. package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +86 -0
  18. package/dist_ts/nosqldb/classes.atomicupdate.d.ts +26 -0
  19. package/dist_ts/nosqldb/classes.atomicupdate.js +55 -0
  20. package/dist_ts/nosqldb/classes.bootstraprecord.d.ts +14 -0
  21. package/dist_ts/nosqldb/classes.bootstraprecord.js +80 -0
  22. package/dist_ts/nosqldb/classes.bsonassertion.d.ts +2 -0
  23. package/dist_ts/nosqldb/classes.bsonassertion.js +29 -0
  24. package/dist_ts/nosqldb/classes.bsonobjectid.d.ts +2 -0
  25. package/dist_ts/nosqldb/classes.bsonobjectid.js +50 -0
  26. package/dist_ts/nosqldb/classes.bsonsnapshot.d.ts +3 -0
  27. package/dist_ts/nosqldb/classes.bsonsnapshot.js +126 -0
  28. package/dist_ts/nosqldb/classes.cached-document.d.ts +27 -0
  29. package/dist_ts/nosqldb/classes.cached-document.js +112 -0
  30. package/dist_ts/nosqldb/classes.collection.d.ts +302 -0
  31. package/dist_ts/nosqldb/classes.collection.js +1568 -0
  32. package/dist_ts/nosqldb/classes.collectionfactory.d.ts +30 -0
  33. package/dist_ts/nosqldb/classes.collectionfactory.js +65 -0
  34. package/dist_ts/nosqldb/classes.collectionlifecycle.d.ts +4 -0
  35. package/dist_ts/nosqldb/classes.collectionlifecycle.js +15 -0
  36. package/dist_ts/nosqldb/classes.collectionpreparation.d.ts +34 -0
  37. package/dist_ts/nosqldb/classes.collectionpreparation.js +134 -0
  38. package/dist_ts/nosqldb/classes.collectiontopology.d.ts +47 -0
  39. package/dist_ts/nosqldb/classes.collectiontopology.js +388 -0
  40. package/dist_ts/nosqldb/classes.connection.d.ts +18 -0
  41. package/dist_ts/nosqldb/classes.connection.js +23 -0
  42. package/dist_ts/nosqldb/classes.convenience.d.ts +1 -0
  43. package/dist_ts/nosqldb/classes.convenience.js +5 -0
  44. package/dist_ts/nosqldb/classes.cursor.d.ts +15 -0
  45. package/dist_ts/nosqldb/classes.cursor.js +82 -0
  46. package/dist_ts/nosqldb/classes.cursoroperation.d.ts +7 -0
  47. package/dist_ts/nosqldb/classes.cursoroperation.js +36 -0
  48. package/dist_ts/nosqldb/classes.db.d.ts +98 -0
  49. package/dist_ts/nosqldb/classes.db.js +434 -0
  50. package/dist_ts/nosqldb/classes.distributedcoordinator.d.ts +52 -0
  51. package/dist_ts/nosqldb/classes.distributedcoordinator.js +314 -0
  52. package/dist_ts/nosqldb/classes.doc.d.ts +722 -0
  53. package/dist_ts/nosqldb/classes.doc.js +2976 -0
  54. package/dist_ts/nosqldb/classes.easystore.d.ts +42 -0
  55. package/dist_ts/nosqldb/classes.easystore.js +199 -0
  56. package/dist_ts/nosqldb/classes.exactpersistence.d.ts +141 -0
  57. package/dist_ts/nosqldb/classes.exactpersistence.js +1110 -0
  58. package/dist_ts/nosqldb/classes.lucene.adapter.d.ts +173 -0
  59. package/dist_ts/nosqldb/classes.lucene.adapter.js +623 -0
  60. package/dist_ts/nosqldb/classes.mongoadministration.d.ts +73 -0
  61. package/dist_ts/nosqldb/classes.mongoadministration.js +497 -0
  62. package/dist_ts/nosqldb/classes.namespaceinspection.d.ts +42 -0
  63. package/dist_ts/nosqldb/classes.namespaceinspection.js +138 -0
  64. package/dist_ts/nosqldb/classes.operationbudget.d.ts +22 -0
  65. package/dist_ts/nosqldb/classes.operationbudget.js +65 -0
  66. package/dist_ts/nosqldb/classes.ordinarypersistence.d.ts +18 -0
  67. package/dist_ts/nosqldb/classes.ordinarypersistence.js +71 -0
  68. package/dist_ts/nosqldb/classes.persistence.d.ts +14 -0
  69. package/dist_ts/nosqldb/classes.persistence.js +34 -0
  70. package/dist_ts/nosqldb/classes.session.d.ts +28 -0
  71. package/dist_ts/nosqldb/classes.session.js +246 -0
  72. package/dist_ts/nosqldb/classes.storedinspection.d.ts +44 -0
  73. package/dist_ts/nosqldb/classes.storedinspection.js +100 -0
  74. package/dist_ts/nosqldb/classes.testdatabase.d.ts +56 -0
  75. package/dist_ts/nosqldb/classes.testdatabase.js +250 -0
  76. package/dist_ts/nosqldb/classes.watcher.d.ts +37 -0
  77. package/dist_ts/nosqldb/classes.watcher.js +98 -0
  78. package/dist_ts/nosqldb/index.d.ts +27 -0
  79. package/dist_ts/nosqldb/index.js +18 -0
  80. package/dist_ts/nosqldb/logging.d.ts +2 -0
  81. package/dist_ts/nosqldb/logging.js +3 -0
  82. package/dist_ts/nosqldb/plugins.d.ts +17 -0
  83. package/dist_ts/nosqldb/plugins.js +20 -0
  84. package/dist_ts/nosqldb/shim.d.ts +1 -0
  85. package/dist_ts/nosqldb/shim.js +8 -0
  86. package/dist_ts/nosqldb/testsupport.d.ts +3 -0
  87. package/dist_ts/nosqldb/testsupport.js +3 -0
  88. package/dist_ts/objectstorage/classes.bucket.d.ts +227 -0
  89. package/dist_ts/objectstorage/classes.bucket.js +939 -0
  90. package/dist_ts/objectstorage/classes.connection.d.ts +21 -0
  91. package/dist_ts/objectstorage/classes.connection.js +27 -0
  92. package/dist_ts/objectstorage/classes.directory.d.ts +124 -0
  93. package/dist_ts/objectstorage/classes.directory.js +335 -0
  94. package/dist_ts/objectstorage/classes.exactpathpurgeerror.d.ts +5 -0
  95. package/dist_ts/objectstorage/classes.exactpathpurgeerror.js +18 -0
  96. package/dist_ts/objectstorage/classes.exactuploaderror.d.ts +5 -0
  97. package/dist_ts/objectstorage/classes.exactuploaderror.js +19 -0
  98. package/dist_ts/objectstorage/classes.file.d.ts +91 -0
  99. package/dist_ts/objectstorage/classes.file.js +255 -0
  100. package/dist_ts/objectstorage/classes.listcursor.d.ts +41 -0
  101. package/dist_ts/objectstorage/classes.listcursor.js +67 -0
  102. package/dist_ts/objectstorage/classes.metadata.d.ts +57 -0
  103. package/dist_ts/objectstorage/classes.metadata.js +113 -0
  104. package/dist_ts/objectstorage/classes.smartbucket.d.ts +57 -0
  105. package/dist_ts/objectstorage/classes.smartbucket.js +357 -0
  106. package/dist_ts/objectstorage/classes.trash.d.ts +11 -0
  107. package/dist_ts/objectstorage/classes.trash.js +22 -0
  108. package/dist_ts/objectstorage/classes.watcher.d.ts +79 -0
  109. package/dist_ts/objectstorage/classes.watcher.js +249 -0
  110. package/dist_ts/objectstorage/helpers.d.ts +25 -0
  111. package/dist_ts/objectstorage/helpers.js +215 -0
  112. package/dist_ts/objectstorage/index.d.ts +13 -0
  113. package/dist_ts/objectstorage/index.js +13 -0
  114. package/dist_ts/objectstorage/interfaces.d.ts +276 -0
  115. package/dist_ts/objectstorage/interfaces.js +2 -0
  116. package/dist_ts/objectstorage/internal.exactpathpurge.operations.d.ts +11 -0
  117. package/dist_ts/objectstorage/internal.exactpathpurge.operations.js +1069 -0
  118. package/dist_ts/objectstorage/internal.exactupload.capability.d.ts +40 -0
  119. package/dist_ts/objectstorage/internal.exactupload.capability.js +382 -0
  120. package/dist_ts/objectstorage/internal.exactupload.operations.d.ts +11 -0
  121. package/dist_ts/objectstorage/internal.exactupload.operations.js +1104 -0
  122. package/dist_ts/objectstorage/internal.multipart.planner.d.ts +8 -0
  123. package/dist_ts/objectstorage/internal.multipart.planner.js +63 -0
  124. package/dist_ts/objectstorage/internal.multipart.stream.d.ts +17 -0
  125. package/dist_ts/objectstorage/internal.multipart.stream.js +440 -0
  126. package/dist_ts/objectstorage/internal.multipart.types.d.ts +82 -0
  127. package/dist_ts/objectstorage/internal.multipart.types.js +2 -0
  128. package/dist_ts/objectstorage/plugins.d.ts +17 -0
  129. package/dist_ts/objectstorage/plugins.js +23 -0
  130. package/dist_ts/plugins.d.ts +4 -0
  131. package/dist_ts/plugins.js +5 -0
  132. package/dist_ts/sqldb/classes.clickhouseconnection.d.ts +39 -0
  133. package/dist_ts/sqldb/classes.clickhouseconnection.js +225 -0
  134. package/dist_ts/sqldb/classes.sqlconnection.d.ts +50 -0
  135. package/dist_ts/sqldb/classes.sqlconnection.js +376 -0
  136. package/dist_ts/sqldb/classes.sqltable.d.ts +29 -0
  137. package/dist_ts/sqldb/classes.sqltable.js +70 -0
  138. package/dist_ts/sqldb/clickhouse/classes.metricwriter.d.ts +27 -0
  139. package/dist_ts/sqldb/clickhouse/classes.metricwriter.js +89 -0
  140. package/dist_ts/sqldb/clickhouse/index.d.ts +9 -0
  141. package/dist_ts/sqldb/clickhouse/index.js +13 -0
  142. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.clickhousetable.d.ts +88 -0
  143. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.clickhousetable.js +304 -0
  144. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.httpclient.d.ts +28 -0
  145. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.httpclient.js +46 -0
  146. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.querybuilder.d.ts +43 -0
  147. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.querybuilder.js +155 -0
  148. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.resultset.d.ts +13 -0
  149. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.resultset.js +36 -0
  150. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.smartclickhouse.d.ts +49 -0
  151. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.smartclickhouse.js +106 -0
  152. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.timedatatable.d.ts +51 -0
  153. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.timedatatable.js +96 -0
  154. package/dist_ts/sqldb/clickhouse/smartclickhouse.plugins.d.ts +5 -0
  155. package/dist_ts/sqldb/clickhouse/smartclickhouse.plugins.js +6 -0
  156. package/dist_ts/sqldb/clickhouse/smartclickhouse.types.d.ts +53 -0
  157. package/dist_ts/sqldb/clickhouse/smartclickhouse.types.js +41 -0
  158. package/dist_ts/sqldb/index.d.ts +6 -0
  159. package/dist_ts/sqldb/index.js +6 -0
  160. package/dist_ts/sqldb/interfaces.d.ts +53 -0
  161. package/dist_ts/sqldb/interfaces.js +19 -0
  162. package/dist_ts/sqldb/internal.sqlpool.d.ts +51 -0
  163. package/dist_ts/sqldb/internal.sqlpool.js +214 -0
  164. package/dist_ts/sqldb/plugins.clickhouse.d.ts +4 -0
  165. package/dist_ts/sqldb/plugins.clickhouse.js +5 -0
  166. package/dist_ts/sqldb/plugins.mariadb.d.ts +4 -0
  167. package/dist_ts/sqldb/plugins.mariadb.js +5 -0
  168. package/docs/source/smartbucket/license.md +19 -0
  169. package/docs/source/smartclickhouse/license.md +19 -0
  170. package/license.md +21 -0
  171. package/package.json +101 -0
  172. package/readme.md +157 -0
  173. package/ts/00_commitinfo_data.ts +8 -0
  174. package/ts/classes.losslessorgclient.ts +104 -0
  175. package/ts/core/classes.error.ts +31 -0
  176. package/ts/core/classes.operation.ts +60 -0
  177. package/ts/core/interfaces.ts +24 -0
  178. package/ts/index.ts +4 -0
  179. package/ts/nosqldb/classes.atomicdelete.ts +81 -0
  180. package/ts/nosqldb/classes.atomicfindoneandupdate.ts +152 -0
  181. package/ts/nosqldb/classes.atomicupdate.ts +99 -0
  182. package/ts/nosqldb/classes.bootstraprecord.ts +107 -0
  183. package/ts/nosqldb/classes.bsonassertion.ts +25 -0
  184. package/ts/nosqldb/classes.bsonobjectid.ts +45 -0
  185. package/ts/nosqldb/classes.bsonsnapshot.ts +105 -0
  186. package/ts/nosqldb/classes.cached-document.ts +67 -0
  187. package/ts/nosqldb/classes.collection.ts +2399 -0
  188. package/ts/nosqldb/classes.collectionfactory.ts +87 -0
  189. package/ts/nosqldb/classes.collectionlifecycle.ts +24 -0
  190. package/ts/nosqldb/classes.collectionpreparation.ts +164 -0
  191. package/ts/nosqldb/classes.collectiontopology.ts +645 -0
  192. package/ts/nosqldb/classes.connection.ts +31 -0
  193. package/ts/nosqldb/classes.convenience.ts +5 -0
  194. package/ts/nosqldb/classes.cursor.ts +88 -0
  195. package/ts/nosqldb/classes.cursoroperation.ts +38 -0
  196. package/ts/nosqldb/classes.db.ts +637 -0
  197. package/ts/nosqldb/classes.distributedcoordinator.ts +305 -0
  198. package/ts/nosqldb/classes.doc.ts +4928 -0
  199. package/ts/nosqldb/classes.easystore.ts +154 -0
  200. package/ts/nosqldb/classes.exactpersistence.ts +1974 -0
  201. package/ts/nosqldb/classes.lucene.adapter.ts +780 -0
  202. package/ts/nosqldb/classes.mongoadministration.ts +867 -0
  203. package/ts/nosqldb/classes.namespaceinspection.ts +199 -0
  204. package/ts/nosqldb/classes.operationbudget.ts +75 -0
  205. package/ts/nosqldb/classes.ordinarypersistence.ts +84 -0
  206. package/ts/nosqldb/classes.persistence.ts +62 -0
  207. package/ts/nosqldb/classes.session.ts +389 -0
  208. package/ts/nosqldb/classes.storedinspection.ts +151 -0
  209. package/ts/nosqldb/classes.testdatabase.ts +272 -0
  210. package/ts/nosqldb/classes.watcher.ts +96 -0
  211. package/ts/nosqldb/index.ts +51 -0
  212. package/ts/nosqldb/logging.ts +3 -0
  213. package/ts/nosqldb/plugins.ts +37 -0
  214. package/ts/nosqldb/shim.ts +6 -0
  215. package/ts/nosqldb/testsupport.ts +13 -0
  216. package/ts/objectstorage/classes.bucket.ts +1114 -0
  217. package/ts/objectstorage/classes.connection.ts +36 -0
  218. package/ts/objectstorage/classes.directory.ts +425 -0
  219. package/ts/objectstorage/classes.exactpathpurgeerror.ts +24 -0
  220. package/ts/objectstorage/classes.exactuploaderror.ts +25 -0
  221. package/ts/objectstorage/classes.file.ts +303 -0
  222. package/ts/objectstorage/classes.listcursor.ts +89 -0
  223. package/ts/objectstorage/classes.metadata.ts +134 -0
  224. package/ts/objectstorage/classes.smartbucket.ts +445 -0
  225. package/ts/objectstorage/classes.trash.ts +30 -0
  226. package/ts/objectstorage/classes.watcher.ts +289 -0
  227. package/ts/objectstorage/helpers.ts +254 -0
  228. package/ts/objectstorage/index.ts +13 -0
  229. package/ts/objectstorage/interfaces.ts +389 -0
  230. package/ts/objectstorage/internal.exactpathpurge.operations.ts +1936 -0
  231. package/ts/objectstorage/internal.exactupload.capability.ts +580 -0
  232. package/ts/objectstorage/internal.exactupload.operations.ts +1715 -0
  233. package/ts/objectstorage/internal.multipart.planner.ts +90 -0
  234. package/ts/objectstorage/internal.multipart.stream.ts +606 -0
  235. package/ts/objectstorage/internal.multipart.types.ts +106 -0
  236. package/ts/objectstorage/plugins.ts +35 -0
  237. package/ts/plugins.ts +4 -0
  238. package/ts/sqldb/classes.clickhouseconnection.ts +189 -0
  239. package/ts/sqldb/classes.sqlconnection.ts +350 -0
  240. package/ts/sqldb/classes.sqltable.ts +70 -0
  241. package/ts/sqldb/clickhouse/classes.metricwriter.ts +72 -0
  242. package/ts/sqldb/clickhouse/index.ts +16 -0
  243. package/ts/sqldb/clickhouse/smartclickhouse.classes.clickhousetable.ts +372 -0
  244. package/ts/sqldb/clickhouse/smartclickhouse.classes.httpclient.ts +52 -0
  245. package/ts/sqldb/clickhouse/smartclickhouse.classes.querybuilder.ts +120 -0
  246. package/ts/sqldb/clickhouse/smartclickhouse.classes.resultset.ts +44 -0
  247. package/ts/sqldb/clickhouse/smartclickhouse.classes.smartclickhouse.ts +123 -0
  248. package/ts/sqldb/clickhouse/smartclickhouse.classes.timedatatable.ts +114 -0
  249. package/ts/sqldb/clickhouse/smartclickhouse.plugins.ts +5 -0
  250. package/ts/sqldb/clickhouse/smartclickhouse.types.ts +134 -0
  251. package/ts/sqldb/index.ts +6 -0
  252. package/ts/sqldb/interfaces.ts +61 -0
  253. package/ts/sqldb/internal.sqlpool.ts +179 -0
  254. package/ts/sqldb/plugins.clickhouse.ts +4 -0
  255. 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
+ }