@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,2399 @@
1
+ import * as plugins from './plugins.js';
2
+ import { LosslessClientError } from '../core/classes.error.js';
3
+ import { runCursorOperation } from './classes.cursoroperation.js';
4
+ import {
5
+ CollectionPreparationBudget, prepareBoundCollection,
6
+ type ISmartdataCollectionPreparationOptions,
7
+ } from './classes.collectionpreparation.js';
8
+ import {
9
+ getOrdinaryPersistencePolicy, validateOrdinaryStoredDocument,
10
+ type IOrdinaryPersistencePolicy,
11
+ } from './classes.ordinarypersistence.js';
12
+ import { SmartdataDb } from './classes.db.js';
13
+ import { SmartdataDbCursor } from './classes.cursor.js';
14
+ import {
15
+ SmartDataDbDoc,
16
+ type IIndexOptions,
17
+ type TSmartdataNumericDocumentPath,
18
+ type TSmartdataIdentityValueType,
19
+ } from './classes.doc.js';
20
+ import { SmartdataDbWatcher } from './classes.watcher.js';
21
+ import { CollectionFactory } from './classes.collectionfactory.js';
22
+ import { logger } from './logging.js';
23
+ import {
24
+ SmartdataPersistenceError,
25
+ normalizeOrdinaryPersistenceError,
26
+ } from './classes.persistence.js';
27
+ import { notifyCollectionReconnect } from './classes.collectionlifecycle.js';
28
+ import {
29
+ runWithOrdinarySmartdataSession,
30
+ type TSmartdataOrdinarySession,
31
+ } from './classes.session.js';
32
+ import {
33
+ collectionModelSchemaResolverSymbol,
34
+ collectionTopologyDbInspectionSymbol,
35
+ getExpectedCollectionTopologyForSchema,
36
+ compareSmartdataTopologyStrings,
37
+ } from './classes.collectiontopology.js';
38
+
39
+ export interface IFindOptions {
40
+ limit?: number;
41
+ }
42
+
43
+ /**
44
+ *
45
+ */
46
+ export interface IDocValidationFunc<T> {
47
+ (doc: T): boolean;
48
+ }
49
+
50
+ export type TDelayed<TDelayedArg> = () => TDelayedArg;
51
+
52
+ const collectionFactory = new CollectionFactory();
53
+
54
+ export interface ICollectionBindingOptions {
55
+ /**
56
+ * Explicit persisted collection name. Use this to bind a renamed model to
57
+ * its legacy collection without changing stored data.
58
+ */
59
+ collectionName?: string;
60
+ }
61
+
62
+ export type TCollectionModelIndexDirection = 1 | -1 | 'text';
63
+
64
+ export interface ICollectionModelIndexOptions {
65
+ background?: boolean;
66
+ unique?: boolean;
67
+ sparse?: boolean;
68
+ expireAfterSeconds?: number;
69
+ }
70
+
71
+ export interface ICollectionModelIndex {
72
+ name: string;
73
+ key: Record<string, TCollectionModelIndexDirection>;
74
+ options?: ICollectionModelIndexOptions;
75
+ }
76
+
77
+ /** Named index on one or more paths rooted in the model's persisted fields. */
78
+ export interface INamedIndexDefinition extends ICollectionModelIndex {}
79
+
80
+ export type TStringFieldKey<TModel extends object> = {
81
+ [TKey in keyof TModel]-?: NonNullable<TModel[TKey]> extends string
82
+ ? TKey
83
+ : never;
84
+ }[keyof TModel] &
85
+ string;
86
+
87
+ export interface ICollectionModelConfig<TModel extends object = any> {
88
+ /**
89
+ * The persisted collection name. Programmatic model binding never derives
90
+ * this value from a constructor name.
91
+ */
92
+ collectionName: string;
93
+ /**
94
+ * Top-level model fields that SmartData may persist.
95
+ */
96
+ persistedFields: ReadonlyArray<keyof TModel & string>;
97
+ /**
98
+ * Persisted top-level numeric fields that strict atomic expressions may
99
+ * reference. This explicit runtime declaration complements the TypeScript
100
+ * numeric-field constraint.
101
+ */
102
+ numericFields?: ReadonlyArray<TSmartdataNumericDocumentPath<TModel>>;
103
+ /**
104
+ * Persisted string identity fields with @unI()-equivalent selector and
105
+ * immutability semantics. Each field requires an explicit single-field,
106
+ * ascending unique index in `indexes`.
107
+ */
108
+ identityFields?: ReadonlyArray<TStringFieldKey<TModel>>;
109
+ /**
110
+ * Persisted fields used by SmartData's search helpers.
111
+ */
112
+ searchableFields?: ReadonlyArray<keyof TModel & string>;
113
+ /**
114
+ * Stable named MongoDB indexes. Object property order defines key order.
115
+ */
116
+ indexes?: ReadonlyArray<ICollectionModelIndex>;
117
+ }
118
+
119
+ export interface INormalizedCollectionModelSchema {
120
+ readonly ordinaryPersistence?: IOrdinaryPersistencePolicy;
121
+ readonly collectionName: string;
122
+ readonly persistedFields: readonly string[];
123
+ readonly numericFields: readonly string[];
124
+ readonly identityFields: readonly string[];
125
+ readonly identityValueTypes: Readonly<
126
+ Record<string, TSmartdataIdentityValueType>
127
+ >;
128
+ readonly searchableFields: readonly string[];
129
+ readonly indexes: ReadonlyArray<{
130
+ readonly name: string;
131
+ readonly key: ReadonlyArray<
132
+ readonly [string, TCollectionModelIndexDirection]
133
+ >;
134
+ readonly options: Readonly<IIndexOptions>;
135
+ }>;
136
+ readonly fingerprint: string;
137
+ }
138
+
139
+ export type TCompoundIndexDirection = 1 | -1;
140
+
141
+ export interface ICompoundIndexDefinition {
142
+ /** Stable MongoDB index name. */
143
+ name: string;
144
+ /** Ordered fields and their ascending/descending directions. */
145
+ key: Record<string, TCompoundIndexDirection>;
146
+ /** MongoDB create-index options other than `name`. */
147
+ options?: Omit<plugins.mongodb.CreateIndexesOptions, 'name'>;
148
+ }
149
+
150
+ export interface ISmartdataIndexInfo {
151
+ name: string;
152
+ fields: Array<{
153
+ path: string;
154
+ direction: 1 | -1 | 'text';
155
+ }>;
156
+ unique: boolean;
157
+ sparse: boolean;
158
+ expireAfterSeconds?: number;
159
+ }
160
+ interface ISmartdataDecoratorMetadata {
161
+ globalSaveableProperties?: string[];
162
+ saveableProperties?: string[];
163
+ uniqueIndexes?: string[];
164
+ identityValueTypes?: Record<string, TSmartdataIdentityValueType>;
165
+ regularIndexes?: Array<{field: string, options: IIndexOptions}>;
166
+ compoundIndexes?: ICompoundIndexDefinition[];
167
+ namedIndexes?: INamedIndexDefinition[];
168
+ searchableFields?: string[];
169
+ _svDbOptions?: Record<string, any>;
170
+ }
171
+
172
+ const unsafeFieldNames = new Set(['__proto__', 'prototype', 'constructor']);
173
+ const automaticTextIndexName = 'smartdata_text_index';
174
+
175
+ const requireSafeFieldName = (
176
+ fieldArg: string,
177
+ labelArg: string,
178
+ allowPathArg = false,
179
+ ): string => {
180
+ if (
181
+ typeof fieldArg !== 'string' ||
182
+ fieldArg.length === 0 ||
183
+ fieldArg.includes('\0') ||
184
+ fieldArg.startsWith('$')
185
+ ) {
186
+ throw new SmartdataPersistenceError(
187
+ 'invalid_configuration',
188
+ `${labelArg} must be a non-empty safe field name.`,
189
+ );
190
+ }
191
+ const segments = fieldArg.split('.');
192
+ if (
193
+ (!allowPathArg && segments.length !== 1) ||
194
+ segments.some(
195
+ (segmentArg) =>
196
+ segmentArg.length === 0 ||
197
+ segmentArg.startsWith('$') ||
198
+ unsafeFieldNames.has(segmentArg),
199
+ )
200
+ ) {
201
+ throw new SmartdataPersistenceError(
202
+ 'invalid_configuration',
203
+ `${labelArg} contains an unsafe field name.`,
204
+ );
205
+ }
206
+ return fieldArg;
207
+ };
208
+
209
+ const normalizeCollectionName = (
210
+ collectionNameArg: string,
211
+ labelArg = 'SmartData collectionName',
212
+ ): string => {
213
+ if (
214
+ typeof collectionNameArg !== 'string' ||
215
+ collectionNameArg.trim().length === 0 ||
216
+ collectionNameArg.includes('\0') ||
217
+ collectionNameArg.startsWith('system.')
218
+ ) {
219
+ throw new SmartdataPersistenceError(
220
+ 'invalid_configuration',
221
+ `${labelArg} must be a non-empty non-system name without null bytes.`,
222
+ );
223
+ }
224
+ return collectionNameArg;
225
+ };
226
+
227
+ const normalizeIndexOptions = (
228
+ optionsArg: IIndexOptions | undefined,
229
+ indexNameArg: string,
230
+ allowExtendedOptionsArg = false,
231
+ ): Readonly<IIndexOptions> => {
232
+ const options = optionsArg || {};
233
+ const allowedKeys = new Set([
234
+ 'background',
235
+ 'unique',
236
+ 'sparse',
237
+ 'expireAfterSeconds',
238
+ ]);
239
+ if (!allowExtendedOptionsArg) {
240
+ for (const optionName of Object.keys(options)) {
241
+ if (allowedKeys.has(optionName)) {
242
+ continue;
243
+ }
244
+ throw new SmartdataPersistenceError(
245
+ 'invalid_configuration',
246
+ `Index "${indexNameArg}" defines unsupported option "${optionName}".`,
247
+ );
248
+ }
249
+ }
250
+ for (const booleanOption of ['background', 'unique', 'sparse'] as const) {
251
+ if (
252
+ options[booleanOption] !== undefined &&
253
+ typeof options[booleanOption] !== 'boolean'
254
+ ) {
255
+ throw new SmartdataPersistenceError(
256
+ 'invalid_configuration',
257
+ `Index "${indexNameArg}" option "${booleanOption}" must be boolean.`,
258
+ );
259
+ }
260
+ }
261
+ if (
262
+ options.expireAfterSeconds !== undefined &&
263
+ (!Number.isFinite(options.expireAfterSeconds) ||
264
+ options.expireAfterSeconds < 0)
265
+ ) {
266
+ throw new SmartdataPersistenceError(
267
+ 'invalid_configuration',
268
+ `Index "${indexNameArg}" expireAfterSeconds must be a finite non-negative number.`,
269
+ );
270
+ }
271
+ return Object.freeze(
272
+ allowExtendedOptionsArg
273
+ ? { ...options }
274
+ : {
275
+ ...(options.background === true ? { background: true } : {}),
276
+ ...(options.unique === true ? { unique: true } : {}),
277
+ ...(options.sparse === true ? { sparse: true } : {}),
278
+ ...(options.expireAfterSeconds !== undefined
279
+ ? { expireAfterSeconds: options.expireAfterSeconds }
280
+ : {}),
281
+ },
282
+ );
283
+ };
284
+
285
+ const stableValue = (valueArg: unknown): string => {
286
+ if (Array.isArray(valueArg)) {
287
+ return `[${valueArg.map((entryArg) => stableValue(entryArg)).join(',')}]`;
288
+ }
289
+ if (typeof valueArg === 'object' && valueArg !== null) {
290
+ return `{${Object.keys(valueArg as Record<string, unknown>)
291
+ .sort(compareSmartdataTopologyStrings)
292
+ .map(
293
+ (keyArg) =>
294
+ `${JSON.stringify(keyArg)}:${stableValue(
295
+ (valueArg as Record<string, unknown>)[keyArg],
296
+ )}`,
297
+ )
298
+ .join(',')}}`;
299
+ }
300
+ return JSON.stringify(valueArg);
301
+ };
302
+
303
+ const normalizeCollectionModelSchema = (
304
+ configArg: ICollectionModelConfig<any>,
305
+ allowExtendedIndexOptionsArg = false,
306
+ identityValueTypesArg?: Readonly<
307
+ Record<string, TSmartdataIdentityValueType>
308
+ >,
309
+ ordinaryPolicyArg?: IOrdinaryPersistencePolicy,
310
+ ): INormalizedCollectionModelSchema => {
311
+ if (
312
+ typeof configArg !== 'object' ||
313
+ configArg === null ||
314
+ Array.isArray(configArg)
315
+ ) {
316
+ throw new SmartdataPersistenceError(
317
+ 'invalid_configuration',
318
+ 'Collection model config must be an object.',
319
+ );
320
+ }
321
+ const allowedConfigKeys = new Set([
322
+ 'collectionName',
323
+ 'persistedFields',
324
+ 'numericFields',
325
+ 'identityFields',
326
+ 'searchableFields',
327
+ 'indexes',
328
+ ]);
329
+ for (const key of Object.keys(configArg)) {
330
+ if (!allowedConfigKeys.has(key)) {
331
+ throw new SmartdataPersistenceError(
332
+ 'invalid_configuration',
333
+ `Collection model config defines unsupported key "${key}".`,
334
+ );
335
+ }
336
+ }
337
+ const collectionName = normalizeCollectionName(configArg.collectionName);
338
+ if (!Array.isArray(configArg.persistedFields)) {
339
+ throw new SmartdataPersistenceError(
340
+ 'invalid_configuration',
341
+ 'Collection model persistedFields must be an array.',
342
+ );
343
+ }
344
+ const persistedFields: string[] = [];
345
+ for (const field of configArg.persistedFields) {
346
+ requireSafeFieldName(field, 'A persisted field');
347
+ if (!persistedFields.includes(field)) {
348
+ persistedFields.push(field);
349
+ }
350
+ }
351
+ if (ordinaryPolicyArg?.idType === 'string' && !persistedFields.includes('_id')) {
352
+ persistedFields.push('_id');
353
+ }
354
+ if (
355
+ configArg.numericFields !== undefined &&
356
+ !Array.isArray(configArg.numericFields)
357
+ ) {
358
+ throw new SmartdataPersistenceError(
359
+ 'invalid_configuration',
360
+ 'Collection model numericFields must be an array.',
361
+ );
362
+ }
363
+ const numericFields: string[] = [];
364
+ for (const field of configArg.numericFields || []) {
365
+ requireSafeFieldName(field, 'A numeric field');
366
+ if (!persistedFields.includes(field)) {
367
+ throw new SmartdataPersistenceError(
368
+ 'invalid_configuration',
369
+ `Numeric field "${field}" is not a declared persisted field.`,
370
+ );
371
+ }
372
+ if (!numericFields.includes(field)) {
373
+ numericFields.push(field);
374
+ }
375
+ }
376
+ if (
377
+ configArg.identityFields !== undefined &&
378
+ !Array.isArray(configArg.identityFields)
379
+ ) {
380
+ throw new SmartdataPersistenceError(
381
+ 'invalid_configuration',
382
+ 'Collection model identityFields must be an array.',
383
+ );
384
+ }
385
+ const identityFields: string[] = [];
386
+ for (const field of configArg.identityFields || []) {
387
+ requireSafeFieldName(field, 'An identity field');
388
+ if (!persistedFields.includes(field)) {
389
+ throw new SmartdataPersistenceError(
390
+ 'invalid_configuration',
391
+ `Identity field "${field}" is not a declared persisted field.`,
392
+ );
393
+ }
394
+ if (!identityFields.includes(field)) {
395
+ identityFields.push(field);
396
+ }
397
+ }
398
+ const identityValueTypes: Record<string, TSmartdataIdentityValueType> = {};
399
+ for (const field of Object.keys(identityValueTypesArg || {})) {
400
+ if (!identityFields.includes(field)) {
401
+ throw new SmartdataPersistenceError(
402
+ 'invalid_configuration',
403
+ `Identity valueType field "${field}" is not a declared identity field.`,
404
+ );
405
+ }
406
+ }
407
+ for (const field of identityFields) {
408
+ const valueType = identityValueTypesArg?.[field] ?? 'string';
409
+ if (valueType !== 'string' && valueType !== 'positiveSafeInteger') {
410
+ throw new SmartdataPersistenceError(
411
+ 'invalid_configuration',
412
+ `Identity field "${field}" has an unsupported valueType.`,
413
+ );
414
+ }
415
+ identityValueTypes[field] = valueType;
416
+ if (valueType === 'positiveSafeInteger' && !numericFields.includes(field)) {
417
+ numericFields.push(field);
418
+ }
419
+ }
420
+ const searchableFields: string[] = [];
421
+ if (
422
+ configArg.searchableFields !== undefined &&
423
+ !Array.isArray(configArg.searchableFields)
424
+ ) {
425
+ throw new SmartdataPersistenceError(
426
+ 'invalid_configuration',
427
+ 'Collection model searchableFields must be an array.',
428
+ );
429
+ }
430
+ for (const field of configArg.searchableFields || []) {
431
+ requireSafeFieldName(field, 'A searchable field');
432
+ if (!persistedFields.includes(field)) {
433
+ throw new SmartdataPersistenceError(
434
+ 'invalid_configuration',
435
+ `Searchable field "${field}" is not a declared persisted field.`,
436
+ );
437
+ }
438
+ if (!searchableFields.includes(field)) {
439
+ searchableFields.push(field);
440
+ }
441
+ }
442
+ if (configArg.indexes !== undefined && !Array.isArray(configArg.indexes)) {
443
+ throw new SmartdataPersistenceError(
444
+ 'invalid_configuration',
445
+ 'Collection model indexes must be an array.',
446
+ );
447
+ }
448
+ const indexesByName = new Map<
449
+ string,
450
+ INormalizedCollectionModelSchema['indexes'][number]
451
+ >();
452
+ for (const index of configArg.indexes || []) {
453
+ if (
454
+ typeof index !== 'object' ||
455
+ index === null ||
456
+ Array.isArray(index) ||
457
+ typeof index.name !== 'string' ||
458
+ index.name.trim().length === 0 ||
459
+ index.name.includes('\0')
460
+ ) {
461
+ throw new SmartdataPersistenceError(
462
+ 'invalid_configuration',
463
+ 'Every collection model index requires a non-empty safe name.',
464
+ );
465
+ }
466
+ if (index.name === automaticTextIndexName) {
467
+ throw new SmartdataPersistenceError(
468
+ 'invalid_configuration',
469
+ `Index name "${automaticTextIndexName}" is reserved for SmartData's automatic text index.`,
470
+ );
471
+ }
472
+ const indexKeys = Object.entries(index.key || {});
473
+ if (indexKeys.length === 0) {
474
+ throw new SmartdataPersistenceError(
475
+ 'invalid_configuration',
476
+ `Index "${index.name}" requires at least one key.`,
477
+ );
478
+ }
479
+ const normalizedKeys = indexKeys.map(([field, direction]) => {
480
+ requireSafeFieldName(field, `Index "${index.name}" field`, true);
481
+ const root = field.split('.')[0];
482
+ if (
483
+ !persistedFields.includes(root) &&
484
+ root !== '_createdAt' &&
485
+ root !== '_updatedAt'
486
+ ) {
487
+ throw new SmartdataPersistenceError(
488
+ 'invalid_configuration',
489
+ `Index "${index.name}" references undeclared field "${field}".`,
490
+ );
491
+ }
492
+ if (direction !== 1 && direction !== -1 && direction !== 'text') {
493
+ throw new SmartdataPersistenceError(
494
+ 'invalid_configuration',
495
+ `Index "${index.name}" has an invalid direction for "${field}".`,
496
+ );
497
+ }
498
+ return Object.freeze([field, direction] as const);
499
+ });
500
+ const normalizedIndex = Object.freeze({
501
+ name: index.name,
502
+ key: Object.freeze(normalizedKeys),
503
+ options: normalizeIndexOptions(
504
+ index.options,
505
+ index.name,
506
+ allowExtendedIndexOptionsArg,
507
+ ),
508
+ });
509
+ const existing = indexesByName.get(index.name);
510
+ if (existing && stableValue(existing) !== stableValue(normalizedIndex)) {
511
+ throw new SmartdataPersistenceError(
512
+ 'invalid_configuration',
513
+ `Index "${index.name}" has divergent declarations.`,
514
+ );
515
+ }
516
+ indexesByName.set(index.name, existing || normalizedIndex);
517
+ }
518
+ const textIndexes = [...indexesByName.values()].filter((indexArg) =>
519
+ indexArg.key.some(([, directionArg]) => directionArg === 'text'),
520
+ );
521
+ if (textIndexes.length > 1) {
522
+ throw new SmartdataPersistenceError(
523
+ 'invalid_configuration',
524
+ 'A collection model may declare at most one text index.',
525
+ );
526
+ }
527
+ if (textIndexes.length === 1) {
528
+ const indexedTextFields = textIndexes[0].key
529
+ .filter(([, directionArg]) => directionArg === 'text')
530
+ .map(([fieldArg]) => fieldArg)
531
+ .sort(compareSmartdataTopologyStrings);
532
+ const normalizedSearchableFields = [...searchableFields].sort(
533
+ compareSmartdataTopologyStrings,
534
+ );
535
+ if (
536
+ stableValue(indexedTextFields) !==
537
+ stableValue(normalizedSearchableFields)
538
+ ) {
539
+ throw new SmartdataPersistenceError(
540
+ 'invalid_configuration',
541
+ `Text index "${textIndexes[0].name}" must cover exactly the declared searchable fields.`,
542
+ );
543
+ }
544
+ }
545
+ if (searchableFields.length > 0 && textIndexes.length === 0) {
546
+ indexesByName.set(
547
+ automaticTextIndexName,
548
+ Object.freeze({
549
+ name: automaticTextIndexName,
550
+ key: Object.freeze(
551
+ searchableFields.map((fieldArg) =>
552
+ Object.freeze([fieldArg, 'text'] as const),
553
+ ),
554
+ ),
555
+ options: Object.freeze({}),
556
+ }),
557
+ );
558
+ }
559
+ for (const identityField of identityFields) {
560
+ const hasIdentityIndex = [...indexesByName.values()].some(
561
+ (indexArg) =>
562
+ indexArg.options.unique === true &&
563
+ indexArg.key.length === 1 &&
564
+ indexArg.key[0][0] === identityField &&
565
+ indexArg.key[0][1] === 1,
566
+ );
567
+ if (!hasIdentityIndex) {
568
+ throw new SmartdataPersistenceError(
569
+ 'invalid_configuration',
570
+ `Identity field "${identityField}" requires an explicit single-field ascending unique index.`,
571
+ );
572
+ }
573
+ }
574
+ const normalizedCore = {
575
+ ordinaryPersistence: ordinaryPolicyArg,
576
+ collectionName,
577
+ persistedFields: Object.freeze([...persistedFields]),
578
+ numericFields: Object.freeze([...numericFields]),
579
+ identityFields: Object.freeze([...identityFields]),
580
+ identityValueTypes: Object.freeze({ ...identityValueTypes }),
581
+ searchableFields: Object.freeze([...searchableFields]),
582
+ indexes: Object.freeze([...indexesByName.values()]),
583
+ };
584
+ return Object.freeze({
585
+ ...normalizedCore,
586
+ fingerprint: stableValue({
587
+ ordinaryPersistence: ordinaryPolicyArg ? {
588
+ idType: ordinaryPolicyArg.idType, timestamps: ordinaryPolicyArg.timestamps,
589
+ } : undefined,
590
+ collectionName,
591
+ persistedFields: [...persistedFields].sort(compareSmartdataTopologyStrings),
592
+ numericFields: [...numericFields].sort(compareSmartdataTopologyStrings),
593
+ identityFields: [...identityFields].sort(compareSmartdataTopologyStrings),
594
+ identityValueTypes,
595
+ searchableFields: [...searchableFields].sort(
596
+ compareSmartdataTopologyStrings,
597
+ ),
598
+ indexes: [...indexesByName.values()].sort((leftArg, rightArg) =>
599
+ compareSmartdataTopologyStrings(leftArg.name, rightArg.name),
600
+ ),
601
+ }),
602
+ });
603
+ };
604
+
605
+ const getOwnMetadataValue = <T>(metadata: any, key: keyof ISmartdataDecoratorMetadata): T | undefined => {
606
+ if (!metadata || !Object.prototype.hasOwnProperty.call(metadata, key)) {
607
+ return undefined;
608
+ }
609
+ return metadata[key] as T;
610
+ };
611
+
612
+ const mergeStringArrays = (...arrays: Array<string[] | undefined>): string[] => {
613
+ const merged: string[] = [];
614
+ for (const array of arrays) {
615
+ if (!array) {
616
+ continue;
617
+ }
618
+ for (const item of array) {
619
+ if (!merged.includes(item)) {
620
+ merged.push(item);
621
+ }
622
+ }
623
+ }
624
+ return merged;
625
+ };
626
+
627
+ const mergeIdentityValueTypes = (
628
+ ...typeMapsArg: Array<
629
+ Record<string, TSmartdataIdentityValueType> | undefined
630
+ >
631
+ ): Record<string, TSmartdataIdentityValueType> => {
632
+ const merged: Record<string, TSmartdataIdentityValueType> = {};
633
+ for (const typeMap of typeMapsArg) {
634
+ for (const [field, valueType] of Object.entries(typeMap || {})) {
635
+ const existing = merged[field];
636
+ if (existing && existing !== valueType) {
637
+ throw new SmartdataPersistenceError(
638
+ 'invalid_configuration',
639
+ `Identity field "${field}" has divergent inherited valueType declarations.`,
640
+ );
641
+ }
642
+ merged[field] = valueType;
643
+ }
644
+ }
645
+ return merged;
646
+ };
647
+
648
+ const mergeRegularIndexes = (
649
+ ...indexArrays: Array<Array<{field: string, options: IIndexOptions}> | undefined>
650
+ ): Array<{field: string, options: IIndexOptions}> => {
651
+ const mergedMap = new Map<string, {field: string, options: IIndexOptions}>();
652
+ for (const indexArray of indexArrays) {
653
+ if (!indexArray) {
654
+ continue;
655
+ }
656
+ for (const indexDef of indexArray) {
657
+ mergedMap.set(indexDef.field, {
658
+ field: indexDef.field,
659
+ options: { ...indexDef.options },
660
+ });
661
+ }
662
+ }
663
+ return [...mergedMap.values()];
664
+ };
665
+
666
+ const stableIndexValue = (valueArg: unknown): string => {
667
+ if (Array.isArray(valueArg)) {
668
+ return `[${valueArg.map((entryArg) => stableIndexValue(entryArg)).join(',')}]`;
669
+ }
670
+ if (typeof valueArg === 'object' && valueArg !== null) {
671
+ return `{${Object.keys(valueArg as Record<string, unknown>)
672
+ .sort(compareSmartdataTopologyStrings)
673
+ .map(
674
+ (keyArg) =>
675
+ `${JSON.stringify(keyArg)}:${stableIndexValue(
676
+ (valueArg as Record<string, unknown>)[keyArg],
677
+ )}`,
678
+ )
679
+ .join(',')}}`;
680
+ }
681
+ return JSON.stringify(valueArg);
682
+ };
683
+
684
+ const compoundIndexesEqual = (
685
+ leftArg: ICollectionModelIndex,
686
+ rightArg: ICollectionModelIndex,
687
+ ): boolean => {
688
+ const leftKeyEntries = Object.entries(leftArg.key);
689
+ const rightKeyEntries = Object.entries(rightArg.key);
690
+ return (
691
+ stableIndexValue(leftKeyEntries) === stableIndexValue(rightKeyEntries) &&
692
+ stableIndexValue(leftArg.options || {}) ===
693
+ stableIndexValue(rightArg.options || {})
694
+ );
695
+ };
696
+
697
+ const mergeCompoundIndexes = <TDefinition extends ICollectionModelIndex>(
698
+ ...indexArrays: Array<TDefinition[] | undefined>
699
+ ): TDefinition[] => {
700
+ const mergedMap = new Map<string, TDefinition>();
701
+ for (const indexArray of indexArrays) {
702
+ if (!indexArray) {
703
+ continue;
704
+ }
705
+ for (const indexDefinition of indexArray) {
706
+ const existing = mergedMap.get(indexDefinition.name);
707
+ if (existing && !compoundIndexesEqual(existing, indexDefinition)) {
708
+ throw new SmartdataPersistenceError(
709
+ 'invalid_configuration',
710
+ `Compound index "${indexDefinition.name}" has divergent inherited declarations.`,
711
+ );
712
+ }
713
+ if (!existing) {
714
+ mergedMap.set(indexDefinition.name, {
715
+ ...indexDefinition,
716
+ name: indexDefinition.name,
717
+ key: { ...indexDefinition.key },
718
+ options: { ...(indexDefinition.options || {}) },
719
+ });
720
+ }
721
+ }
722
+ }
723
+ return [...mergedMap.values()];
724
+ };
725
+
726
+ const mergeDecoratorMetadata = (
727
+ ...metadataArgs: Array<ISmartdataDecoratorMetadata | undefined>
728
+ ): ISmartdataDecoratorMetadata => {
729
+ const merged: ISmartdataDecoratorMetadata = {};
730
+
731
+ merged.globalSaveableProperties = mergeStringArrays(
732
+ ...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'globalSaveableProperties')),
733
+ );
734
+ merged.saveableProperties = mergeStringArrays(
735
+ ...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'saveableProperties')),
736
+ );
737
+ merged.uniqueIndexes = mergeStringArrays(
738
+ ...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'uniqueIndexes')),
739
+ );
740
+ merged.identityValueTypes = mergeIdentityValueTypes(
741
+ ...metadataArgs.map((metadataArg) =>
742
+ getOwnMetadataValue<Record<string, TSmartdataIdentityValueType>>(
743
+ metadataArg,
744
+ 'identityValueTypes',
745
+ ),
746
+ ),
747
+ );
748
+ merged.searchableFields = mergeStringArrays(
749
+ ...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'searchableFields')),
750
+ );
751
+ merged.regularIndexes = mergeRegularIndexes(
752
+ ...metadataArgs.map((metadataArg) => getOwnMetadataValue<Array<{field: string, options: IIndexOptions}>>(metadataArg, 'regularIndexes')),
753
+ );
754
+ merged.compoundIndexes = mergeCompoundIndexes(
755
+ ...metadataArgs.map((metadataArg) =>
756
+ getOwnMetadataValue<ICompoundIndexDefinition[]>(
757
+ metadataArg,
758
+ 'compoundIndexes',
759
+ ),
760
+ ),
761
+ );
762
+ merged.namedIndexes = mergeCompoundIndexes(
763
+ ...metadataArgs.map((metadataArg) =>
764
+ getOwnMetadataValue<INamedIndexDefinition[]>(metadataArg, 'namedIndexes')),
765
+ );
766
+
767
+ const svDbOptions: Record<string, any> = {};
768
+ for (const metadataArg of metadataArgs) {
769
+ const options = getOwnMetadataValue<Record<string, any>>(metadataArg, '_svDbOptions');
770
+ if (options) {
771
+ Object.assign(svDbOptions, options);
772
+ }
773
+ }
774
+ if (Object.keys(svDbOptions).length > 0) {
775
+ merged._svDbOptions = svDbOptions;
776
+ }
777
+
778
+ return merged;
779
+ };
780
+
781
+ const collectInheritedDecoratorMetadata = (
782
+ constructor: { new (...args: any[]): any; prototype: any },
783
+ ): ISmartdataDecoratorMetadata => {
784
+ const metadataChain: ISmartdataDecoratorMetadata[] = [];
785
+ let proto = Object.getPrototypeOf(constructor.prototype);
786
+ while (proto && proto !== Object.prototype) {
787
+ const metadata = proto.constructor?.[(Symbol as any).metadata] as
788
+ | ISmartdataDecoratorMetadata
789
+ | undefined;
790
+ if (metadata) {
791
+ metadataChain.unshift(metadata);
792
+ }
793
+ proto = Object.getPrototypeOf(proto);
794
+ }
795
+ return mergeDecoratorMetadata(...metadataChain);
796
+ };
797
+
798
+ /**
799
+ * Initialize prototype and constructor properties from TC39 decorator metadata.
800
+ * Shared by both Collection and managed decorators.
801
+ */
802
+ function initializeDecoratorMetadata(
803
+ constructor: { new (...args: any[]): any; prototype: any },
804
+ metadata: any
805
+ ): void {
806
+ const mergedMetadata = mergeDecoratorMetadata(
807
+ collectInheritedDecoratorMetadata(constructor),
808
+ metadata as ISmartdataDecoratorMetadata | undefined,
809
+ );
810
+
811
+ const proto = constructor.prototype;
812
+ const ctor = constructor as any;
813
+
814
+ // Prototype properties (instance-level)
815
+ if (mergedMetadata.globalSaveableProperties?.length) {
816
+ proto.globalSaveableProperties = [...mergedMetadata.globalSaveableProperties];
817
+ }
818
+ if (mergedMetadata.saveableProperties?.length) {
819
+ proto.saveableProperties = [...mergedMetadata.saveableProperties];
820
+ }
821
+ if (mergedMetadata.uniqueIndexes?.length) {
822
+ proto.uniqueIndexes = [...mergedMetadata.uniqueIndexes];
823
+ }
824
+ if (mergedMetadata.identityValueTypes) {
825
+ proto.identityValueTypes = { ...mergedMetadata.identityValueTypes };
826
+ }
827
+ if (mergedMetadata.regularIndexes?.length) {
828
+ proto.regularIndexes = [...mergedMetadata.regularIndexes];
829
+ }
830
+ if (mergedMetadata.compoundIndexes?.length) {
831
+ proto.compoundIndexes = [...mergedMetadata.compoundIndexes];
832
+ ctor.compoundIndexes = [...mergedMetadata.compoundIndexes];
833
+ }
834
+
835
+ // Constructor properties (static-level)
836
+ if (mergedMetadata.searchableFields?.length) {
837
+ ctor.searchableFields = [...mergedMetadata.searchableFields];
838
+ }
839
+ if (mergedMetadata._svDbOptions) {
840
+ ctor._svDbOptions = { ...mergedMetadata._svDbOptions };
841
+ }
842
+ }
843
+
844
+ type TCollectionModelConstructor<TModel extends SmartDataDbDoc<any, any>> = {
845
+ new (...args: any[]): TModel;
846
+ name: string;
847
+ prototype: TModel;
848
+ };
849
+
850
+ const definedModelSchemaSymbol = Symbol.for(
851
+ '@push.rocks/smartdata.definedCollectionModelSchema',
852
+ );
853
+
854
+ const schemaFromDecoratorMetadata = (
855
+ constructorArg: TCollectionModelConstructor<any>,
856
+ collectionNameArg: string,
857
+ ): INormalizedCollectionModelSchema => {
858
+ const ownMetadata = Object.prototype.hasOwnProperty.call(
859
+ constructorArg,
860
+ (Symbol as any).metadata,
861
+ )
862
+ ? ((constructorArg as any)[(Symbol as any).metadata] as
863
+ | ISmartdataDecoratorMetadata
864
+ | undefined)
865
+ : undefined;
866
+ const metadata = mergeDecoratorMetadata(
867
+ collectInheritedDecoratorMetadata(constructorArg),
868
+ ownMetadata,
869
+ );
870
+ const persistedFields = [
871
+ ...new Set<string>([
872
+ ...(metadata.saveableProperties || []),
873
+ ...(metadata.uniqueIndexes || []),
874
+ ...(metadata.regularIndexes || []).map(
875
+ (indexArg: { field: string }) => indexArg.field.split('.')[0],
876
+ ),
877
+ ...(metadata.compoundIndexes || []).flatMap(
878
+ (indexArg: ICompoundIndexDefinition) =>
879
+ Object.keys(indexArg.key).map((fieldArg) => fieldArg.split('.')[0]),
880
+ ),
881
+ ]),
882
+ ];
883
+ const numericFields = Object.entries(metadata._svDbOptions || {})
884
+ .filter(([, optionsArg]) => optionsArg?.numeric === true)
885
+ .map(([fieldArg]) => fieldArg);
886
+ const indexes: ICollectionModelIndex[] = [];
887
+ for (const uniqueField of metadata.uniqueIndexes || []) {
888
+ indexes.push({
889
+ name: `${uniqueField}_1`,
890
+ key: { [uniqueField]: 1 },
891
+ options: { unique: true },
892
+ });
893
+ }
894
+ for (const regularIndex of metadata.regularIndexes || []) {
895
+ const rawOptions = { ...(regularIndex.options || {}) } as Record<
896
+ string,
897
+ unknown
898
+ >;
899
+ const explicitName =
900
+ typeof rawOptions.name === 'string'
901
+ ? rawOptions.name
902
+ : `${regularIndex.field}_1`;
903
+ delete rawOptions.name;
904
+ indexes.push({
905
+ name: explicitName,
906
+ key: { [regularIndex.field]: 1 },
907
+ options: rawOptions as ICollectionModelIndexOptions,
908
+ });
909
+ }
910
+ for (const compoundIndexDefinition of metadata.compoundIndexes || []) {
911
+ const validatedDefinition = validateCompoundIndexDefinition(
912
+ compoundIndexDefinition,
913
+ );
914
+ indexes.push({
915
+ name: validatedDefinition.name,
916
+ key: { ...validatedDefinition.key },
917
+ options: { ...(validatedDefinition.options || {}) },
918
+ });
919
+ }
920
+ indexes.push(...(metadata.namedIndexes || []));
921
+ return normalizeCollectionModelSchema(
922
+ {
923
+ collectionName: collectionNameArg,
924
+ persistedFields,
925
+ numericFields,
926
+ identityFields: metadata.uniqueIndexes || [],
927
+ searchableFields: metadata.searchableFields || [],
928
+ indexes,
929
+ },
930
+ true,
931
+ metadata.identityValueTypes,
932
+ getOrdinaryPersistencePolicy(constructorArg),
933
+ );
934
+ };
935
+
936
+ const installCollectionBinding = <
937
+ TModel extends SmartDataDbDoc<any, any>,
938
+ >(
939
+ constructorArg: TCollectionModelConstructor<TModel>,
940
+ dbResolverArg: (receiverArg?: unknown) => SmartdataDb,
941
+ schemaResolverArg: () => INormalizedCollectionModelSchema,
942
+ ): TCollectionModelConstructor<TModel> => {
943
+ const getCollection = (receiverArg?: unknown) => {
944
+ const db = dbResolverArg(receiverArg);
945
+ if (!(db instanceof SmartdataDb)) {
946
+ throw new SmartdataPersistenceError(
947
+ 'invalid_configuration',
948
+ `Model "${constructorArg.name}" did not resolve a SmartdataDb.`,
949
+ );
950
+ }
951
+ const schema = schemaResolverArg();
952
+ if (schema.ordinaryPersistence !== getOrdinaryPersistencePolicy(constructorArg)) {
953
+ throw new SmartdataPersistenceError('invalid_configuration',
954
+ 'Persistence policy must be installed before binding a programmatic model.');
955
+ }
956
+ return collectionFactory.getCollection(
957
+ schema.collectionName,
958
+ db,
959
+ schema,
960
+ );
961
+ };
962
+ Object.defineProperty(constructorArg, collectionModelSchemaResolverSymbol, {
963
+ value: schemaResolverArg,
964
+ enumerable: false,
965
+ configurable: true,
966
+ writable: false,
967
+ });
968
+ (constructorArg as any).className = constructorArg.name;
969
+ Object.defineProperty(constructorArg, 'collection', {
970
+ get(this: unknown) {
971
+ return getCollection(this);
972
+ },
973
+ enumerable: false,
974
+ configurable: true,
975
+ });
976
+ Object.defineProperty(constructorArg.prototype, 'collection', {
977
+ get(this: unknown) {
978
+ return getCollection(this);
979
+ },
980
+ enumerable: false,
981
+ configurable: true,
982
+ });
983
+ return constructorArg;
984
+ };
985
+
986
+ /**
987
+ * Programmatically declares and binds an ordinary SmartData model.
988
+ *
989
+ * Repeating an equivalent declaration is safe. Divergent declarations for
990
+ * the same constructor, database and collection fail before database work.
991
+ */
992
+ export function defineCollectionModel<
993
+ TModel extends SmartDataDbDoc<any, any>,
994
+ >(
995
+ modelArg: TCollectionModelConstructor<TModel>,
996
+ dbArg: SmartdataDb | TDelayed<SmartdataDb>,
997
+ configArg: ICollectionModelConfig<TModel>,
998
+ ): TCollectionModelConstructor<TModel> {
999
+ if (
1000
+ typeof modelArg !== 'function' ||
1001
+ !(modelArg.prototype instanceof SmartDataDbDoc)
1002
+ ) {
1003
+ throw new SmartdataPersistenceError(
1004
+ 'invalid_configuration',
1005
+ 'defineCollectionModel requires a SmartDataDbDoc constructor.',
1006
+ );
1007
+ }
1008
+ const inheritedPrototype = Object.getPrototypeOf(modelArg.prototype) as
1009
+ | {
1010
+ saveableProperties?: string[];
1011
+ }
1012
+ | undefined;
1013
+ const inheritedConstructor = Object.getPrototypeOf(modelArg) as
1014
+ | {
1015
+ [definedModelSchemaSymbol]?: INormalizedCollectionModelSchema;
1016
+ }
1017
+ | undefined;
1018
+ const inheritedSchema = inheritedConstructor?.[definedModelSchemaSymbol];
1019
+ const normalizedSchema = normalizeCollectionModelSchema({
1020
+ ...configArg,
1021
+ persistedFields: [
1022
+ ...(inheritedPrototype?.saveableProperties || []),
1023
+ ...(configArg.persistedFields || []),
1024
+ ] as Array<keyof TModel & string>,
1025
+ numericFields: [
1026
+ ...(inheritedSchema?.numericFields || []),
1027
+ ...(configArg.numericFields || []),
1028
+ ] as Array<TSmartdataNumericDocumentPath<TModel>>,
1029
+ identityFields: [
1030
+ ...(inheritedSchema?.identityFields || []),
1031
+ ...(configArg.identityFields || []),
1032
+ ] as Array<keyof TModel & string>,
1033
+ searchableFields: [
1034
+ ...(inheritedSchema?.searchableFields || []),
1035
+ ...(configArg.searchableFields || []),
1036
+ ] as Array<keyof TModel & string>,
1037
+ indexes: [
1038
+ ...(inheritedSchema?.indexes || [])
1039
+ .filter((indexArg) => indexArg.name !== automaticTextIndexName)
1040
+ .map((indexArg) => ({
1041
+ name: indexArg.name,
1042
+ key: Object.fromEntries(indexArg.key),
1043
+ options: { ...indexArg.options },
1044
+ })),
1045
+ ...(configArg.indexes || []),
1046
+ ],
1047
+ }, false, undefined, getOrdinaryPersistencePolicy(modelArg));
1048
+ const existingOwnSchema = Object.prototype.hasOwnProperty.call(
1049
+ modelArg,
1050
+ definedModelSchemaSymbol,
1051
+ )
1052
+ ? ((modelArg as any)[
1053
+ definedModelSchemaSymbol
1054
+ ] as INormalizedCollectionModelSchema)
1055
+ : undefined;
1056
+ if (
1057
+ existingOwnSchema &&
1058
+ (existingOwnSchema.fingerprint !== normalizedSchema.fingerprint ||
1059
+ existingOwnSchema.ordinaryPersistence?.assertStoredDocument !==
1060
+ normalizedSchema.ordinaryPersistence?.assertStoredDocument)
1061
+ ) {
1062
+ throw new SmartdataPersistenceError(
1063
+ 'invalid_configuration',
1064
+ `Model "${modelArg.name}" already has a divergent collection schema.`,
1065
+ );
1066
+ }
1067
+ const effectiveSchema = existingOwnSchema || normalizedSchema;
1068
+ if (!existingOwnSchema) {
1069
+ Object.defineProperty(modelArg, definedModelSchemaSymbol, {
1070
+ value: effectiveSchema,
1071
+ enumerable: false,
1072
+ configurable: false,
1073
+ writable: false,
1074
+ });
1075
+ }
1076
+ modelArg.prototype.saveableProperties = [
1077
+ ...effectiveSchema.persistedFields,
1078
+ ];
1079
+ modelArg.prototype.uniqueIndexes = [...effectiveSchema.identityFields];
1080
+ modelArg.prototype.identityValueTypes = {
1081
+ ...effectiveSchema.identityValueTypes,
1082
+ };
1083
+ (modelArg as any).searchableFields = [
1084
+ ...effectiveSchema.searchableFields,
1085
+ ];
1086
+ return installCollectionBinding(
1087
+ modelArg,
1088
+ () => (dbArg instanceof SmartdataDb ? dbArg : dbArg()),
1089
+ () => effectiveSchema,
1090
+ );
1091
+ }
1092
+
1093
+ const validateCompoundIndexDefinition = (
1094
+ definitionArg: ICompoundIndexDefinition,
1095
+ ): ICompoundIndexDefinition => {
1096
+ if (
1097
+ typeof definitionArg?.name !== 'string' ||
1098
+ definitionArg.name.trim().length === 0
1099
+ ) {
1100
+ throw new SmartdataPersistenceError(
1101
+ 'invalid_configuration',
1102
+ 'A compound index requires a non-empty name.',
1103
+ );
1104
+ }
1105
+ if (
1106
+ typeof definitionArg.key !== 'object' ||
1107
+ definitionArg.key === null ||
1108
+ Array.isArray(definitionArg.key) ||
1109
+ Object.keys(definitionArg.key).length < 2
1110
+ ) {
1111
+ throw new SmartdataPersistenceError(
1112
+ 'invalid_configuration',
1113
+ `Compound index "${definitionArg.name}" requires at least two ordered fields.`,
1114
+ );
1115
+ }
1116
+ for (const [field, direction] of Object.entries(definitionArg.key)) {
1117
+ if (
1118
+ field.trim().length === 0 ||
1119
+ field.startsWith('$') ||
1120
+ field.includes('\0') ||
1121
+ (direction !== 1 && direction !== -1)
1122
+ ) {
1123
+ throw new SmartdataPersistenceError(
1124
+ 'invalid_configuration',
1125
+ `Compound index "${definitionArg.name}" has an invalid field or direction.`,
1126
+ );
1127
+ }
1128
+ }
1129
+ const options = { ...(definitionArg.options || {}) } as Record<string, unknown>;
1130
+ if ('name' in options) {
1131
+ throw new SmartdataPersistenceError(
1132
+ 'invalid_configuration',
1133
+ `Compound index "${definitionArg.name}" must define its name outside options.`,
1134
+ );
1135
+ }
1136
+ if ('unique' in options && typeof options.unique !== 'boolean') {
1137
+ throw new SmartdataPersistenceError(
1138
+ 'invalid_configuration',
1139
+ `Compound index "${definitionArg.name}" has a non-boolean unique option.`,
1140
+ );
1141
+ }
1142
+ if ('sparse' in options && typeof options.sparse !== 'boolean') {
1143
+ throw new SmartdataPersistenceError(
1144
+ 'invalid_configuration',
1145
+ `Compound index "${definitionArg.name}" has a non-boolean sparse option.`,
1146
+ );
1147
+ }
1148
+ if (
1149
+ 'expireAfterSeconds' in options &&
1150
+ (!Number.isFinite(options.expireAfterSeconds) ||
1151
+ (options.expireAfterSeconds as number) < 0)
1152
+ ) {
1153
+ throw new SmartdataPersistenceError(
1154
+ 'invalid_configuration',
1155
+ `Compound index "${definitionArg.name}" has an invalid expireAfterSeconds option.`,
1156
+ );
1157
+ }
1158
+ return {
1159
+ name: definitionArg.name,
1160
+ key: { ...definitionArg.key },
1161
+ options: { ...(definitionArg.options || {}) },
1162
+ };
1163
+ };
1164
+
1165
+ /**
1166
+ * Declares a stable named compound index owned by a SmartData model.
1167
+ */
1168
+ export function compoundIndex(definitionArg: ICompoundIndexDefinition) {
1169
+ const definition = validateCompoundIndexDefinition(definitionArg);
1170
+ return function classDecorator(value: Function, context: ClassDecoratorContext) {
1171
+ if (context.kind !== 'class') {
1172
+ throw new Error('compoundIndex can only decorate classes');
1173
+ }
1174
+ const metadata = context.metadata as ISmartdataDecoratorMetadata;
1175
+ if (
1176
+ !Object.prototype.hasOwnProperty.call(metadata, 'compoundIndexes')
1177
+ ) {
1178
+ metadata.compoundIndexes = [...(metadata.compoundIndexes || [])];
1179
+ }
1180
+ const ownCompoundIndexes = metadata.compoundIndexes!;
1181
+ const existing = ownCompoundIndexes.find(
1182
+ (indexDefinition) => indexDefinition.name === definition.name,
1183
+ );
1184
+ if (existing && !compoundIndexesEqual(existing, definition)) {
1185
+ throw new SmartdataPersistenceError(
1186
+ 'invalid_configuration',
1187
+ `Compound index "${definition.name}" has divergent declarations.`,
1188
+ );
1189
+ }
1190
+ if (!existing) {
1191
+ ownCompoundIndexes.push(definition);
1192
+ }
1193
+ initializeDecoratorMetadata(
1194
+ value as { new (...args: any[]): any; prototype: any },
1195
+ context.metadata,
1196
+ );
1197
+ return value as any;
1198
+ };
1199
+ }
1200
+
1201
+ /** Declares an inherited named index without implicitly declaring persisted fields. */
1202
+ export function namedIndex(definitionArg: INamedIndexDefinition) {
1203
+ const requirePlainDataObject = (valueArg: unknown, keysArg?: string[]) => {
1204
+ if (!valueArg || Object.getPrototypeOf(valueArg) !== Object.prototype ||
1205
+ Reflect.ownKeys(valueArg).some((keyArg) => typeof keyArg !== 'string' ||
1206
+ (keysArg && !keysArg.includes(keyArg)) ||
1207
+ !('value' in Object.getOwnPropertyDescriptor(valueArg, keyArg)!))) {
1208
+ throw new SmartdataPersistenceError('invalid_configuration', 'Invalid named index declaration.');
1209
+ }
1210
+ };
1211
+ requirePlainDataObject(definitionArg, ['name', 'key', 'options']);
1212
+ requirePlainDataObject(definitionArg.key);
1213
+ if (definitionArg.options !== undefined) requirePlainDataObject(definitionArg.options);
1214
+ const schema = normalizeCollectionModelSchema({
1215
+ collectionName: 'named_index_declaration',
1216
+ persistedFields: [...new Set(Object.keys(definitionArg.key).map((pathArg) => pathArg.split('.')[0]))],
1217
+ searchableFields: Object.entries(definitionArg.key)
1218
+ .filter(([, directionArg]) => directionArg === 'text').map(([pathArg]) => pathArg),
1219
+ indexes: [definitionArg],
1220
+ });
1221
+ // Share topology constraints too (reserved names, index widths, supported options).
1222
+ getExpectedCollectionTopologyForSchema(schema);
1223
+ const normalized = schema.indexes[0];
1224
+ const definition: INamedIndexDefinition = Object.freeze({
1225
+ name: normalized.name,
1226
+ key: Object.freeze(Object.fromEntries(normalized.key)),
1227
+ options: normalized.options,
1228
+ });
1229
+ return function classDecorator(value: Function, context: ClassDecoratorContext) {
1230
+ if (context.kind !== 'class') {
1231
+ throw new SmartdataPersistenceError('invalid_configuration', 'namedIndex can only decorate classes.');
1232
+ }
1233
+ const metadata = context.metadata as ISmartdataDecoratorMetadata;
1234
+ metadata.namedIndexes = mergeCompoundIndexes(metadata.namedIndexes, [definition]);
1235
+ return value as any;
1236
+ };
1237
+ }
1238
+
1239
+ /**
1240
+ * This is a decorator that will tell the decorated class what dbTable to use
1241
+ * @param dbArg
1242
+ */
1243
+ export function Collection(
1244
+ dbArg: SmartdataDb | TDelayed<SmartdataDb>,
1245
+ optionsArg?: ICollectionBindingOptions,
1246
+ ) {
1247
+ return function classDecorator(value: Function, context: ClassDecoratorContext) {
1248
+ if (context.kind !== 'class') {
1249
+ throw new Error('Collection can only decorate classes');
1250
+ }
1251
+
1252
+ const constructor = value as { new (...args: any[]): any } & { className?: string };
1253
+ initializeDecoratorMetadata(constructor, context.metadata);
1254
+ const collectionName = normalizeCollectionName(
1255
+ optionsArg?.collectionName ?? constructor.name,
1256
+ );
1257
+ return installCollectionBinding(
1258
+ constructor as TCollectionModelConstructor<any>,
1259
+ () => (dbArg instanceof SmartdataDb ? dbArg : dbArg()),
1260
+ () => {
1261
+ return schemaFromDecoratorMetadata(
1262
+ constructor as TCollectionModelConstructor<any>,
1263
+ collectionName,
1264
+ );
1265
+ },
1266
+ ) as any;
1267
+ };
1268
+ }
1269
+
1270
+ export interface IManager {
1271
+ db: SmartdataDb;
1272
+ }
1273
+
1274
+ export const setDefaultManagerForDoc = <T,>(managerArg: IManager, dbDocArg: T): T => {
1275
+ (dbDocArg as any).prototype.defaultManager = managerArg;
1276
+ return dbDocArg;
1277
+ };
1278
+
1279
+ /**
1280
+ * This is a decorator that will tell the decorated class what dbTable to use
1281
+ * @param dbArg
1282
+ */
1283
+ export function managed<TManager extends IManager>(
1284
+ managerOrOptionsArg?:
1285
+ | TManager
1286
+ | TDelayed<TManager>
1287
+ | ICollectionBindingOptions,
1288
+ optionsArg?: ICollectionBindingOptions,
1289
+ ) {
1290
+ const firstArgumentIsOptions =
1291
+ typeof managerOrOptionsArg === 'object' &&
1292
+ managerOrOptionsArg !== null &&
1293
+ !('db' in managerOrOptionsArg) &&
1294
+ 'collectionName' in managerOrOptionsArg &&
1295
+ optionsArg === undefined;
1296
+ const managerArg = firstArgumentIsOptions
1297
+ ? undefined
1298
+ : (managerOrOptionsArg as TManager | TDelayed<TManager> | undefined);
1299
+ const bindingOptions = firstArgumentIsOptions
1300
+ ? (managerOrOptionsArg as ICollectionBindingOptions)
1301
+ : optionsArg;
1302
+ return function classDecorator(value: Function, context: ClassDecoratorContext) {
1303
+ if (context.kind !== 'class') {
1304
+ throw new Error('managed can only decorate classes');
1305
+ }
1306
+
1307
+ const constructor = value as { new (...args: any[]): any } & { className?: string };
1308
+ initializeDecoratorMetadata(constructor, context.metadata);
1309
+
1310
+ // Resolution helpers (capture managerArg via closure)
1311
+ const getManager = (receiverArg?: unknown): TManager => {
1312
+ if (!managerArg) {
1313
+ if (typeof receiverArg === 'function') {
1314
+ return (receiverArg as typeof constructor).prototype.defaultManager;
1315
+ }
1316
+ return (
1317
+ (receiverArg as { defaultManager?: TManager } | undefined)
1318
+ ?.defaultManager ?? constructor.prototype.defaultManager
1319
+ );
1320
+ }
1321
+ if (typeof managerArg !== 'function' && managerArg.db) {
1322
+ return managerArg as TManager;
1323
+ }
1324
+ return (managerArg as TDelayed<TManager>)();
1325
+ };
1326
+
1327
+ const collectionName = normalizeCollectionName(
1328
+ bindingOptions?.collectionName ?? constructor.name,
1329
+ );
1330
+ installCollectionBinding(
1331
+ constructor as TCollectionModelConstructor<any>,
1332
+ (receiverArg) => getManager(receiverArg).db,
1333
+ () => {
1334
+ return schemaFromDecoratorMetadata(
1335
+ constructor as TCollectionModelConstructor<any>,
1336
+ collectionName,
1337
+ );
1338
+ },
1339
+ );
1340
+ Object.defineProperty(constructor, 'manager', {
1341
+ get(this: unknown) { return getManager(this); },
1342
+ enumerable: false,
1343
+ configurable: true
1344
+ });
1345
+
1346
+ Object.defineProperty(constructor.prototype, 'manager', {
1347
+ get(this: unknown) { return getManager(this); },
1348
+ enumerable: false,
1349
+ configurable: true
1350
+ });
1351
+
1352
+ return constructor as any;
1353
+ };
1354
+ }
1355
+
1356
+ /**
1357
+ * @dpecrecated use @managed instead
1358
+ */
1359
+ export const Manager = managed;
1360
+
1361
+ export class SmartdataCollection<T> {
1362
+ /**
1363
+ * the collection that is used
1364
+ */
1365
+ public mongoDbCollection!: plugins.mongodb.Collection;
1366
+ public objectValidation: IDocValidationFunc<T> | null = null;
1367
+ public collectionName: string;
1368
+ public smartdataDb: SmartdataDb;
1369
+ public uniqueIndexes: string[] = [];
1370
+ public regularIndexes: Array<{field: string, options: IIndexOptions}> = [];
1371
+ public compoundIndexes: ICompoundIndexDefinition[] = [];
1372
+ private modelSchema?: INormalizedCollectionModelSchema;
1373
+ private initializationPromise?: Promise<void>;
1374
+ private initializationGeneration = 0;
1375
+ private initialized = false;
1376
+
1377
+ constructor(classNameArg: string, smartDataDbArg: SmartdataDb) {
1378
+ // tell the collection where it belongs
1379
+ this.collectionName = classNameArg;
1380
+ this.smartdataDb = smartDataDbArg;
1381
+
1382
+ // tell the db class about it (important since Db uses different systems under the hood)
1383
+ this.smartdataDb.addCollection(this);
1384
+ }
1385
+
1386
+ private runWithOrdinarySession<TResult>(
1387
+ sessionArg: TSmartdataOrdinarySession | undefined,
1388
+ ordinaryWriteArg: boolean,
1389
+ operationArg: (
1390
+ rawSessionArg: plugins.mongodb.ClientSession | undefined,
1391
+ ) => Promise<TResult>,
1392
+ ): Promise<TResult> {
1393
+ return runWithOrdinarySmartdataSession(
1394
+ sessionArg,
1395
+ this.smartdataDb,
1396
+ {
1397
+ ordinaryWrite: ordinaryWriteArg,
1398
+ prepared: this.isInitializedForCurrentDatabase(),
1399
+ preparationMessage:
1400
+ `Initialize collection "${this.collectionName}" before using an owned SmartData session.`,
1401
+ },
1402
+ operationArg,
1403
+ );
1404
+ }
1405
+
1406
+ /**
1407
+ * Returns the deep-frozen normalized model schema bound to this collection,
1408
+ * or undefined when no model schema has been bound yet. The schema exposes
1409
+ * declared fields and named index definitions only, never raw driver access.
1410
+ */
1411
+ public getBoundModelSchema(): INormalizedCollectionModelSchema | undefined {
1412
+ return this.modelSchema;
1413
+ }
1414
+
1415
+ public bindModelSchema(schemaArg: INormalizedCollectionModelSchema): void {
1416
+ if (
1417
+ schemaArg.collectionName !== this.collectionName ||
1418
+ (this.modelSchema &&
1419
+ (this.modelSchema.fingerprint !== schemaArg.fingerprint ||
1420
+ this.modelSchema.ordinaryPersistence?.assertStoredDocument !==
1421
+ schemaArg.ordinaryPersistence?.assertStoredDocument))
1422
+ ) {
1423
+ throw new SmartdataPersistenceError(
1424
+ 'invalid_configuration',
1425
+ `Collection "${this.collectionName}" cannot bind a divergent model schema.`,
1426
+ );
1427
+ }
1428
+ if (!this.modelSchema) {
1429
+ this.modelSchema = schemaArg;
1430
+ this.initializationGeneration++;
1431
+ this.initialized = false;
1432
+ this.initializationPromise = undefined;
1433
+ }
1434
+ }
1435
+
1436
+ public resetForDatabaseReconnect(): void {
1437
+ this.initializationGeneration++;
1438
+ delete (this as { mongoDbCollection?: plugins.mongodb.Collection })
1439
+ .mongoDbCollection;
1440
+ this.initialized = false;
1441
+ this.initializationPromise = undefined;
1442
+ this.uniqueIndexes = [];
1443
+ this.regularIndexes = [];
1444
+ this.compoundIndexes = [];
1445
+ notifyCollectionReconnect(this);
1446
+ }
1447
+
1448
+ /**
1449
+ * makes sure a collection exists within MongoDb that maps to the SmartdataCollection
1450
+ */
1451
+ public async init(): Promise<void> {
1452
+ if (this.initialized) {
1453
+ return;
1454
+ }
1455
+ if (!this.initializationPromise) {
1456
+ const generation = this.initializationGeneration;
1457
+ const targetDb = this.smartdataDb.mongoDb;
1458
+ let initializationPromise!: Promise<void>;
1459
+ initializationPromise = this.initializeOnce(targetDb, generation)
1460
+ .then(() => {
1461
+ if (
1462
+ this.initializationGeneration === generation
1463
+ && this.smartdataDb.mongoDb === targetDb
1464
+ ) {
1465
+ this.initialized = true;
1466
+ }
1467
+ })
1468
+ .catch((errorArg) => {
1469
+ const isCurrentGeneration =
1470
+ this.initializationGeneration === generation
1471
+ && this.smartdataDb.mongoDb === targetDb;
1472
+ if (this.initializationPromise === initializationPromise) {
1473
+ this.initializationPromise = undefined;
1474
+ if (isCurrentGeneration) {
1475
+ this.initialized = false;
1476
+ }
1477
+ }
1478
+ if (isCurrentGeneration) {
1479
+ throw errorArg;
1480
+ }
1481
+ })
1482
+ .finally(() => {
1483
+ if (this.initializationPromise === initializationPromise) {
1484
+ this.initializationPromise = undefined;
1485
+ }
1486
+ });
1487
+ this.initializationPromise = initializationPromise;
1488
+ }
1489
+ const initializationPromise = this.initializationPromise;
1490
+ await initializationPromise;
1491
+ if (!this.initialized) {
1492
+ await this.init();
1493
+ }
1494
+ }
1495
+
1496
+ public async ensureInitialized(): Promise<void> {
1497
+ await this.init();
1498
+ }
1499
+
1500
+ /** Explicit bounded preparation of the bound model's exact declared topology. */
1501
+ public async prepareCollection(opts: ISmartdataCollectionPreparationOptions): Promise<void> {
1502
+ const budget = new CollectionPreparationBudget(opts);
1503
+ const generation = this.initializationGeneration;
1504
+ const targetDb = this.smartdataDb.mongoDb;
1505
+ const assertCurrent = () => {
1506
+ budget.check();
1507
+ if (this.initializationGeneration !== generation || this.smartdataDb.mongoDb !== targetDb ||
1508
+ this.smartdataDb.status !== 'connected') {
1509
+ throw new SmartdataPersistenceError('unsupported_operation',
1510
+ 'SmartData connection changed during collection preparation.');
1511
+ }
1512
+ };
1513
+ try {
1514
+ if (!this.modelSchema) {
1515
+ throw new SmartdataPersistenceError('invalid_configuration',
1516
+ 'Collection preparation requires a bound model schema.');
1517
+ }
1518
+ // The caller owns its waiting deadline, not the preceding operation.
1519
+ while (this.initializationPromise) {
1520
+ await budget.waitFor(this.initializationPromise);
1521
+ assertCurrent();
1522
+ }
1523
+ assertCurrent();
1524
+ this.initialized = false;
1525
+ const schema = this.modelSchema;
1526
+ const preparation = (async () => {
1527
+ const outcome = await this.smartdataDb[collectionTopologyDbInspectionSymbol](async (dbArg) => {
1528
+ try {
1529
+ return { collection: await prepareBoundCollection(dbArg, schema, budget, assertCurrent) };
1530
+ } catch (errorArg) {
1531
+ // Let the connection owner check its epoch before publishing either outcome.
1532
+ return { error: errorArg };
1533
+ }
1534
+ });
1535
+ assertCurrent();
1536
+ if ('error' in outcome) throw outcome.error;
1537
+ this.mongoDbCollection = outcome.collection;
1538
+ this.publishModelIndexes(schema);
1539
+ this.initialized = true;
1540
+ })();
1541
+ this.initializationPromise = preparation;
1542
+ try {
1543
+ await preparation;
1544
+ } finally {
1545
+ if (this.initializationPromise === preparation) this.initializationPromise = undefined;
1546
+ }
1547
+ } catch (errorArg) {
1548
+ budget.check();
1549
+ throw errorArg;
1550
+ } finally {
1551
+ budget.close();
1552
+ }
1553
+ }
1554
+
1555
+ public isInitializedForCurrentDatabase(): boolean {
1556
+ return this.initialized && Boolean(this.mongoDbCollection);
1557
+ }
1558
+
1559
+ private async initializeOnce(
1560
+ targetDbArg: plugins.mongodb.Db,
1561
+ generationArg: number,
1562
+ ): Promise<void> {
1563
+ let targetCollection = this.mongoDbCollection;
1564
+ if (!targetCollection) {
1565
+ const wantedCollection = await targetDbArg
1566
+ .listCollections(
1567
+ { name: this.collectionName },
1568
+ { nameOnly: true },
1569
+ )
1570
+ .hasNext();
1571
+ if (!wantedCollection) {
1572
+ try {
1573
+ await targetDbArg.createCollection(
1574
+ this.collectionName,
1575
+ );
1576
+ } catch (errorArg) {
1577
+ const mongoError = errorArg as { code?: unknown };
1578
+ if (mongoError.code !== 48) {
1579
+ throw errorArg;
1580
+ }
1581
+ }
1582
+ logger.log('info', `Successfully initiated Collection ${this.collectionName}`);
1583
+ }
1584
+ targetCollection = targetDbArg.collection(this.collectionName);
1585
+ }
1586
+ if (
1587
+ this.initializationGeneration !== generationArg
1588
+ || this.smartdataDb.mongoDb !== targetDbArg
1589
+ ) {
1590
+ return;
1591
+ }
1592
+ this.mongoDbCollection = targetCollection;
1593
+ const modelSchema = this.modelSchema;
1594
+ if (!modelSchema) {
1595
+ return;
1596
+ }
1597
+ for (const index of modelSchema.indexes) {
1598
+ await targetCollection.createIndex(
1599
+ Object.fromEntries(index.key) as plugins.mongodb.IndexSpecification,
1600
+ {
1601
+ ...index.options,
1602
+ name: index.name,
1603
+ },
1604
+ );
1605
+ }
1606
+ const actualIndexes = await targetCollection.listIndexes().toArray();
1607
+ for (const expected of modelSchema.indexes) {
1608
+ const actual = actualIndexes.find(
1609
+ (indexArg) => indexArg.name === expected.name,
1610
+ );
1611
+ if (!actual) {
1612
+ throw new SmartdataPersistenceError(
1613
+ 'invalid_configuration',
1614
+ `Index "${expected.name}" was not created on "${this.collectionName}".`,
1615
+ );
1616
+ }
1617
+ const actualKeyEntries = Object.entries(actual.key || {});
1618
+ const expectedTextFields = expected.key
1619
+ .filter(([, directionArg]) => directionArg === 'text')
1620
+ .map(([fieldArg]) => fieldArg);
1621
+ const canonicalExpectedKey: Array<
1622
+ readonly [string, TCollectionModelIndexDirection]
1623
+ > = [];
1624
+ let textMarkerAdded = false;
1625
+ for (const [field, direction] of expected.key) {
1626
+ if (direction === 'text') {
1627
+ if (!textMarkerAdded) {
1628
+ canonicalExpectedKey.push(['_fts', 'text'], ['_ftsx', 1]);
1629
+ textMarkerAdded = true;
1630
+ }
1631
+ } else {
1632
+ canonicalExpectedKey.push([field, direction]);
1633
+ }
1634
+ }
1635
+ const actualTextFields = Object.keys(actual.weights || {}).sort(
1636
+ compareSmartdataTopologyStrings,
1637
+ );
1638
+ if (
1639
+ stableValue(actualKeyEntries) !==
1640
+ stableValue(canonicalExpectedKey) ||
1641
+ stableValue(actualTextFields) !==
1642
+ stableValue(
1643
+ [...expectedTextFields].sort(compareSmartdataTopologyStrings),
1644
+ )
1645
+ ) {
1646
+ throw new SmartdataPersistenceError(
1647
+ 'invalid_configuration',
1648
+ `Index "${expected.name}" has an unexpected key specification.`,
1649
+ );
1650
+ }
1651
+ for (const booleanOption of ['unique', 'sparse'] as const) {
1652
+ if (
1653
+ Boolean(actual[booleanOption]) !==
1654
+ Boolean(expected.options[booleanOption])
1655
+ ) {
1656
+ throw new SmartdataPersistenceError(
1657
+ 'invalid_configuration',
1658
+ `Index "${expected.name}" has an unexpected "${booleanOption}" option.`,
1659
+ );
1660
+ }
1661
+ }
1662
+ if (
1663
+ expected.options.expireAfterSeconds !== undefined &&
1664
+ actual.expireAfterSeconds !==
1665
+ expected.options.expireAfterSeconds
1666
+ ) {
1667
+ throw new SmartdataPersistenceError(
1668
+ 'invalid_configuration',
1669
+ `Index "${expected.name}" has an unexpected expireAfterSeconds option.`,
1670
+ );
1671
+ }
1672
+ }
1673
+ if (
1674
+ this.initializationGeneration !== generationArg
1675
+ || this.smartdataDb.mongoDb !== targetDbArg
1676
+ || this.mongoDbCollection !== targetCollection
1677
+ ) {
1678
+ return;
1679
+ }
1680
+ this.publishModelIndexes(modelSchema);
1681
+ }
1682
+
1683
+ private publishModelIndexes(modelSchema: INormalizedCollectionModelSchema): void {
1684
+ this.uniqueIndexes = modelSchema.indexes
1685
+ .filter((indexArg) => indexArg.options.unique)
1686
+ .filter(
1687
+ (indexArg) =>
1688
+ indexArg.key.length === 1 && indexArg.key[0][1] === 1,
1689
+ )
1690
+ .map((indexArg) => indexArg.key[0][0]);
1691
+ this.regularIndexes = modelSchema.indexes
1692
+ .filter(
1693
+ (indexArg) =>
1694
+ indexArg.key.length === 1 &&
1695
+ indexArg.key[0][1] === 1,
1696
+ )
1697
+ .map((indexArg) => ({
1698
+ field: indexArg.key[0][0],
1699
+ options: { ...indexArg.options },
1700
+ }));
1701
+ this.compoundIndexes = modelSchema.indexes
1702
+ .filter(
1703
+ (indexArg) =>
1704
+ indexArg.key.length >= 2 &&
1705
+ indexArg.key.every(([, directionArg]) => directionArg !== 'text'),
1706
+ )
1707
+ .map((indexArg) => ({
1708
+ name: indexArg.name,
1709
+ key: Object.fromEntries(indexArg.key) as Record<
1710
+ string,
1711
+ TCompoundIndexDirection
1712
+ >,
1713
+ options: { ...indexArg.options },
1714
+ }));
1715
+ }
1716
+
1717
+ /**
1718
+ * mark unique index
1719
+ */
1720
+ public async markUniqueIndexes(keyArrayArg: string[] = []) {
1721
+ for (const key of keyArrayArg) {
1722
+ if (!this.uniqueIndexes.includes(key)) {
1723
+ try {
1724
+ await this.mongoDbCollection.createIndex({ [key]: 1 }, {
1725
+ unique: true,
1726
+ name: `${key}_1`,
1727
+ });
1728
+ this.uniqueIndexes.push(key);
1729
+ } catch (err: any) {
1730
+ const errorCode = err?.code || err?.codeName || 'unknown';
1731
+ const errorMessage = err?.message || String(err);
1732
+ logger.log(
1733
+ 'error',
1734
+ `Failed to create unique index on field "${key}" in collection "${this.collectionName}". ` +
1735
+ `MongoDB error [${errorCode}]: ${errorMessage}. ` +
1736
+ `Uniqueness constraint on "${key}" is NOT enforced.`
1737
+ );
1738
+ if (errorCode === 11000 || errorCode === 'DuplicateKey' || String(errorMessage).includes('E11000')) {
1739
+ await this.logDuplicatesForField(key);
1740
+ }
1741
+ throw err;
1742
+ }
1743
+ }
1744
+ }
1745
+ }
1746
+
1747
+ /**
1748
+ * creates regular indexes for the collection
1749
+ */
1750
+ public async createRegularIndexes(indexesArg: Array<{field: string, options: IIndexOptions}> = []) {
1751
+ for (const indexDef of indexesArg) {
1752
+ // Check if we've already created this index
1753
+ const indexKey = indexDef.field;
1754
+ if (!this.regularIndexes.some(i => i.field === indexKey)) {
1755
+ try {
1756
+ await this.mongoDbCollection.createIndex(
1757
+ { [indexDef.field]: 1 }, // Simple single-field index
1758
+ indexDef.options
1759
+ );
1760
+ this.regularIndexes.push(indexDef);
1761
+ } catch (err: any) {
1762
+ const errorCode = err?.code || err?.codeName || 'unknown';
1763
+ const errorMessage = err?.message || String(err);
1764
+ logger.log(
1765
+ 'warn',
1766
+ `Failed to create index on field "${indexKey}" in collection "${this.collectionName}". ` +
1767
+ `MongoDB error [${errorCode}]: ${errorMessage}.`
1768
+ );
1769
+ if (
1770
+ indexDef.options?.unique &&
1771
+ (errorCode === 11000 || errorCode === 'DuplicateKey' || String(errorMessage).includes('E11000'))
1772
+ ) {
1773
+ await this.logDuplicatesForField(indexKey);
1774
+ }
1775
+ throw err;
1776
+ }
1777
+ }
1778
+ }
1779
+ }
1780
+
1781
+ /**
1782
+ * Logs duplicate values for a field to help diagnose unique index creation failures.
1783
+ */
1784
+ private async logDuplicatesForField(field: string): Promise<void> {
1785
+ try {
1786
+ const pipeline = [
1787
+ { $group: { _id: `$${field}`, count: { $sum: 1 }, ids: { $push: '$_id' } } },
1788
+ { $match: { count: { $gt: 1 } } },
1789
+ { $limit: 5 },
1790
+ ];
1791
+ const duplicates = await this.mongoDbCollection.aggregate(pipeline).toArray();
1792
+ if (duplicates.length > 0) {
1793
+ for (const dup of duplicates) {
1794
+ logger.log(
1795
+ 'warn',
1796
+ `Duplicate values for "${field}" in "${this.collectionName}": ` +
1797
+ `value=${JSON.stringify(dup._id)} appears ${dup.count} times ` +
1798
+ `(document _ids: ${JSON.stringify(dup.ids.slice(0, 5))})`
1799
+ );
1800
+ }
1801
+ logger.log(
1802
+ 'warn',
1803
+ `Unique index on "${field}" in "${this.collectionName}" was NOT created. ` +
1804
+ `Resolve duplicates and restart to enforce uniqueness.`
1805
+ );
1806
+ }
1807
+ } catch (aggErr: any) {
1808
+ logger.log(
1809
+ 'warn',
1810
+ `Could not identify duplicate documents for field "${field}" in "${this.collectionName}": ${aggErr?.message || String(aggErr)}`
1811
+ );
1812
+ }
1813
+ }
1814
+
1815
+ /**
1816
+ * adds a validation function that all newly inserted and updated objects have to pass
1817
+ */
1818
+ public addDocValidation(funcArg: IDocValidationFunc<T>) {
1819
+ this.objectValidation = funcArg;
1820
+ }
1821
+
1822
+ /**
1823
+ * finds an object in the DbCollection
1824
+ */
1825
+ public async findOne(
1826
+ filterObject: any,
1827
+ opts?: {
1828
+ projection?: plugins.mongodb.Document;
1829
+ maxTimeMS?: number;
1830
+ session?: TSmartdataOrdinarySession;
1831
+ },
1832
+ ): Promise<any> {
1833
+ return this.runWithOrdinarySession(
1834
+ opts?.session,
1835
+ false,
1836
+ async (rawSessionArg) => {
1837
+ await this.init();
1838
+ return this.mongoDbCollection.findOne(filterObject, {
1839
+ projection: opts?.projection,
1840
+ maxTimeMS: opts?.maxTimeMS,
1841
+ session: rawSessionArg,
1842
+ });
1843
+ },
1844
+ );
1845
+ }
1846
+
1847
+ public async getCursor(
1848
+ filterObjectArg: any,
1849
+ dbDocArg: typeof SmartDataDbDoc,
1850
+ opts?: { session?: plugins.mongodb.ClientSession }
1851
+ ): Promise<SmartdataDbCursor<any>> {
1852
+ await this.init();
1853
+ const cursor = this.mongoDbCollection.find(filterObjectArg, { session: opts?.session });
1854
+ return new SmartdataDbCursor(cursor, dbDocArg);
1855
+ }
1856
+
1857
+ /**
1858
+ * finds an object in the DbCollection
1859
+ */
1860
+ public async findAll(
1861
+ filterObject: any,
1862
+ opts?: { session?: TSmartdataOrdinarySession }
1863
+ ): Promise<any[]> {
1864
+ return this.runWithOrdinarySession(
1865
+ opts?.session,
1866
+ false,
1867
+ async (rawSessionArg) => {
1868
+ await this.init();
1869
+ const cursor = this.mongoDbCollection.find(filterObject, {
1870
+ session: rawSessionArg,
1871
+ });
1872
+ let result!: any[];
1873
+ let operationFailed = false;
1874
+ try {
1875
+ result = await cursor.toArray();
1876
+ } catch (errorArg) {
1877
+ operationFailed = true;
1878
+ throw errorArg;
1879
+ } finally {
1880
+ try {
1881
+ await cursor.close();
1882
+ } catch (closeError) {
1883
+ if (!operationFailed) {
1884
+ throw closeError;
1885
+ }
1886
+ }
1887
+ }
1888
+
1889
+ // In-memory check for duplicate _id values (should never happen)
1890
+ if (result.length > 0) {
1891
+ const idSet = new Set<string>();
1892
+ const duplicateIds: string[] = [];
1893
+ for (const doc of result) {
1894
+ const idStr = String(doc._id);
1895
+ if (idSet.has(idStr)) {
1896
+ duplicateIds.push(idStr);
1897
+ } else {
1898
+ idSet.add(idStr);
1899
+ }
1900
+ }
1901
+ if (duplicateIds.length > 0) {
1902
+ logger.log(
1903
+ 'error',
1904
+ `Integrity issue in "${this.collectionName}": found ${duplicateIds.length} duplicate _id values ` +
1905
+ `in findAll results: [${duplicateIds.slice(0, 5).join(', ')}]. This should never happen.`
1906
+ );
1907
+ }
1908
+ }
1909
+
1910
+ return result;
1911
+ },
1912
+ );
1913
+ }
1914
+
1915
+ /**
1916
+ * Watches the collection, returning a SmartdataDbWatcher with RxJS and EventEmitter support.
1917
+ * @param filterObject match filter for change stream
1918
+ * @param opts optional MongoDB ChangeStreamOptions & { bufferTimeMs } to buffer events
1919
+ * @param smartdataDbDocArg document class for instance creation
1920
+ */
1921
+ public async watch(
1922
+ filterObject: any,
1923
+ opts: (plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number }) = {},
1924
+ smartdataDbDocArg?: typeof SmartDataDbDoc,
1925
+ ): Promise<SmartdataDbWatcher> {
1926
+ if ('capabilities' in this.smartdataDb &&
1927
+ (this.smartdataDb.capabilities as { changeStreams?: string }).changeStreams === 'unsupported') {
1928
+ throw new LosslessClientError('unsupported_capability', 'Change streams are unsupported by this backend.');
1929
+ }
1930
+ await this.init();
1931
+ // Extract bufferTimeMs from options
1932
+ const { bufferTimeMs, fullDocument, ...otherOptions } = opts || {};
1933
+ // Determine fullDocument behavior: default to 'updateLookup'
1934
+ const changeStreamOptions: plugins.mongodb.ChangeStreamOptions = {
1935
+ ...otherOptions,
1936
+ fullDocument:
1937
+ fullDocument === undefined
1938
+ ? 'updateLookup'
1939
+ : (fullDocument as any) === true
1940
+ ? 'updateLookup'
1941
+ : fullDocument,
1942
+ } as any;
1943
+ // Build pipeline with match if provided
1944
+ const pipeline = filterObject ? [{ $match: filterObject }] : [];
1945
+ const changeStream = this.mongoDbCollection.watch(
1946
+ pipeline,
1947
+ changeStreamOptions,
1948
+ );
1949
+ const smartdataWatcher = new SmartdataDbWatcher(
1950
+ changeStream,
1951
+ smartdataDbDocArg!,
1952
+ { bufferTimeMs },
1953
+ );
1954
+ await smartdataWatcher.readyDeferred.promise;
1955
+ return smartdataWatcher;
1956
+ }
1957
+
1958
+ /**
1959
+ * create an object in the database
1960
+ */
1961
+ private prepareOrdinaryInsert(documentArg: any): any {
1962
+ const policy = this.modelSchema?.ordinaryPersistence;
1963
+ if (!policy) return documentArg;
1964
+ if (policy.idType === 'objectId' && !Object.prototype.hasOwnProperty.call(documentArg, '_id')) {
1965
+ documentArg = { ...documentArg, _id: new plugins.mongodb.ObjectId() };
1966
+ }
1967
+ return validateOrdinaryStoredDocument(documentArg, policy, this.modelSchema!.persistedFields);
1968
+ }
1969
+
1970
+ public async insert(
1971
+ dbDocArg: T & SmartDataDbDoc<T, unknown>,
1972
+ opts?: { session?: TSmartdataOrdinarySession }
1973
+ ): Promise<any> {
1974
+ const preparedObject = this.modelSchema?.ordinaryPersistence
1975
+ ? this.prepareOrdinaryInsert(await dbDocArg.createSavableObject()) : undefined;
1976
+ return this.runWithOrdinarySession(
1977
+ opts?.session,
1978
+ true,
1979
+ async (rawSessionArg) => {
1980
+ await this.init();
1981
+ await this.checkDoc(dbDocArg);
1982
+ await this.markUniqueIndexes(dbDocArg.uniqueIndexes);
1983
+
1984
+ // Create regular indexes if available
1985
+ if (dbDocArg.regularIndexes && dbDocArg.regularIndexes.length > 0) {
1986
+ await this.createRegularIndexes(dbDocArg.regularIndexes);
1987
+ }
1988
+
1989
+ const saveableObject = preparedObject ?? await dbDocArg.createSavableObject();
1990
+ try {
1991
+ return await this.mongoDbCollection.insertOne(saveableObject, {
1992
+ session: rawSessionArg,
1993
+ });
1994
+ } catch (err: any) {
1995
+ return normalizeOrdinaryPersistenceError(
1996
+ err,
1997
+ `Insert conflicts with a unique index in collection "${this.collectionName}".`,
1998
+ );
1999
+ }
2000
+ },
2001
+ );
2002
+ }
2003
+
2004
+ /**
2005
+ * Executes a bounded grouped-totals aggregation over already-validated
2006
+ * stages and never exposes the aggregation cursor. Prefer the validated
2007
+ * static `SmartDataDbDoc.getGroupedTotals()`, which owns input validation
2008
+ * before this raw persistence boundary.
2009
+ */
2010
+ public async aggregateGroupedTotals(
2011
+ matchArg: plugins.mongodb.Filter<plugins.mongodb.Document> | undefined,
2012
+ groupArg: plugins.mongodb.Document,
2013
+ limitArg: number,
2014
+ opts?: { session?: TSmartdataOrdinarySession; maxTimeMS?: number; timeoutMS?: number; signal?: AbortSignal }
2015
+ ): Promise<plugins.mongodb.Document[]> {
2016
+ opts?.signal?.throwIfAborted();
2017
+ return this.runWithOrdinarySession(
2018
+ opts?.session,
2019
+ false,
2020
+ async (rawSessionArg) => {
2021
+ await this.init();
2022
+ const pipeline: plugins.mongodb.Document[] = [];
2023
+ if (matchArg && Object.keys(matchArg).length > 0) {
2024
+ pipeline.push({ $match: matchArg });
2025
+ }
2026
+ pipeline.push({ $group: groupArg });
2027
+ pipeline.push({ $limit: limitArg });
2028
+ opts?.signal?.throwIfAborted();
2029
+ const cursor = this.mongoDbCollection.aggregate(pipeline, {
2030
+ session: rawSessionArg,
2031
+ ...(opts?.timeoutMS !== undefined ? { timeoutMS: opts.timeoutMS, timeoutMode: 'cursorLifetime' } : {}),
2032
+ signal: opts?.signal,
2033
+ ...(opts?.maxTimeMS !== undefined
2034
+ ? { maxTimeMS: opts.maxTimeMS }
2035
+ : {}),
2036
+ });
2037
+ return runCursorOperation(cursor, (cursorArg) => cursorArg.toArray(), opts?.signal);
2038
+ },
2039
+ );
2040
+ }
2041
+
2042
+ public async insertMany(
2043
+ dbDocsArg: Array<T & SmartDataDbDoc<T, unknown>>,
2044
+ opts?: { session?: TSmartdataOrdinarySession; ordered?: boolean }
2045
+ ): Promise<plugins.mongodb.InsertManyResult> {
2046
+ if (!Array.isArray(dbDocsArg) || dbDocsArg.length === 0) {
2047
+ throw new SmartdataPersistenceError(
2048
+ 'invalid_argument',
2049
+ `insertMany requires a non-empty document batch for collection "${this.collectionName}".`,
2050
+ );
2051
+ }
2052
+ const preparedObjects: any[] | undefined = this.modelSchema?.ordinaryPersistence ? [] : undefined;
2053
+ if (preparedObjects) {
2054
+ for (const dbDocArg of dbDocsArg) {
2055
+ preparedObjects.push(this.prepareOrdinaryInsert(await dbDocArg.createSavableObject()));
2056
+ }
2057
+ }
2058
+ return this.runWithOrdinarySession(
2059
+ opts?.session,
2060
+ true,
2061
+ async (rawSessionArg) => {
2062
+ await this.init();
2063
+ for (const dbDocArg of dbDocsArg) {
2064
+ await this.checkDoc(dbDocArg);
2065
+ }
2066
+ const firstDocument = dbDocsArg[0];
2067
+ await this.markUniqueIndexes(firstDocument.uniqueIndexes);
2068
+ if (
2069
+ firstDocument.regularIndexes
2070
+ && firstDocument.regularIndexes.length > 0
2071
+ ) {
2072
+ await this.createRegularIndexes(firstDocument.regularIndexes);
2073
+ }
2074
+ const saveableObjects: any[] = preparedObjects ?? [];
2075
+ if (!preparedObjects) {
2076
+ for (const dbDocArg of dbDocsArg) {
2077
+ saveableObjects.push(await dbDocArg.createSavableObject());
2078
+ }
2079
+ }
2080
+ try {
2081
+ return await this.mongoDbCollection.insertMany(saveableObjects, {
2082
+ session: rawSessionArg,
2083
+ ordered: opts?.ordered !== false,
2084
+ });
2085
+ } catch (err: any) {
2086
+ return normalizeOrdinaryPersistenceError(
2087
+ err,
2088
+ `Insert conflicts with a unique index in collection "${this.collectionName}".`,
2089
+ );
2090
+ }
2091
+ },
2092
+ );
2093
+ }
2094
+
2095
+ /** Model.insertManyIfAbsent owns identity and new-instance validation. */
2096
+ public async insertManyIfAbsent(
2097
+ documentsArg: Array<T & SmartDataDbDoc<T, unknown>>,
2098
+ identityFieldArg: string,
2099
+ optsArg: { session?: TSmartdataOrdinarySession; timeoutMS?: number },
2100
+ ): Promise<{ insertedCount: number; matchedCount: number }> {
2101
+ const prepared: plugins.mongodb.Document[] = [];
2102
+ let bytes = 0;
2103
+ // Validate the entire batch before any write, including opted-in whole
2104
+ // stored-document policies and collection-level object validation.
2105
+ for (const document of documentsArg) {
2106
+ await this.checkDoc(document);
2107
+ const stored = this.prepareOrdinaryInsert(await document.createSavableObject());
2108
+ const bson = plugins.mongodb.BSON.serialize(stored);
2109
+ bytes += bson.byteLength;
2110
+ if (bytes > 16 * 1024 * 1024) {
2111
+ throw new SmartdataPersistenceError('invalid_argument',
2112
+ 'insertManyIfAbsent accepts at most 16 MiB of serialized documents.');
2113
+ }
2114
+ prepared.push(plugins.mongodb.BSON.deserialize(bson));
2115
+ }
2116
+ return this.runWithOrdinarySession(optsArg.session, true, async (rawSessionArg) => {
2117
+ await this.init();
2118
+ const first = documentsArg[0];
2119
+ await this.markUniqueIndexes(first.uniqueIndexes);
2120
+ await this.createRegularIndexes(first.regularIndexes || []);
2121
+ try {
2122
+ const result = await this.mongoDbCollection.bulkWrite(prepared.map((document) => ({
2123
+ updateOne: {
2124
+ filter: { [identityFieldArg]: document[identityFieldArg] },
2125
+ update: { $setOnInsert: document },
2126
+ upsert: true,
2127
+ },
2128
+ })), { ordered: false, session: rawSessionArg, timeoutMS: optsArg.timeoutMS });
2129
+ if (result.upsertedCount + result.matchedCount !== prepared.length) {
2130
+ throw new SmartdataPersistenceError('unsupported_operation',
2131
+ 'insertManyIfAbsent requires an acknowledged result for every identity.');
2132
+ }
2133
+ return { insertedCount: result.upsertedCount, matchedCount: result.matchedCount };
2134
+ } catch (errorArg) {
2135
+ return normalizeOrdinaryPersistenceError(errorArg,
2136
+ `Insert conflicts with a unique index in collection "${this.collectionName}".`);
2137
+ }
2138
+ });
2139
+ }
2140
+
2141
+ /**
2142
+ * inserts object into the DbCollection
2143
+ */
2144
+ public async update(
2145
+ dbDocArg: T & SmartDataDbDoc<T, unknown>,
2146
+ opts?: { session?: TSmartdataOrdinarySession }
2147
+ ): Promise<any> {
2148
+ if (this.modelSchema?.ordinaryPersistence) {
2149
+ throw new SmartdataPersistenceError('unsupported_operation',
2150
+ 'Validated ordinary models require a transactional postimage update.');
2151
+ }
2152
+ return this.runWithOrdinarySession(
2153
+ opts?.session,
2154
+ true,
2155
+ async (rawSessionArg) => {
2156
+ await this.init();
2157
+ await this.checkDoc(dbDocArg);
2158
+ await this.markUniqueIndexes(dbDocArg.uniqueIndexes);
2159
+ if (dbDocArg.regularIndexes && dbDocArg.regularIndexes.length > 0) {
2160
+ await this.createRegularIndexes(dbDocArg.regularIndexes);
2161
+ }
2162
+ const identifiableObject = await dbDocArg.createIdentifiableObject();
2163
+ this.assertConstrainingIdentifiableObject(identifiableObject, 'update');
2164
+ const saveableObject = await dbDocArg.createSavableObject() as any;
2165
+ const updateableObject: any = {};
2166
+ for (const key of Object.keys(saveableObject)) {
2167
+ if (identifiableObject[key]) {
2168
+ continue;
2169
+ }
2170
+ updateableObject[key] = saveableObject[key];
2171
+ }
2172
+ try {
2173
+ return await this.mongoDbCollection.updateOne(
2174
+ identifiableObject,
2175
+ { $set: updateableObject },
2176
+ { upsert: true, session: rawSessionArg },
2177
+ );
2178
+ } catch (errorArg) {
2179
+ return normalizeOrdinaryPersistenceError(
2180
+ errorArg,
2181
+ `Update conflicts with a unique index in collection "${this.collectionName}".`,
2182
+ );
2183
+ }
2184
+ },
2185
+ );
2186
+ }
2187
+
2188
+ public async delete(
2189
+ dbDocArg: T & SmartDataDbDoc<T, unknown>,
2190
+ opts?: { session?: TSmartdataOrdinarySession }
2191
+ ): Promise<any> {
2192
+ return this.runWithOrdinarySession(
2193
+ opts?.session,
2194
+ true,
2195
+ async (rawSessionArg) => {
2196
+ await this.init();
2197
+ await this.checkDoc(dbDocArg);
2198
+ const identifiableObject = await dbDocArg.createIdentifiableObject();
2199
+ this.assertConstrainingIdentifiableObject(identifiableObject, 'delete');
2200
+ return this.mongoDbCollection.deleteOne(identifiableObject, {
2201
+ session: rawSessionArg,
2202
+ });
2203
+ },
2204
+ );
2205
+ }
2206
+
2207
+ /**
2208
+ * Rejects instance-level document targeting when the model declares no
2209
+ * identity fields. An empty identity filter would otherwise address an
2210
+ * arbitrary document in the collection.
2211
+ */
2212
+ private assertConstrainingIdentifiableObject(
2213
+ identifiableObjectArg: Record<string, unknown>,
2214
+ operationLabelArg: string,
2215
+ ): void {
2216
+ if (Object.keys(identifiableObjectArg).length === 0) {
2217
+ throw new SmartdataPersistenceError(
2218
+ 'invalid_configuration',
2219
+ `Instance ${operationLabelArg} on collection "${this.collectionName}" requires declared identity fields; without them the operation would target an arbitrary document.`,
2220
+ );
2221
+ }
2222
+ }
2223
+
2224
+ public async getCount(
2225
+ filterObject: any,
2226
+ opts?: {
2227
+ limit?: number;
2228
+ hint?: string;
2229
+ maxTimeMS?: number;
2230
+ timeoutMS?: number;
2231
+ signal?: AbortSignal;
2232
+ session?: TSmartdataOrdinarySession;
2233
+ },
2234
+ ) {
2235
+ if (opts?.hint !== undefined && (
2236
+ typeof opts.hint !== 'string' ||
2237
+ (opts.hint !== '_id_' && !this.modelSchema?.indexes.some(
2238
+ (indexArg) => indexArg.name === opts.hint,
2239
+ ))
2240
+ )) {
2241
+ throw new SmartdataPersistenceError(
2242
+ 'invalid_argument',
2243
+ 'Query hint must name an index declared by this model or the built-in _id_ index.',
2244
+ );
2245
+ }
2246
+ return this.runWithOrdinarySession(
2247
+ opts?.session,
2248
+ false,
2249
+ async (rawSessionArg) => {
2250
+ await this.init();
2251
+ opts?.signal?.throwIfAborted();
2252
+ const pipeline: plugins.mongodb.Document[] = [{ $match: filterObject }];
2253
+ if (opts?.limit !== undefined) {
2254
+ pipeline.push({ $limit: opts.limit });
2255
+ }
2256
+ pipeline.push({ $count: 'count' });
2257
+ const cursor = this.mongoDbCollection.aggregate<{ count: number }>(pipeline, {
2258
+ hint: opts?.hint,
2259
+ maxTimeMS: opts?.maxTimeMS,
2260
+ timeoutMS: opts?.timeoutMS,
2261
+ signal: opts?.signal,
2262
+ session: rawSessionArg,
2263
+ });
2264
+ return runCursorOperation(cursor, async (cursorArg) =>
2265
+ (await cursorArg.next())?.count ?? 0,
2266
+ opts?.signal);
2267
+ },
2268
+ );
2269
+ }
2270
+
2271
+ public async getIndexInfo(): Promise<ISmartdataIndexInfo[]> {
2272
+ await this.init();
2273
+ const indexes = await this.mongoDbCollection.listIndexes().toArray();
2274
+ return indexes.map((indexArg) => {
2275
+ let fields: ISmartdataIndexInfo['fields'];
2276
+ if (indexArg.key._fts === 'text') {
2277
+ const weights =
2278
+ typeof indexArg.weights === 'object' &&
2279
+ indexArg.weights !== null &&
2280
+ !Array.isArray(indexArg.weights)
2281
+ ? indexArg.weights
2282
+ : {};
2283
+ fields = Object.keys(weights).map((path) => ({
2284
+ path,
2285
+ direction: 'text' as const,
2286
+ }));
2287
+ } else {
2288
+ fields = Object.entries(indexArg.key).map(([path, direction]) => {
2289
+ if (direction !== 1 && direction !== -1) {
2290
+ throw new SmartdataPersistenceError(
2291
+ 'unsupported_operation',
2292
+ `Index "${indexArg.name}" uses an unsupported direction.`,
2293
+ );
2294
+ }
2295
+ return {
2296
+ path,
2297
+ direction,
2298
+ };
2299
+ });
2300
+ }
2301
+ return {
2302
+ name: indexArg.name,
2303
+ fields,
2304
+ unique: indexArg.unique === true,
2305
+ sparse: indexArg.sparse === true,
2306
+ expireAfterSeconds:
2307
+ typeof indexArg.expireAfterSeconds === 'number'
2308
+ ? indexArg.expireAfterSeconds
2309
+ : undefined,
2310
+ };
2311
+ });
2312
+ }
2313
+
2314
+ /**
2315
+ * Runs an integrity check on the collection.
2316
+ * Compares estimated vs actual document count and checks for duplicates on unique index fields.
2317
+ */
2318
+ public async checkCollectionIntegrity(): Promise<{
2319
+ ok: boolean;
2320
+ estimatedCount: number;
2321
+ actualCount: number;
2322
+ duplicateFields: Array<{ field: string; duplicateValues: number }>;
2323
+ }> {
2324
+ await this.init();
2325
+ const result = {
2326
+ ok: true,
2327
+ estimatedCount: 0,
2328
+ actualCount: 0,
2329
+ duplicateFields: [] as Array<{ field: string; duplicateValues: number }>,
2330
+ };
2331
+
2332
+ try {
2333
+ result.estimatedCount = await this.mongoDbCollection.estimatedDocumentCount();
2334
+ result.actualCount = await this.mongoDbCollection.countDocuments({});
2335
+
2336
+ if (result.estimatedCount !== result.actualCount) {
2337
+ result.ok = false;
2338
+ logger.log(
2339
+ 'warn',
2340
+ `Integrity check on "${this.collectionName}": estimatedDocumentCount=${result.estimatedCount} ` +
2341
+ `but countDocuments=${result.actualCount}. Possible data inconsistency.`
2342
+ );
2343
+ }
2344
+
2345
+ // Check for duplicates on each tracked unique index field
2346
+ for (const field of this.uniqueIndexes) {
2347
+ try {
2348
+ const pipeline = [
2349
+ { $group: { _id: `$${field}`, count: { $sum: 1 } } },
2350
+ { $match: { count: { $gt: 1 } } },
2351
+ { $count: 'total' },
2352
+ ];
2353
+ const countResult = await this.mongoDbCollection.aggregate(pipeline).toArray();
2354
+ const dupCount = countResult[0]?.total || 0;
2355
+ if (dupCount > 0) {
2356
+ result.ok = false;
2357
+ result.duplicateFields.push({ field, duplicateValues: dupCount });
2358
+ logger.log(
2359
+ 'warn',
2360
+ `Integrity check on "${this.collectionName}": field "${field}" has ${dupCount} values with duplicates ` +
2361
+ `despite being marked as unique.`
2362
+ );
2363
+ }
2364
+ } catch (fieldErr: any) {
2365
+ logger.log(
2366
+ 'warn',
2367
+ `Integrity check: could not verify uniqueness of "${field}" in "${this.collectionName}": ${fieldErr?.message || String(fieldErr)}`
2368
+ );
2369
+ }
2370
+ }
2371
+ } catch (err: any) {
2372
+ result.ok = false;
2373
+ logger.log(
2374
+ 'error',
2375
+ `Integrity check failed for "${this.collectionName}": ${err?.message || String(err)}`
2376
+ );
2377
+ }
2378
+
2379
+ return result;
2380
+ }
2381
+
2382
+ /**
2383
+ * checks a Doc for constraints
2384
+ * if this.objectValidation is not set it passes.
2385
+ */
2386
+ private checkDoc(docArg: T): Promise<void> {
2387
+ const done = plugins.smartpromise.defer<void>();
2388
+ let validationResult = true;
2389
+ if (this.objectValidation) {
2390
+ validationResult = this.objectValidation(docArg);
2391
+ }
2392
+ if (validationResult) {
2393
+ done.resolve();
2394
+ } else {
2395
+ done.reject('validation of object did not pass');
2396
+ }
2397
+ return done.promise;
2398
+ }
2399
+ }