@rapidrest/service-core 1.0.0-rc.7 → 1.0.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 (380) hide show
  1. package/LICENSE +382 -23
  2. package/README.md +79 -5
  3. package/dist/lib/ApiErrors.js +10 -0
  4. package/dist/lib/ApiErrors.js.map +1 -1
  5. package/dist/lib/BackgroundService.js +1 -4
  6. package/dist/lib/BackgroundService.js.map +1 -1
  7. package/dist/lib/BackgroundServiceManager.js +47 -6
  8. package/dist/lib/BackgroundServiceManager.js.map +1 -1
  9. package/dist/lib/BulkError.js +4 -0
  10. package/dist/lib/BulkError.js.map +1 -1
  11. package/dist/lib/EventListenerManager.js +53 -18
  12. package/dist/lib/EventListenerManager.js.map +1 -1
  13. package/dist/lib/NetUtils.js +1 -0
  14. package/dist/lib/NetUtils.js.map +1 -1
  15. package/dist/lib/NotificationUtils.js +27 -8
  16. package/dist/lib/NotificationUtils.js.map +1 -1
  17. package/dist/lib/ObjectFactory.js +25 -44
  18. package/dist/lib/ObjectFactory.js.map +1 -1
  19. package/dist/lib/OpenApiSpec.js +31 -11
  20. package/dist/lib/OpenApiSpec.js.map +1 -1
  21. package/dist/lib/Server.js +74 -110
  22. package/dist/lib/Server.js.map +1 -1
  23. package/dist/lib/auth/AuthMiddleware.js +11 -16
  24. package/dist/lib/auth/AuthMiddleware.js.map +1 -1
  25. package/dist/lib/auth/JWTStrategy.js +68 -17
  26. package/dist/lib/auth/JWTStrategy.js.map +1 -1
  27. package/dist/lib/auth/index.js +0 -1
  28. package/dist/lib/auth/index.js.map +1 -1
  29. package/dist/lib/database/ConnectionKinds.js +15 -0
  30. package/dist/lib/database/ConnectionKinds.js.map +1 -1
  31. package/dist/lib/database/ConnectionManager.js +140 -61
  32. package/dist/lib/database/ConnectionManager.js.map +1 -1
  33. package/dist/lib/database/MongoConnection.js +13 -3
  34. package/dist/lib/database/MongoConnection.js.map +1 -1
  35. package/dist/lib/database/MongoRepository.js +61 -15
  36. package/dist/lib/database/MongoRepository.js.map +1 -1
  37. package/dist/lib/database/MongoSchemaSync.js +3 -3
  38. package/dist/lib/database/MongoSchemaSync.js.map +1 -1
  39. package/dist/lib/database/NamingUtils.js +3 -2
  40. package/dist/lib/database/NamingUtils.js.map +1 -1
  41. package/dist/lib/database/RedisCache.js +64 -0
  42. package/dist/lib/database/RedisCache.js.map +1 -0
  43. package/dist/lib/database/TypeOrmSupport.js +53 -13
  44. package/dist/lib/database/TypeOrmSupport.js.map +1 -1
  45. package/dist/lib/database/index.js +1 -0
  46. package/dist/lib/database/index.js.map +1 -1
  47. package/dist/lib/decorators/DatabaseDecorators.js +264 -20
  48. package/dist/lib/decorators/DatabaseDecorators.js.map +1 -1
  49. package/dist/lib/decorators/DocDecorators.js +30 -9
  50. package/dist/lib/decorators/DocDecorators.js.map +1 -1
  51. package/dist/lib/decorators/EventDecorators.js +1 -0
  52. package/dist/lib/decorators/EventDecorators.js.map +1 -1
  53. package/dist/lib/decorators/ModelDecorators.js +39 -23
  54. package/dist/lib/decorators/ModelDecorators.js.map +1 -1
  55. package/dist/lib/decorators/PersistenceDecorators.js +4 -3
  56. package/dist/lib/decorators/PersistenceDecorators.js.map +1 -1
  57. package/dist/lib/decorators/RouteDecorators.js +139 -36
  58. package/dist/lib/decorators/RouteDecorators.js.map +1 -1
  59. package/dist/lib/http/IWebSocketShim.js +2 -0
  60. package/dist/lib/http/IWebSocketShim.js.map +1 -0
  61. package/dist/lib/http/MiddlewareChain.js +167 -0
  62. package/dist/lib/http/MiddlewareChain.js.map +1 -0
  63. package/dist/lib/http/RuntimeDetect.js +9 -0
  64. package/dist/lib/http/RuntimeDetect.js.map +1 -0
  65. package/dist/lib/http/bun/BunAdapters.js +326 -0
  66. package/dist/lib/http/bun/BunAdapters.js.map +1 -0
  67. package/dist/lib/http/bun/BunRouter.js +391 -0
  68. package/dist/lib/http/bun/BunRouter.js.map +1 -0
  69. package/dist/lib/http/bun/BunWebSocket.js +49 -0
  70. package/dist/lib/http/bun/BunWebSocket.js.map +1 -0
  71. package/dist/lib/http/index.js +16 -3
  72. package/dist/lib/http/index.js.map +1 -1
  73. package/dist/lib/http/session/RedisSessionStore.js +30 -0
  74. package/dist/lib/http/session/RedisSessionStore.js.map +1 -0
  75. package/dist/lib/http/session/SessionManager.js +113 -0
  76. package/dist/lib/http/session/SessionManager.js.map +1 -0
  77. package/dist/lib/http/session/SessionStore.js +6 -0
  78. package/dist/lib/http/session/SessionStore.js.map +1 -0
  79. package/dist/lib/http/session/sessionMiddleware.js +42 -0
  80. package/dist/lib/http/session/sessionMiddleware.js.map +1 -0
  81. package/dist/lib/http/types.js +0 -3
  82. package/dist/lib/http/types.js.map +1 -1
  83. package/dist/lib/http/uWS/Adapters.js +421 -0
  84. package/dist/lib/http/uWS/Adapters.js.map +1 -0
  85. package/dist/lib/http/{Router.js → uWS/Router.js} +73 -173
  86. package/dist/lib/http/uWS/Router.js.map +1 -0
  87. package/dist/lib/http/{WebSocket.js → uWS/WebSocket.js} +4 -1
  88. package/dist/lib/http/uWS/WebSocket.js.map +1 -0
  89. package/dist/lib/models/BaseEntity.js +2 -1
  90. package/dist/lib/models/BaseEntity.js.map +1 -1
  91. package/dist/lib/models/BaseMongoEntity.js +2 -2
  92. package/dist/lib/models/BaseMongoEntity.js.map +1 -1
  93. package/dist/lib/models/ModelUtils.js +248 -101
  94. package/dist/lib/models/ModelUtils.js.map +1 -1
  95. package/dist/lib/models/RecoverableBaseEntity.js +6 -1
  96. package/dist/lib/models/RecoverableBaseEntity.js.map +1 -1
  97. package/dist/lib/models/RecoverableBaseMongoEntity.js +1 -0
  98. package/dist/lib/models/RecoverableBaseMongoEntity.js.map +1 -1
  99. package/dist/lib/models/RepoUtils.js +641 -203
  100. package/dist/lib/models/RepoUtils.js.map +1 -1
  101. package/dist/lib/models/SimpleEntity.js +1 -0
  102. package/dist/lib/models/SimpleEntity.js.map +1 -1
  103. package/dist/lib/models/SimpleMongoEntity.js +2 -1
  104. package/dist/lib/models/SimpleMongoEntity.js.map +1 -1
  105. package/dist/lib/models/StatusExtraData.js +4 -0
  106. package/dist/lib/models/StatusExtraData.js.map +1 -1
  107. package/dist/lib/routes/{AdminRoute.js → BaseAdminRoute.js} +98 -80
  108. package/dist/lib/routes/BaseAdminRoute.js.map +1 -0
  109. package/dist/lib/routes/{MetricsRoute.js → BaseMetricsRoute.js} +29 -15
  110. package/dist/lib/routes/BaseMetricsRoute.js.map +1 -0
  111. package/dist/lib/routes/{OpenAPIRoute.js → BaseOpenAPIRoute.js} +32 -32
  112. package/dist/lib/routes/BaseOpenAPIRoute.js.map +1 -0
  113. package/dist/lib/routes/BasePushRoute.js +305 -0
  114. package/dist/lib/routes/BasePushRoute.js.map +1 -0
  115. package/dist/lib/routes/BaseStaticRoute.js +153 -0
  116. package/dist/lib/routes/BaseStaticRoute.js.map +1 -0
  117. package/dist/lib/routes/BaseStatusRoute.js +80 -0
  118. package/dist/lib/routes/BaseStatusRoute.js.map +1 -0
  119. package/dist/lib/routes/CRUDRoute.js +284 -0
  120. package/dist/lib/routes/CRUDRoute.js.map +1 -0
  121. package/dist/lib/routes/ModelRoute.js +84 -57
  122. package/dist/lib/routes/ModelRoute.js.map +1 -1
  123. package/dist/lib/routes/RouteUtils.js +108 -20
  124. package/dist/lib/routes/RouteUtils.js.map +1 -1
  125. package/dist/lib/routes/index.js +7 -4
  126. package/dist/lib/routes/index.js.map +1 -1
  127. package/dist/lib/security/ACLUtils.js +297 -170
  128. package/dist/lib/security/ACLUtils.js.map +1 -1
  129. package/dist/lib/security/AccessControlList.js +18 -10
  130. package/dist/lib/security/AccessControlList.js.map +1 -1
  131. package/dist/lib/security/AccessControlListMongo.js +19 -68
  132. package/dist/lib/security/AccessControlListMongo.js.map +1 -1
  133. package/dist/lib/security/AccessControlListSQL.js +21 -70
  134. package/dist/lib/security/AccessControlListSQL.js.map +1 -1
  135. package/dist/lib/security/BaseACLRoute.js +239 -0
  136. package/dist/lib/security/BaseACLRoute.js.map +1 -0
  137. package/dist/lib/security/index.js +3 -0
  138. package/dist/lib/security/index.js.map +1 -1
  139. package/dist/lib/test/request.js +62 -5
  140. package/dist/lib/test/request.js.map +1 -1
  141. package/dist/lib/test/requestws.js +34 -10
  142. package/dist/lib/test/requestws.js.map +1 -1
  143. package/dist/types/ApiErrors.d.ts +8 -2
  144. package/dist/types/BackgroundService.d.ts +0 -5
  145. package/dist/types/BackgroundServiceManager.d.ts +2 -1
  146. package/dist/types/EventListenerManager.d.ts +7 -6
  147. package/dist/types/NotificationUtils.d.ts +5 -3
  148. package/dist/types/OpenApiSpec.d.ts +2 -2
  149. package/dist/types/Server.d.ts +28 -10
  150. package/dist/types/auth/AuthMiddleware.d.ts +3 -3
  151. package/dist/types/auth/AuthStrategy.d.ts +10 -7
  152. package/dist/types/auth/JWTStrategy.d.ts +6 -4
  153. package/dist/types/auth/index.d.ts +0 -1
  154. package/dist/types/database/ConnectionKinds.d.ts +7 -0
  155. package/dist/types/database/ConnectionManager.d.ts +29 -4
  156. package/dist/types/database/MongoConnection.d.ts +16 -4
  157. package/dist/types/database/MongoRepository.d.ts +32 -11
  158. package/dist/types/database/RedisCache.d.ts +23 -0
  159. package/dist/types/database/TypeOrmSupport.d.ts +4 -4
  160. package/dist/types/database/index.d.ts +1 -0
  161. package/dist/types/decorators/DatabaseDecorators.d.ts +139 -8
  162. package/dist/types/decorators/DocDecorators.d.ts +11 -2
  163. package/dist/types/decorators/ModelDecorators.d.ts +12 -5
  164. package/dist/types/decorators/PersistenceDecorators.d.ts +11 -4
  165. package/dist/types/decorators/RouteDecorators.d.ts +36 -6
  166. package/dist/types/http/IWebSocketShim.d.ts +11 -0
  167. package/dist/types/http/MiddlewareChain.d.ts +47 -0
  168. package/dist/types/http/RuntimeDetect.d.ts +2 -0
  169. package/dist/types/http/bun/BunAdapters.d.ts +139 -0
  170. package/dist/types/http/bun/BunRouter.d.ts +79 -0
  171. package/dist/types/http/bun/BunWebSocket.d.ts +35 -0
  172. package/dist/types/http/index.d.ts +16 -6
  173. package/dist/types/http/session/RedisSessionStore.d.ts +15 -0
  174. package/dist/types/http/session/SessionManager.d.ts +35 -0
  175. package/dist/types/http/session/SessionStore.d.ts +12 -0
  176. package/dist/types/http/session/sessionMiddleware.d.ts +10 -0
  177. package/dist/types/http/types.d.ts +42 -3
  178. package/dist/types/http/uWS/Adapters.d.ts +129 -0
  179. package/dist/types/http/{Router.d.ts → uWS/Router.d.ts} +14 -38
  180. package/dist/types/http/{WebSocket.d.ts → uWS/WebSocket.d.ts} +6 -5
  181. package/dist/types/models/BaseEntity.d.ts +1 -1
  182. package/dist/types/models/ModelUtils.d.ts +47 -14
  183. package/dist/types/models/RecoverableBaseEntity.d.ts +3 -1
  184. package/dist/types/models/RepoUtils.d.ts +84 -14
  185. package/dist/types/routes/BaseAdminRoute.d.ts +65 -0
  186. package/dist/types/routes/BaseMetricsRoute.d.ts +37 -0
  187. package/dist/types/routes/BaseOpenAPIRoute.d.ts +33 -0
  188. package/dist/types/routes/BasePushRoute.d.ts +53 -0
  189. package/dist/types/routes/BaseStaticRoute.d.ts +44 -0
  190. package/dist/types/routes/BaseStatusRoute.d.ts +37 -0
  191. package/dist/types/routes/CRUDRoute.d.ts +57 -0
  192. package/dist/types/routes/ModelRoute.d.ts +39 -36
  193. package/dist/types/routes/RouteUtils.d.ts +19 -0
  194. package/dist/types/routes/index.d.ts +7 -4
  195. package/dist/types/security/ACLUtils.d.ts +94 -18
  196. package/dist/types/security/AccessControlList.d.ts +37 -59
  197. package/dist/types/security/AccessControlListMongo.d.ts +3 -8
  198. package/dist/types/security/AccessControlListSQL.d.ts +3 -8
  199. package/dist/types/security/BaseACLRoute.d.ts +73 -0
  200. package/dist/types/security/index.d.ts +3 -0
  201. package/dist/types/test/request.d.ts +18 -1
  202. package/package.json +135 -129
  203. package/tsconfig.export.json +17 -0
  204. package/dist/lib/auth/BasicStrategy.js +0 -113
  205. package/dist/lib/auth/BasicStrategy.js.map +0 -1
  206. package/dist/lib/http/Adapters.js +0 -241
  207. package/dist/lib/http/Adapters.js.map +0 -1
  208. package/dist/lib/http/Router.js.map +0 -1
  209. package/dist/lib/http/WebSocket.js.map +0 -1
  210. package/dist/lib/routes/AdminRoute.js.map +0 -1
  211. package/dist/lib/routes/MetricsRoute.js.map +0 -1
  212. package/dist/lib/routes/OpenAPIRoute.js.map +0 -1
  213. package/dist/lib/routes/StatusRoute.js +0 -55
  214. package/dist/lib/routes/StatusRoute.js.map +0 -1
  215. package/dist/lib/security/ACLRouteMongo.js +0 -194
  216. package/dist/lib/security/ACLRouteMongo.js.map +0 -1
  217. package/dist/lib/security/ACLRouteSQL.js +0 -193
  218. package/dist/lib/security/ACLRouteSQL.js.map +0 -1
  219. package/dist/types/auth/BasicStrategy.d.ts +0 -37
  220. package/dist/types/http/Adapters.d.ts +0 -68
  221. package/dist/types/routes/AdminRoute.d.ts +0 -47
  222. package/dist/types/routes/MetricsRoute.d.ts +0 -15
  223. package/dist/types/routes/OpenAPIRoute.d.ts +0 -17
  224. package/dist/types/routes/StatusRoute.d.ts +0 -11
  225. package/dist/types/security/ACLRouteMongo.d.ts +0 -19
  226. package/dist/types/security/ACLRouteSQL.d.ts +0 -19
  227. package/docs/Makefile +0 -20
  228. package/docs/conf.py +0 -58
  229. package/docs/index.rst +0 -17
  230. package/docs/make.bat +0 -35
  231. package/docs/reference/@rapidrest/namespaces/DatabaseDecorators/README.md +0 -13
  232. package/docs/reference/@rapidrest/namespaces/DatabaseDecorators/functions/MongoRepository.md +0 -25
  233. package/docs/reference/@rapidrest/namespaces/DatabaseDecorators/functions/RedisConnection.md +0 -25
  234. package/docs/reference/@rapidrest/namespaces/DatabaseDecorators/functions/Repository.md +0 -25
  235. package/docs/reference/@rapidrest/namespaces/DocDecorators/README.md +0 -23
  236. package/docs/reference/@rapidrest/namespaces/DocDecorators/functions/Default.md +0 -25
  237. package/docs/reference/@rapidrest/namespaces/DocDecorators/functions/Description.md +0 -25
  238. package/docs/reference/@rapidrest/namespaces/DocDecorators/functions/Document.md +0 -25
  239. package/docs/reference/@rapidrest/namespaces/DocDecorators/functions/Example.md +0 -25
  240. package/docs/reference/@rapidrest/namespaces/DocDecorators/functions/Format.md +0 -25
  241. package/docs/reference/@rapidrest/namespaces/DocDecorators/functions/Returns.md +0 -28
  242. package/docs/reference/@rapidrest/namespaces/DocDecorators/functions/Summary.md +0 -25
  243. package/docs/reference/@rapidrest/namespaces/DocDecorators/functions/Tags.md +0 -25
  244. package/docs/reference/@rapidrest/namespaces/DocDecorators/functions/TypeInfo.md +0 -28
  245. package/docs/reference/@rapidrest/namespaces/DocDecorators/interfaces/DocumentsData.md +0 -57
  246. package/docs/reference/@rapidrest/namespaces/EventDecorators/README.md +0 -12
  247. package/docs/reference/@rapidrest/namespaces/EventDecorators/functions/EventListener.md +0 -17
  248. package/docs/reference/@rapidrest/namespaces/EventDecorators/functions/OnEvent.md +0 -26
  249. package/docs/reference/@rapidrest/namespaces/ModelDecorators/README.md +0 -26
  250. package/docs/reference/@rapidrest/namespaces/ModelDecorators/functions/Cache.md +0 -25
  251. package/docs/reference/@rapidrest/namespaces/ModelDecorators/functions/ChildEntity.md +0 -18
  252. package/docs/reference/@rapidrest/namespaces/ModelDecorators/functions/DataStore.md +0 -25
  253. package/docs/reference/@rapidrest/namespaces/ModelDecorators/functions/Identifier.md +0 -27
  254. package/docs/reference/@rapidrest/namespaces/ModelDecorators/functions/Protect.md +0 -35
  255. package/docs/reference/@rapidrest/namespaces/ModelDecorators/functions/Reference.md +0 -25
  256. package/docs/reference/@rapidrest/namespaces/ModelDecorators/functions/Shard.md +0 -27
  257. package/docs/reference/@rapidrest/namespaces/ModelDecorators/functions/TrackChanges.md +0 -26
  258. package/docs/reference/@rapidrest/namespaces/ModelDecorators/interfaces/PendingTypeOrmColumn.md +0 -45
  259. package/docs/reference/@rapidrest/namespaces/ModelDecorators/variables/pendingTypeOrmColumns.md +0 -14
  260. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/README.md +0 -27
  261. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/functions/Column.md +0 -25
  262. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/functions/Entity.md +0 -25
  263. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/functions/Index.md +0 -119
  264. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/functions/PrimaryColumn.md +0 -25
  265. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/functions/Unique.md +0 -68
  266. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/functions/getColumnMetadata.md +0 -26
  267. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/functions/getEntityName.md +0 -26
  268. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/functions/getIndexMetadata.md +0 -27
  269. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/interfaces/CollationOptions.md +0 -79
  270. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/interfaces/ColumnInfo.md +0 -41
  271. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/interfaces/ColumnOptions.md +0 -51
  272. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/interfaces/EntityOptions.md +0 -32
  273. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/interfaces/IndexInfo.md +0 -41
  274. package/docs/reference/@rapidrest/namespaces/PersistenceDecorators/interfaces/IndexOptions.md +0 -61
  275. package/docs/reference/@rapidrest/namespaces/RouteDecorators/README.md +0 -36
  276. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/After.md +0 -26
  277. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Auth.md +0 -33
  278. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/AuthResult.md +0 -31
  279. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Before.md +0 -25
  280. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/ContentType.md +0 -25
  281. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Delete.md +0 -25
  282. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Get.md +0 -25
  283. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Head.md +0 -25
  284. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Header.md +0 -25
  285. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Method.md +0 -31
  286. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Model.md +0 -25
  287. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Options.md +0 -25
  288. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Param.md +0 -26
  289. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Patch.md +0 -25
  290. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Post.md +0 -25
  291. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Protect.md +0 -31
  292. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Put.md +0 -25
  293. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Query.md +0 -26
  294. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Request.md +0 -31
  295. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/RequiresRole.md +0 -26
  296. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Response.md +0 -31
  297. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Route.md +0 -25
  298. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Socket.md +0 -33
  299. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/User.md +0 -31
  300. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/Validate.md +0 -25
  301. package/docs/reference/@rapidrest/namespaces/RouteDecorators/functions/WebSocket.md +0 -25
  302. package/docs/reference/README.md +0 -20
  303. package/docs/reference/classes/ACLUtils.md +0 -263
  304. package/docs/reference/classes/AdminRoute.md +0 -51
  305. package/docs/reference/classes/AuthMiddleware.md +0 -173
  306. package/docs/reference/classes/BackgroundService.md +0 -117
  307. package/docs/reference/classes/BackgroundServiceManager.md +0 -172
  308. package/docs/reference/classes/BaseEntity.md +0 -82
  309. package/docs/reference/classes/BaseMongoEntity.md +0 -107
  310. package/docs/reference/classes/BasicStrategy.md +0 -131
  311. package/docs/reference/classes/BasicStrategyOptions.md +0 -115
  312. package/docs/reference/classes/BulkError.md +0 -271
  313. package/docs/reference/classes/ConnectionManager.md +0 -75
  314. package/docs/reference/classes/EventListenerManager.md +0 -88
  315. package/docs/reference/classes/HttpRouter.md +0 -343
  316. package/docs/reference/classes/JWTStrategy.md +0 -100
  317. package/docs/reference/classes/JWTStrategyOptions.md +0 -97
  318. package/docs/reference/classes/MetricsRoute.md +0 -27
  319. package/docs/reference/classes/ModelRoute.md +0 -527
  320. package/docs/reference/classes/ModelUtils.md +0 -446
  321. package/docs/reference/classes/MongoConnection.md +0 -218
  322. package/docs/reference/classes/MongoRepository.md +0 -390
  323. package/docs/reference/classes/MongoSchemaSync.md +0 -97
  324. package/docs/reference/classes/NetUtils.md +0 -90
  325. package/docs/reference/classes/NotificationUtils.md +0 -77
  326. package/docs/reference/classes/ObjectFactory.md +0 -336
  327. package/docs/reference/classes/OpenAPIRoute.md +0 -77
  328. package/docs/reference/classes/OpenApiSpec.md +0 -892
  329. package/docs/reference/classes/RecoverableBaseEntity.md +0 -114
  330. package/docs/reference/classes/RecoverableBaseMongoEntity.md +0 -124
  331. package/docs/reference/classes/RedisTransport.md +0 -2202
  332. package/docs/reference/classes/RepoUtils.md +0 -482
  333. package/docs/reference/classes/RouteUtils.md +0 -191
  334. package/docs/reference/classes/Server.md +0 -408
  335. package/docs/reference/classes/SimpleEntity.md +0 -48
  336. package/docs/reference/classes/SimpleMongoEntity.md +0 -66
  337. package/docs/reference/classes/StatusExtraData.md +0 -57
  338. package/docs/reference/classes/StatusRoute.md +0 -26
  339. package/docs/reference/classes/UWSRequest.md +0 -226
  340. package/docs/reference/classes/UWSResponse.md +0 -257
  341. package/docs/reference/classes/UWSWebSocketShim.md +0 -958
  342. package/docs/reference/enumerations/ACLAction.md +0 -63
  343. package/docs/reference/enumerations/ApiErrorMessages.md +0 -123
  344. package/docs/reference/enumerations/ApiErrors.md +0 -123
  345. package/docs/reference/functions/createWebSocketStream.md +0 -27
  346. package/docs/reference/functions/isSqlDataSource.md +0 -26
  347. package/docs/reference/functions/readBody.md +0 -29
  348. package/docs/reference/functions/resolveCollectionName.md +0 -33
  349. package/docs/reference/functions/runChain.md +0 -40
  350. package/docs/reference/functions/snakeCase.md +0 -28
  351. package/docs/reference/globals.md +0 -106
  352. package/docs/reference/interfaces/ACLRecord.md +0 -96
  353. package/docs/reference/interfaces/AccessControlList.md +0 -76
  354. package/docs/reference/interfaces/AuthResult.md +0 -55
  355. package/docs/reference/interfaces/AuthStrategy.md +0 -95
  356. package/docs/reference/interfaces/CreateRequestOptions.md +0 -121
  357. package/docs/reference/interfaces/DeleteRequestOptions.md +0 -137
  358. package/docs/reference/interfaces/FindRequestOptions.md +0 -133
  359. package/docs/reference/interfaces/HttpRequest.md +0 -147
  360. package/docs/reference/interfaces/HttpResponse.md +0 -164
  361. package/docs/reference/interfaces/JWTAuthResult.md +0 -84
  362. package/docs/reference/interfaces/RepoCreateOptions.md +0 -95
  363. package/docs/reference/interfaces/RepoDeleteOptions.md +0 -105
  364. package/docs/reference/interfaces/RepoFindOptions.md +0 -125
  365. package/docs/reference/interfaces/RepoOperationOptions.md +0 -69
  366. package/docs/reference/interfaces/RepoUpdateOptions.md +0 -101
  367. package/docs/reference/interfaces/RequestOptions.md +0 -112
  368. package/docs/reference/interfaces/RequestWS.md +0 -225
  369. package/docs/reference/interfaces/TruncateRequestOptions.md +0 -161
  370. package/docs/reference/interfaces/UpdateRequestOptions.md +0 -139
  371. package/docs/reference/type-aliases/ErrorHandler.md +0 -35
  372. package/docs/reference/type-aliases/NextFunction.md +0 -23
  373. package/docs/reference/type-aliases/OneOrMany.md +0 -19
  374. package/docs/reference/type-aliases/OneOrNull.md +0 -19
  375. package/docs/reference/type-aliases/PartialBaseEntity.md +0 -17
  376. package/docs/reference/type-aliases/PartialSimpleEntity.md +0 -17
  377. package/docs/reference/type-aliases/RequestHandler.md +0 -31
  378. package/docs/reference/type-aliases/UpdateObject.md +0 -19
  379. package/docs/reference/type-aliases/WsUpgradeAuth.md +0 -27
  380. package/docs/reference/type-aliases/WsUpgradeAuthResult.md +0 -47
@@ -9,6 +9,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  };
10
10
  ///////////////////////////////////////////////////////////////////////////////
11
11
  // Copyright (C) 2020-2026 Jean-Philippe Steinmetz. All rights reserved.
12
+ // SPDX-License-Identifier: MPL-2.0
12
13
  ////////////////////////////////////////////////////////////////////////////////
13
14
  import * as crypto from "crypto";
14
15
  import { MongoRepository } from "../database/MongoRepository.js";
@@ -20,15 +21,13 @@ import { BaseEntity } from "../models/BaseEntity.js";
20
21
  import { BaseMongoEntity } from "../models/BaseMongoEntity.js";
21
22
  import { ApiErrorMessages, ApiErrors } from "../ApiErrors.js";
22
23
  import { ApiError, ObjectDecorators, ObjectUtils, UserUtils } from "@rapidrest/core";
23
- import { DatabaseDecorators } from "../decorators/index.js";
24
- import { Redis } from "ioredis";
25
- import { ObjectFactory } from "../ObjectFactory.js";
26
24
  import { NotificationUtils } from "../NotificationUtils.js";
27
25
  import { RecoverableBaseEntity } from "./RecoverableBaseEntity.js";
28
26
  import { ACLAction } from "../security/index.js";
29
- import { ConnectionManager } from "../database/index.js";
27
+ import { ConnectionManager, RedisCache } from "../database/index.js";
28
+ import { registerRollbackHook, Transactional, transactionContext } from "../decorators/DatabaseDecorators.js";
30
29
  const { Config, Init, Inject, Logger } = ObjectDecorators;
31
- const { RedisConnection } = DatabaseDecorators;
30
+ const _hashCache = new Map();
32
31
  /**
33
32
  * @author Jean-Philippe Steinmetz
34
33
  */
@@ -43,18 +42,25 @@ export class RepoUtils {
43
42
  async init() {
44
43
  // Retrieve the repository based on the modelClass that was passed in to the constructor
45
44
  if (!this.repo) {
46
- if (!this.modelClass.datastore) {
45
+ if (!this.modelClass.datasource) {
47
46
  throw new Error(`Cannot initialize RepoUtils. Did you forget to add @DataStore() to ${this.modelClass.name}?`);
48
47
  }
49
48
  if (!this.connectionManager) {
50
49
  throw new Error("Cannot initialize RepoUtils. Failed to retrieve ConnectionManager.");
51
50
  }
52
- const ds = this.connectionManager.connections.get(this.modelClass.datastore);
51
+ const ds = this.connectionManager.connections.get(this.modelClass.datasource);
53
52
  if (!ds) {
54
- throw new Error(`Cannot initialize RepoUtils. No connection found for datastore '${this.modelClass.datastore}'`);
53
+ throw new Error(`Cannot initialize RepoUtils. No connection found for datasource '${this.modelClass.datasource}'`);
55
54
  }
56
55
  this.repo = ds.getRepository(this.modelClass);
57
56
  }
57
+ // Create the cache store if caching is enabled for this entity type
58
+ if (!this.cache && this.modelClass.cacheTTL) {
59
+ this.cache = await this._objectFactory?.newInstance(RedisCache, {
60
+ name: this.modelClass.fqn ?? this.modelClass.name,
61
+ args: [this.modelClass],
62
+ });
63
+ }
58
64
  if (!this.repo) {
59
65
  throw new Error(`Cannot initialize RepoUtils. No repository found for class ${this.modelClass.name}.`);
60
66
  }
@@ -68,11 +74,11 @@ export class RepoUtils {
68
74
  // Does the model specify a MongoDB shard configuration?
69
75
  const shardConfig = Reflect.getMetadata("rrst:shardConfig", this.modelClass);
70
76
  if (shardConfig && this.repo instanceof MongoRepository) {
71
- const conn = this.connectionManager?.connections.get(this.modelClass.datastore);
77
+ const conn = this.connectionManager?.connections.get(this.modelClass.datasource);
72
78
  const admin = conn?.admin();
73
79
  if (admin) {
74
80
  const collectionName = resolveCollectionName(this.modelClass);
75
- const dbName = this.config.get(`datastores:${this.modelClass.datastore}:database`);
81
+ const dbName = this.config.get(`datastores:${this.modelClass.datasource}:database`);
76
82
  try {
77
83
  this.logger.info(`Configuring sharding for: collection=${dbName}.${collectionName}, key=${JSON.stringify(shardConfig.key)}, unique=${shardConfig.unique}, options=${JSON.stringify(shardConfig.options)})`);
78
84
  const result = await admin.command({
@@ -93,39 +99,203 @@ export class RepoUtils {
93
99
  }
94
100
  }
95
101
  /**
96
- * The base key used to get or set data in the cache.
102
+ * Retrieves every uid matching the given (already-built) search query, ignoring any pagination `take`/`page`
103
+ * baked into it by `ModelUtils.buildSearchQuery`. Used by `count`/`exists`/`truncate`, which must narrow by
104
+ * record-level ACLs against the *entire* matching set rather than a single page of it — applying the default
105
+ * `take` here would silently undercount, under-check existence for, or under-delete a large result set.
97
106
  */
98
- get baseCacheKey() {
99
- return "db.cache." + this.modelClass.name;
107
+ async findAllUids(searchQuery, options) {
108
+ const txInfo = this.getTransaction(options);
109
+ if (this.repo instanceof MongoRepository) {
110
+ if (Array.isArray(searchQuery)) {
111
+ return await this.repo.distinct("uid", searchQuery[0].$match, { session: txInfo?.session });
112
+ }
113
+ return await this.repo.distinct("uid", searchQuery["$match"] ? searchQuery["$match"] : searchQuery, {
114
+ session: txInfo?.session,
115
+ });
116
+ }
117
+ // Only the uid column is needed, and pagination must not clip the result set here.
118
+ const uidQuery = { ...searchQuery, select: { uid: true } };
119
+ delete uidQuery.take;
120
+ delete uidQuery.page;
121
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
122
+ const rows = (await repo.find(uidQuery));
123
+ return rows.map((obj) => obj.uid);
124
+ }
125
+ /**
126
+ * Filters the given uids down to those the user has `action` permission for, checking in bounded-size
127
+ * batches rather than a single unbounded `Promise.all` so a large matching set can't fire an unbounded
128
+ * number of concurrent permission-check round trips at once.
129
+ */
130
+ async filterPermittedUids(uids, action, options) {
131
+ const batchSize = 100;
132
+ const permitted = [];
133
+ for (let i = 0; i < uids.length; i += batchSize) {
134
+ const batch = uids.slice(i, i + batchSize);
135
+ const results = await Promise.all(batch.map((uid) => this.aclUtils.hasPermission(options?.user, uid, action)));
136
+ for (let j = 0; j < batch.length; j++) {
137
+ if (results[j]) {
138
+ permitted.push(batch[j]);
139
+ }
140
+ }
141
+ }
142
+ return permitted;
100
143
  }
101
144
  async count(query, options) {
102
145
  if (!this.repo) {
103
146
  throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
104
147
  }
105
148
  let count = 0;
106
- // Check user permissions
149
+ const action = options?.action ?? ACLAction.COUNT;
150
+ const txInfo = this.getTransaction(options);
151
+ // Check user permissions against the class-level ACL. This is a fast-fail gate for users with no
152
+ // legitimate access to the resource type at all; per-record narrowing (below) is an additional layer
153
+ // on top of this, not a replacement for it.
107
154
  if (this.aclUtils?.enabled && !options?.ignoreACL) {
108
- if (!(await this.aclUtils.hasPermission(options?.user, this.defaultACLUid, ACLAction.READ))) {
155
+ if (!(await this.aclUtils.hasPermission(options?.user, this.defaultACLUid, action))) {
109
156
  throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, ApiErrorMessages.AUTH_PERMISSION_FAILURE);
110
157
  }
111
158
  }
159
+ // A client-supplied `?deleted=true` filter overrides `buildSearchQuery()`'s default exclusion of
160
+ // soft-deleted rows. Counting a matched soft-deleted row requires the DELETE+UPDATE permissions.
161
+ const clientRequestsDeleted = query?.deleted === true || query?.deleted === "true";
162
+ const recordACL = !!this.modelClass.recordACL;
163
+ let effectiveQuery = query;
164
+ if (clientRequestsDeleted && this.aclUtils?.enabled && !options?.ignoreACL && !recordACL) {
165
+ if (!(await this.canViewDeleted(options?.user, this.defaultACLUid))) {
166
+ effectiveQuery = { ...query };
167
+ delete effectiveQuery.deleted;
168
+ }
169
+ }
170
+ const searchQuery = ModelUtils.buildSearchQuery(this.modelClass, this.repo, effectiveQuery, true, options?.user);
171
+ // `buildSearchQuery()` auto-excludes soft-deleted rows for a RecoverableBaseEntity by default. We strip
172
+ // that out of the query rather than trying to influence the exclusion via the input `query` object.
173
+ if (options?.includeDeleted) {
174
+ if (Array.isArray(searchQuery)) {
175
+ delete searchQuery[0]?.$match?.deleted;
176
+ }
177
+ else if (searchQuery?.$match) {
178
+ delete searchQuery.$match.deleted;
179
+ }
180
+ else if (Array.isArray(searchQuery?.where)) {
181
+ for (const w of searchQuery.where) {
182
+ delete w.deleted;
183
+ }
184
+ }
185
+ }
186
+ // Record-level ACLs aren't reflected in the query itself, so the matched uids must be checked
187
+ // individually and counted rather than delegating the count to the database.
188
+ if (this.aclUtils?.enabled && !options?.ignoreACL && recordACL) {
189
+ const uids = await this.findAllUids(searchQuery, options);
190
+ let permitted;
191
+ if (clientRequestsDeleted) {
192
+ // Every uid this query matched is, by construction, a soft-deleted record - check the restore
193
+ // bar per-record rather than the ordinary `action`.
194
+ const deleteOk = new Set(await this.filterPermittedUids(uids, ACLAction.DELETE, options));
195
+ const updateOk = await this.filterPermittedUids(uids, ACLAction.UPDATE, options);
196
+ permitted = updateOk.filter((uid) => deleteOk.has(uid));
197
+ }
198
+ else {
199
+ permitted = await this.filterPermittedUids(uids, action, options);
200
+ }
201
+ return permitted.length;
202
+ }
112
203
  if (this.repo instanceof MongoRepository) {
113
- if (Array.isArray(query)) {
114
- query.push({ $count: "count" });
115
- const result = await this.repo.aggregate(query).next();
204
+ if (Array.isArray(searchQuery)) {
205
+ searchQuery.push({ $count: "count" });
206
+ const result = await this.repo.aggregate(searchQuery, { session: txInfo?.session }).next();
116
207
  count = result ? result.count : count;
117
208
  }
118
209
  else {
119
- count = await this.repo.count(query["$match"] ? query["$match"] : query);
210
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
211
+ count = await repo.count(searchQuery["$match"] ? searchQuery["$match"] : searchQuery, {
212
+ session: txInfo?.session,
213
+ });
120
214
  }
121
215
  }
122
216
  else {
123
- count = await this.repo.count(query);
217
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
218
+ count = await repo.count(searchQuery);
124
219
  }
125
220
  return count;
126
221
  }
127
222
  /**
128
- * Stores a new record of the provided object in the datastore. Performs pre-processing, permission checks against
223
+ * Determines whether an object with the given unique identifier (and, optionally, a specific version) exists
224
+ * in the datasource. Respects record-level ACLs the same way `count()` does.
225
+ *
226
+ * @param id The unique identifier of the object to check for.
227
+ * @param options The additional options to consider, such as `version` and the requesting `user`.
228
+ */
229
+ async exists(id, options) {
230
+ if (!this.repo) {
231
+ throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
232
+ }
233
+ const action = options?.action ?? ACLAction.EXISTS;
234
+ // Check user permissions against the class-level ACL. This is a fast-fail gate for users with no
235
+ // legitimate access to the resource type at all; per-record narrowing (below) is an additional layer
236
+ // on top of this, not a replacement for it.
237
+ if (this.aclUtils?.enabled && !options?.ignoreACL) {
238
+ if (!(await this.aclUtils.hasPermission(options?.user, this.defaultACLUid, action))) {
239
+ throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, ApiErrorMessages.AUTH_PERMISSION_FAILURE);
240
+ }
241
+ }
242
+ // Ordinary (live-record) existence check, respecting `action`/record-level ACLs exactly as before.
243
+ let count = await this.existsQuery(id, options, false, action);
244
+ // A soft-deleted record only counts as "existing" for a caller with both DELETE and UPDATE permission.
245
+ if (count === 0 && options?.includeDeleted) {
246
+ let canRestore = true;
247
+ if (this.aclUtils?.enabled && !options?.ignoreACL) {
248
+ const restoreAclUid = this.modelClass.recordACL ? id : this.defaultACLUid;
249
+ canRestore = await this.canViewDeleted(options?.user, restoreAclUid);
250
+ }
251
+ if (canRestore) {
252
+ // Permission for the deleted record was already established above, so this pass runs as a raw
253
+ // existence check rather than re-deriving/re-checking `action` (which the record's ACL may not
254
+ // grant even to someone who can restore it).
255
+ count = await this.existsQuery(id, options, true, null);
256
+ }
257
+ }
258
+ return count;
259
+ }
260
+ /**
261
+ * Runs the actual existence check/count for `exists()`, deduped by uid and clamped to at most 1. Split out
262
+ * so `exists()` can run it twice — once for a live record, once (gated on DELETE+UPDATE permission) for a
263
+ * soft-deleted one — without duplicating the Mongo/SQL/record-ACL branching.
264
+ *
265
+ * @param id The unique identifier of the object to check for.
266
+ * @param options The additional options to consider, such as `version`.
267
+ * @param includeDeleted Whether to match a soft-deleted record.
268
+ * @param enforceAction The ACL action to check per matched record on a `recordACL` model, or `null` to skip
269
+ * that check (used for the second, already-authorized `includeDeleted` pass).
270
+ */
271
+ async existsQuery(id, options, includeDeleted, enforceAction) {
272
+ if (!this.repo) {
273
+ throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
274
+ }
275
+ const txInfo = this.getTransaction(options);
276
+ // Without an explicit version, `query` matches every historical row sharing this uid on a trackChanges
277
+ // entity - existence is still a yes/no question about the uid itself, so results are deduped by uid and
278
+ // the final count clamped to at most 1, rather than reporting the number of matching version rows.
279
+ const query = this.searchIdQuery(id, options?.version, includeDeleted);
280
+ // Record-level ACLs aren't reflected in the query itself, so the matched uids must be checked
281
+ // individually and counted rather than delegating the count to the database.
282
+ if (enforceAction && this.aclUtils?.enabled && !options?.ignoreACL && this.modelClass.recordACL) {
283
+ const uids = await this.findAllUids(query);
284
+ const permitted = await this.filterPermittedUids(uids, enforceAction, options);
285
+ return permitted.length > 0 ? 1 : 0;
286
+ }
287
+ let count;
288
+ if (this.repo instanceof MongoRepository) {
289
+ count = await this.repo.count(query, { session: txInfo?.session });
290
+ }
291
+ else {
292
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
293
+ count = await repo.count(query);
294
+ }
295
+ return Math.min(count, 1);
296
+ }
297
+ /**
298
+ * Stores a new record of the provided object in the datasource. Performs pre-processing, permission checks against
129
299
  * the class ACL, cache seeding, telemetry recording and push notifications.
130
300
  *
131
301
  * @param obj The object to store.
@@ -135,6 +305,7 @@ export class RepoUtils {
135
305
  if (!this.repo) {
136
306
  throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
137
307
  }
308
+ const txInfo = this.getTransaction(options);
138
309
  // Verify the user's permission to create objects
139
310
  if (this.aclUtils?.enabled &&
140
311
  !options?.ignoreACL &&
@@ -144,6 +315,7 @@ export class RepoUtils {
144
315
  // Instantiate the object if not already done
145
316
  const clazz = this.getClassType(obj);
146
317
  const newObj = obj instanceof clazz ? obj : this.instantiateObject(obj, clazz);
318
+ const repo = this.repo;
147
319
  // Make sure an existing object doesn't already exist with the same identifiers
148
320
  const ids = [];
149
321
  const idProps = ModelUtils.getIdPropertyNames(clazz);
@@ -153,11 +325,28 @@ export class RepoUtils {
153
325
  ids.push(val);
154
326
  }
155
327
  }
156
- const query = ModelUtils.buildIdSearchQuery(this.repo, clazz, ids, undefined);
157
- const count = await this.repo.count(query);
328
+ const query = ModelUtils.buildIdSearchQuery(repo, clazz, ids, undefined);
329
+ const count = this.repo instanceof MongoRepository
330
+ ? await this.repo.count(query, { session: txInfo?.session })
331
+ : await (txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo).count(query);
158
332
  if (!this.modelClass.trackChanges && count > 0) {
159
333
  throw new ApiError(ApiErrors.IDENTIFIER_EXISTS, 400, ApiErrorMessages.IDENTIFIER_EXISTS);
160
334
  }
335
+ else if (this.modelClass.trackChanges &&
336
+ count > 0 &&
337
+ this.modelClass.recordACL &&
338
+ this.aclUtils?.enabled &&
339
+ !(await this.aclUtils.hasPermission(options?.user, newObj.uid, ACLAction.UPDATE))) {
340
+ // A trackChanges + recordACL model is being "re-created" under an existing uid (i.e. a new
341
+ // version). That's only legitimate for someone who already has update rights on the
342
+ // existing record — generic class-level CREATE permission isn't enough, otherwise any
343
+ // creator could inject a new "latest version" of another user's record. Deliberately NOT
344
+ // gated on `options.ignoreACL`: that flag exists so ModelRoute.doCreate() can skip
345
+ // re-doing the class-level CREATE check it already performed upstream — it says nothing
346
+ // about this distinct, additional per-record check, which has no upstream equivalent and
347
+ // must always run.
348
+ throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, ApiErrorMessages.AUTH_PERMISSION_FAILURE);
349
+ }
161
350
  // Override the date and version fields with their defaults
162
351
  if (newObj instanceof BaseEntity) {
163
352
  newObj.dateCreated = new Date();
@@ -168,38 +357,85 @@ export class RepoUtils {
168
357
  if (newObj instanceof BaseEntity && this.modelClass.trackChanges === 0) {
169
358
  newObj.version = 0;
170
359
  }
171
- // HAX We shouldn't be casting obj to any here but this is the only way to get it to compile since T
172
- // extends BaseEntity.
173
- const result = this.instantiateObject(await this.repo.save(newObj));
174
- if (this.cacheClient && this.modelClass.cacheTTL) {
175
- // Cache the object for faster retrieval
176
- const query = this.searchIdQuery(newObj.uid);
177
- const cacheKey = `${this.baseCacheKey}.${this.hashQuery(query)}`;
178
- void this.cacheClient.setex(cacheKey, this.modelClass.cacheTTL, JSON.stringify(result));
360
+ // HAX We shouldn't be casting obj to any here but this is the only way to get it to compile
361
+ // since T extends BaseEntity.
362
+ let saved;
363
+ if (this.repo instanceof MongoRepository) {
364
+ saved = await this.repo.save(newObj, { session: txInfo?.session });
365
+ }
366
+ else {
367
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
368
+ saved = await repo.save(newObj);
179
369
  }
370
+ const result = this.instantiateObject(saved);
180
371
  if (this.aclUtils?.enabled && this.modelClass.recordACL) {
181
- // If ACLs are enabled but no ACL was given create one
182
- const acl = {
372
+ // Reuse the existing ACL if this is a legitimate trackChanges "new version" rather than building a fresh,
373
+ // version-less object from scratch. Building fresh here would let saveACL()'s optimistic-lock version
374
+ // check pass by coincidence (a never-updated ACL is also version 0), silently discarding the real records.
375
+ const existingAcl = await this.aclUtils.findACL(result.uid);
376
+ const isFreshAcl = !existingAcl;
377
+ const acl = existingAcl ?? {
183
378
  uid: result.uid,
184
379
  parentUid: options?.acl?.parentUid || this.defaultACLUid,
185
380
  records: options?.acl?.records || [],
186
381
  };
187
382
  // Look for an existing record for the creator
188
383
  let found = !!this.aclUtils.getRecord(acl, options?.user);
384
+ let modifiedExistingAcl = false;
189
385
  // Always grant the creator CRUD access, unless the user is a superuser.
190
386
  if (!found && options?.user && !UserUtils.hasRoles(options?.user, this.trustedRoles)) {
191
387
  acl.records.push({
192
388
  userOrRoleId: options.user.uid,
193
- create: true,
194
- read: true,
195
- update: true,
196
- delete: true,
197
- special: false,
198
- full: false,
389
+ actions: [
390
+ ACLAction.COUNT,
391
+ ACLAction.CREATE,
392
+ ACLAction.DELETE,
393
+ ACLAction.EXISTS,
394
+ ACLAction.READ,
395
+ ACLAction.LIST,
396
+ ACLAction.TRUNCATE,
397
+ ACLAction.UPDATE,
398
+ ],
199
399
  });
400
+ modifiedExistingAcl = !isFreshAcl;
200
401
  }
201
402
  await this.aclUtils.saveACL(acl);
403
+ // `saveACL()` commits independently, on the `acl` connection's own transaction (see its doc
404
+ // comment). That means it can't be rolled back by this (the entity-side) transaction's own abort if this
405
+ // transaction fails later. Register a compensating action so a later failure doesn't leave an orphaned
406
+ // ACL behind.
407
+ if (isFreshAcl) {
408
+ const newAclUid = acl.uid;
409
+ registerRollbackHook(async () => {
410
+ try {
411
+ await this.aclUtils.removeACL(newAclUid);
412
+ }
413
+ catch (err) {
414
+ this.logger?.warn(`RepoUtils: Failed to roll back orphaned ACL ${newAclUid} after a failed create().`);
415
+ this.logger?.debug(err);
416
+ }
417
+ });
418
+ }
419
+ else if (modifiedExistingAcl) {
420
+ // Reverting a change to an already-existing ACL isn't well-defined in general (it may carry
421
+ // other, unrelated state) — log loudly instead so this is visible for manual reconciliation.
422
+ const modifiedAclUid = acl.uid;
423
+ registerRollbackHook(async () => {
424
+ this.logger?.warn(`RepoUtils: create() failed after modifying existing ACL ${modifiedAclUid} — that change was not automatically reverted.`);
425
+ });
426
+ }
427
+ }
428
+ if (this.cache) {
429
+ // Cache the object for faster retrieval.
430
+ const query = this.searchIdQuery(newObj.uid);
431
+ const cacheKey = this.hashQuery(query);
432
+ this.cache.save(cacheKey, result).catch((err) => this.logCacheError("save", err));
433
+ this.cache.save(result.uid, result).catch((err) => this.logCacheError("save", err));
202
434
  }
435
+ // Process the result to remove any properties that have been scoped with @RequiresScope that the user
436
+ // does not have access to. Done after caching (the cache must retain the full object for other
437
+ // requests) but before the result is returned or broadcast to push subscribers.
438
+ ObjectUtils.deleteScopedProps(result, options?.user, this.modelClass);
203
439
  if (!options?.skipPush) {
204
440
  let channels = [result.uid].concat(options?.pushChannels || []);
205
441
  this.notificationUtils?.sendMessage(channels, this.modelClass.name, "create", result);
@@ -210,6 +446,7 @@ export class RepoUtils {
210
446
  if (!this.repo) {
211
447
  throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
212
448
  }
449
+ const txInfo = this.getTransaction(options);
213
450
  if (this.aclUtils?.enabled && !options.ignoreACL) {
214
451
  const acl = await this.aclUtils.findACL(uid);
215
452
  if (!(await this.aclUtils.hasPermission(options.user, acl ? acl : this.defaultACLUid, ACLAction.DELETE))) {
@@ -218,18 +455,38 @@ export class RepoUtils {
218
455
  }
219
456
  const isRecoverable = this.instantiateObject({}) instanceof RecoverableBaseEntity;
220
457
  const isPurge = isRecoverable ? options.purge || false : true;
458
+ // Delete must be able to target a record regardless of its current `deleted` state (the default) —
459
+ // otherwise an already soft-deleted record could never be purged, nor a soft-delete repeated idempotently.
221
460
  const query = ModelUtils.buildIdSearchQuery(this.repo, this.modelClass, uid, options.version ? Number(options.version) : undefined);
222
461
  // If the object(s) are being permenantly removed from the database do so and then clear the accompanying
223
462
  // ACL(s). If the class type is recoverable and purge isn't desired, simply mark the object(s) as deleted.
224
463
  if (isPurge) {
225
464
  if (this.repo instanceof MongoRepository) {
226
- await this.repo.deleteMany(query);
465
+ await this.repo.deleteMany(query, { session: txInfo?.session });
227
466
  }
228
467
  else {
229
- await this.repo.delete(query.where);
468
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
469
+ await repo.delete(query.where);
230
470
  }
231
471
  if (this.aclUtils?.enabled && this.modelClass.recordACL) {
232
- await this.aclUtils.removeACL(uid);
472
+ // `removeACL()` returns the exact document it deleted (captured atomically, not via a separate
473
+ // earlier read - see its doc comment) - used as the restore snapshot below. `removeACL()`
474
+ // commits independently, on the `acl` connection's own transaction; if this (the entity-side)
475
+ // transaction later fails, its own abort can't undo that removal, so the rollback hook restores
476
+ // the snapshot in that case. `preserveVersion` restores its exact prior version instead of
477
+ // bumping it, and refuses (rather than clobbers) if something already exists at this uid.
478
+ const removedAcl = await this.aclUtils.removeACL(uid);
479
+ if (removedAcl) {
480
+ registerRollbackHook(async () => {
481
+ try {
482
+ await this.aclUtils.saveACL(removedAcl, { preserveVersion: true });
483
+ }
484
+ catch (err) {
485
+ this.logger?.warn(`RepoUtils: Failed to restore ACL ${uid} after a failed delete().`);
486
+ this.logger?.debug(err);
487
+ }
488
+ });
489
+ }
233
490
  }
234
491
  }
235
492
  else {
@@ -238,18 +495,22 @@ export class RepoUtils {
238
495
  $set: {
239
496
  deleted: true,
240
497
  },
241
- });
498
+ }, { session: txInfo?.session });
242
499
  }
243
500
  else {
244
- await this.repo.update(query.where, {
501
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
502
+ await repo.update(query.where, {
245
503
  deleted: true,
246
504
  });
247
505
  }
248
506
  }
249
- if (this.cacheClient && this.modelClass.cacheTTL) {
250
- // Delete the object from cache
251
- void this.cacheClient.del(`${this.baseCacheKey}.${this.hashQuery(query)}`);
252
- void this.cacheClient.del(`${this.baseCacheKey}.${this.hashQuery(this.searchIdQuery(uid))}`);
507
+ if (this.cache) {
508
+ // Delete the object from cache.
509
+ this.cache.delete(uid).catch((err) => this.logCacheError("delete", err));
510
+ this.cache.delete(this.hashQuery(query)).catch((err) => this.logCacheError("delete", err));
511
+ this.cache
512
+ .delete(this.hashQuery(this.searchIdQuery(uid)))
513
+ .catch((err) => this.logCacheError("delete", err));
253
514
  }
254
515
  if (!options?.skipPush) {
255
516
  let channels = [uid].concat(options?.pushChannels || []);
@@ -260,7 +521,7 @@ export class RepoUtils {
260
521
  }
261
522
  }
262
523
  /**
263
- * Retrieves an array of objects from the datastore matching the given search query. This function will first
524
+ * Retrieves an array of objects from the datasource matching the given search query. This function will first
264
525
  * attempt to look up the results in the cache. Also checks ACLs for READ permission.
265
526
  *
266
527
  * @param query The constructed search query to run.
@@ -270,9 +531,13 @@ export class RepoUtils {
270
531
  if (!this.repo) {
271
532
  throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
272
533
  }
273
- // Check user permissions
534
+ const action = options?.action ?? ACLAction.LIST;
535
+ const txInfo = this.getTransaction(options);
536
+ // Check user permissions against the class-level ACL. This is a fast-fail gate for users with no
537
+ // legitimate access to the resource type at all; per-record narrowing (below, right before the
538
+ // results are returned) is an additional layer on top of this, not a replacement for it.
274
539
  if (this.aclUtils?.enabled && !options?.ignoreACL) {
275
- if (!(await this.aclUtils.hasPermission(options?.user, this.defaultACLUid, ACLAction.READ))) {
540
+ if (!(await this.aclUtils.hasPermission(options?.user, this.defaultACLUid, action))) {
276
541
  throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, ApiErrorMessages.AUTH_PERMISSION_FAILURE);
277
542
  }
278
543
  }
@@ -287,88 +552,74 @@ export class RepoUtils {
287
552
  page,
288
553
  });
289
554
  // Pull from the cache if available
290
- if (!options?.skipCache && this.cacheClient && this.modelClass.cacheTTL) {
291
- const json = await this.cacheClient.get(`${this.baseCacheKey}.${searchQueryHash}`);
292
- if (json) {
293
- try {
294
- const uids = JSON.parse(json);
295
- // Attempt to retrieve as many objects from the cache simultaneously
296
- const keys = uids.map((uid) => `${this.baseCacheKey}.${this.hashQuery(this.searchIdQuery(uid))}`);
297
- // Retrieve all objects from the cache (if possible)
298
- const cresults = await this.cacheClient.mget(...keys);
299
- // Check for any missing objects that weren't in the cache. Build a list of missing
300
- // items that we can retrieve from the database directly.
301
- const missingUids = [];
302
- for (let i = 0; i < cresults.length; i++) {
303
- if (cresults[i] === null) {
304
- missingUids.push(cresults[i]);
305
- }
306
- }
307
- // Now pull all missing items from the database
308
- let missing = [];
309
- if (missingUids.length > 0) {
310
- if (this.repo instanceof MongoRepository) {
311
- missing = await this.repo.find({ uid: { $in: missingUids } }).toArray();
312
- }
313
- else {
314
- const { In } = ModelUtils.orm;
315
- missing = await this.repo.find({ where: { uid: In(missingUids) } });
316
- }
317
- }
318
- // Merge the results back into the results array. We iterate through cresults to preserve
319
- // the order of the original query.
320
- for (let i = 0; i < cresults.length; i++) {
321
- if (cresults[i] !== null) {
322
- results.push(JSON.parse(cresults[i]));
323
- }
324
- else {
325
- // Find the desired object in the missing array
326
- for (const obj of missing) {
327
- if (obj.uid === missingUids[i]) {
328
- results.push(obj);
329
- break;
330
- }
331
- }
332
- }
333
- }
334
- // Populate the cache with all missing objects
335
- for (const obj of missing) {
336
- const query = this.searchIdQuery(obj.uid);
337
- const cacheKey = `${this.baseCacheKey}.${this.hashQuery(query)}`;
338
- void this.cacheClient.setex(cacheKey, this.modelClass.cacheTTL, JSON.stringify(obj));
339
- }
340
- }
341
- catch (err) {
342
- // It doesn't matter if this fails
343
- }
555
+ if (!options?.skipCache && this.cache) {
556
+ const cached = await this.cache.loadSet(searchQueryHash);
557
+ if (cached) {
558
+ results = cached.filter((obj) => obj !== undefined);
344
559
  }
345
560
  }
346
561
  // If the query wasn't cached retrieve from the database
347
562
  if (results.length === 0) {
563
+ const searchQuery = ModelUtils.buildSearchQuery(this.modelClass, this.repo, query, true, options?.user);
348
564
  if (this.repo instanceof MongoRepository) {
349
565
  const skip = page * limit;
350
- if (Array.isArray(query)) {
351
- results = await this.repo.aggregate(query).skip(skip).limit(limit).toArray();
566
+ if (Array.isArray(searchQuery)) {
567
+ results = await this.repo
568
+ .aggregate(searchQuery, { session: txInfo?.session })
569
+ .skip(skip)
570
+ .limit(limit)
571
+ .toArray();
352
572
  }
353
573
  else {
354
574
  results = await this.repo
355
- .find(query["$match"] ? query["$match"] : query, {
575
+ .find(searchQuery["$match"] ? searchQuery["$match"] : searchQuery, {
356
576
  limit,
577
+ session: txInfo?.session,
357
578
  skip,
358
- sort: query["$sort"],
579
+ sort: searchQuery["$sort"],
359
580
  })
360
581
  .toArray();
361
582
  }
362
583
  }
363
584
  else {
364
- results = await this.repo.find(query);
585
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
586
+ // `searchQuery.page` (set by `buildSearchQuerySQL`) isn't a TypeORM find option and is silently
587
+ // ignored by `repo.find()` - it must be translated to `skip` here, the same way the Mongo branch
588
+ // above translates `page` into its own `skip`, or every SQL page request returns page 0.
589
+ results = (await repo.find({ ...searchQuery, skip: page * limit }));
365
590
  }
366
591
  // Cache the results for future requests. Don't bother if there were no results.
367
- if (results.length > 0 && this.cacheClient && this.modelClass.cacheTTL) {
368
- const uids = results.map((obj) => obj.uid);
369
- void this.cacheClient.setex(`${this.baseCacheKey}.${searchQueryHash}`, this.modelClass.cacheTTL, JSON.stringify(uids));
592
+ if (results.length > 0 && this.cache) {
593
+ this.cache.saveSet(searchQueryHash, results).catch((err) => this.logCacheError("saveSet", err));
594
+ // Also seed each individual object's own cache entry.
595
+ const ids = results.map((obj) => this.hashQuery(this.searchIdQuery(obj.uid)));
596
+ this.cache.saveMany(ids, results).catch((err) => this.logCacheError("saveMany", err));
370
597
  }
371
598
  }
599
+ // Record-level ACLs aren't reflected in the query itself (nor in cached results, which are shared across
600
+ // users), so each matched record must be checked individually before it's returned to the caller. The
601
+ // checks are run concurrently, and share a request-scoped ACL cache, so that a page of N results costs
602
+ // at most one round trip per *distinct* ACL uid (typically just the shared parent, since per-record
603
+ // ACLs are rarely warm in Redis) instead of N sequential round trips.
604
+ //
605
+ // A soft-deleted row can appear in `results` despite `buildSearchQuery()`'s default exclusion via a client
606
+ // supplying its own `deleted` query param, which the query builder honors as-is. Such a row needs the
607
+ // same DELETE+UPDATE permission.
608
+ if (this.aclUtils?.enabled && !options?.ignoreACL) {
609
+ const recordACL = !!this.modelClass.recordACL;
610
+ const permitted = await Promise.all(results.map((obj) => {
611
+ if (obj.deleted === true) {
612
+ return this.canViewDeleted(options?.user, recordACL ? obj.uid : this.defaultACLUid);
613
+ }
614
+ return recordACL
615
+ ? this.aclUtils.hasPermission(options?.user, obj.uid, action)
616
+ : Promise.resolve(true);
617
+ }));
618
+ results = results.filter((_obj, i) => permitted[i]);
619
+ }
620
+ // Process the results to remove any properties that have been scoped with @RequiresScope that the user
621
+ // does not have access to.
622
+ ObjectUtils.deleteScopedProps(results, options?.user, this.modelClass);
372
623
  return results;
373
624
  }
374
625
  /**
@@ -382,39 +633,78 @@ export class RepoUtils {
382
633
  if (!this.repo) {
383
634
  throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
384
635
  }
636
+ let existing = undefined;
637
+ const txInfo = this.getTransaction(options);
638
+ // Deliberately uses the default (includeDeleted: true) query shape here — this result is cached under
639
+ // a key shared with create()/update()/find()'s cache-seeding, all of which also use the default shape,
640
+ // so changing it here alone would desync this read from what those write. Soft-deleted records are
641
+ // filtered out below instead, after the cache/DB read, regardless of which one produced the result.
385
642
  const query = this.searchIdQuery(id, options?.version);
386
- if (!options?.skipCache && this.cacheClient && this.modelClass.cacheTTL) {
387
- // First attempt to retrieve the object from the cache
388
- const json = await this.cacheClient.get(`${this.baseCacheKey}.${this.hashQuery(query)}`);
389
- if (json) {
390
- try {
391
- const existing = JSON.parse(json);
392
- if (existing) {
393
- return existing;
643
+ if (!options?.skipCache && this.cache) {
644
+ existing = await this.cache.load(this.hashQuery(query));
645
+ }
646
+ if (!existing) {
647
+ if (this.repo instanceof MongoRepository) {
648
+ existing = await this.repo
649
+ .find(query["$match"] ? query["$match"] : query, {
650
+ session: txInfo?.session,
651
+ sort: { version: -1 },
652
+ })
653
+ .next();
654
+ }
655
+ else if (this.modelClass.prototype instanceof BaseEntity) {
656
+ // Without an explicit version, `query` matches every row sharing this uid (all historical
657
+ // versions, for a trackChanges entity). Order by version desc so the newest one wins, the same
658
+ // way the Mongo branch above does - otherwise TypeORM's `findOne()` returns whichever matching
659
+ // row it encounters first, which isn't guaranteed to be the latest. `SimpleEntity` has no
660
+ // `version` column to order by, so this only applies to `BaseEntity` subclasses.
661
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
662
+ existing = (await repo.findOne({ ...query, order: { version: "DESC" } }));
663
+ }
664
+ else {
665
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
666
+ existing = (await repo.findOne(query));
667
+ }
668
+ }
669
+ // Never surface a soft-deleted record via an id-based lookup by default — matches the default the
670
+ // list/search endpoint already applies. `includeDeleted` opts back in (e.g. an admin history/restore
671
+ // view fetching a specific past version by id). Checked here (after cache or DB resolution) rather
672
+ // than by filtering `deleted` into the query above, so a cache entry that predates a delete, or was
673
+ // seeded by an explicit `?deleted=true` list request, is filtered consistently either way.
674
+ if (existing && existing.deleted === true && !options?.includeDeleted) {
675
+ existing = null;
676
+ }
677
+ if (existing) {
678
+ if (this.cache) {
679
+ // Cache the object for faster retrieval
680
+ this.cache.save(this.hashQuery(query), existing).catch((err) => this.logCacheError("save", err));
681
+ }
682
+ // Check user permissions
683
+ if (this.aclUtils?.enabled && !options?.ignoreACL) {
684
+ const acl = await this.aclUtils.findACL(existing.uid);
685
+ if (existing.deleted === true) {
686
+ // Viewing a soft-deleted record (opted into via `includeDeleted`) requires both DELETE and UPDATE
687
+ // permission, the two actions actually needed to restore the record.
688
+ if (!(await this.canViewDeleted(options?.user, acl ? acl : this.defaultACLUid))) {
689
+ existing = null;
394
690
  }
395
691
  }
396
- catch (err) {
397
- // It doesn't matter if this fails
692
+ else {
693
+ const action = options?.action ?? ACLAction.READ;
694
+ if (!(await this.aclUtils.hasPermission(options?.user, acl ? acl : this.defaultACLUid, action))) {
695
+ throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, ApiErrorMessages.AUTH_PERMISSION_FAILURE);
696
+ }
398
697
  }
399
698
  }
400
699
  }
401
- let existing = null;
402
- if (this.repo instanceof MongoRepository) {
403
- existing = await this.repo
404
- .find(query["$match"] ? query["$match"] : query, {
405
- sort: { version: -1 },
406
- })
407
- .next();
408
- }
409
- else {
410
- existing = await this.repo.findOne(query);
411
- }
412
- if (existing && this.cacheClient && this.modelClass.cacheTTL) {
413
- // Cache the object for faster retrieval
414
- void this.cacheClient.setex(`${this.baseCacheKey}.${this.hashQuery(query)}`, this.modelClass.cacheTTL, JSON.stringify(existing));
700
+ const result = existing ? this.instantiateObject(existing) : undefined;
701
+ // Process the result to remove any properties that have been scoped with @RequiresScope that the user
702
+ // does not have access to.
703
+ if (result) {
704
+ ObjectUtils.deleteScopedProps(result, options?.user, this.modelClass);
415
705
  }
416
706
  // Make sure we return the correct data type
417
- return existing ? this.instantiateObject(existing) : undefined;
707
+ return result;
418
708
  }
419
709
  /**
420
710
  * Returns the default access control list governing the model type. Returning a value of `undefined` will grant
@@ -432,13 +722,47 @@ export class RepoUtils {
432
722
  }
433
723
  return result;
434
724
  }
725
+ /**
726
+ * Checks whether the caller has both `DELETE` and `UPDATE` permission against the given ACL (or ACL uid).
727
+ * Ordinary READ/LIST/EXISTS permission on a record says nothing about whether its owner
728
+ * consented to its "deleted" state being visible, so those two actions (the ones actually needed to
729
+ * restore the record) are required instead.
730
+ *
731
+ * @param user The user to check.
732
+ * @param acl The ACL (or ACL uid) governing the record.
733
+ */
734
+ async canViewDeleted(user, acl) {
735
+ return ((await this.aclUtils.hasPermission(user, acl, ACLAction.DELETE)) &&
736
+ (await this.aclUtils.hasPermission(user, acl, ACLAction.UPDATE)));
737
+ }
738
+ /**
739
+ * Logs a swallowed error from a fire-and-forget cache operation. Cache reads/writes are best-effort and
740
+ * must never propagate into (and thus fail/retry) the write they're attached to.
741
+ * @param op The cache operation that failed (e.g. "save", "delete").
742
+ * @param err The error thrown by the cache operation.
743
+ */
744
+ logCacheError(op, err) {
745
+ this.logger?.warn(`RepoUtils: Cache ${op} failed for ${this.modelClass?.name}.`);
746
+ this.logger?.debug(err);
747
+ }
435
748
  /**
436
749
  * Hashes the given query object to a unique string.
437
750
  * @param query The query object to hash.
438
751
  */
439
752
  hashQuery(query) {
440
753
  const queryStr = JSON.stringify(query);
441
- return crypto.createHash("md5").update(queryStr).digest("hex");
754
+ let hash = _hashCache.get(queryStr);
755
+ if (hash === undefined) {
756
+ // Hash the query string
757
+ hash = crypto.createHash("md5").update(queryStr).digest("hex");
758
+ // Clear the hash cache if it grows too big to prevent runaway memory usage
759
+ if (_hashCache.size >= 10000) {
760
+ _hashCache.clear();
761
+ }
762
+ // Store the hashed query string for faster lookup next time
763
+ _hashCache.set(queryStr, hash);
764
+ }
765
+ return hash;
442
766
  }
443
767
  /**
444
768
  * Returns the class type (constructor) for the given object. This uses the `_fqn` or `_type` property of `obj` to
@@ -449,14 +773,28 @@ export class RepoUtils {
449
773
  */
450
774
  getClassType(obj) {
451
775
  const className = obj._fqn || obj._type;
452
- if (this.objectFactory) {
776
+ if (this._objectFactory) {
453
777
  if (className && typeof className === "string") {
454
- const clazz = this.objectFactory.classes.get(className) || this.objectFactory.classes.get(`models.${className}`);
455
- return clazz;
778
+ const clazz = this._objectFactory.classes.get(className) ||
779
+ this._objectFactory.classes.get(`models.${className}`);
780
+ // Only accept the resolved class if it's actually this route's model or a subtype of it (e.g. a
781
+ // @ChildEntity()). `objectFactory.classes` contains every registered model in the app, so without
782
+ // this check a client could point `_type`/`_fqn` at an unrelated model to have its payload
783
+ // instantiated/validated against that other model's (possibly much looser) rules while still
784
+ // being persisted through this route's own datasource/collection.
785
+ if (clazz && (clazz === this.modelClass || clazz.prototype instanceof this.modelClass)) {
786
+ return clazz;
787
+ }
456
788
  }
457
789
  }
458
790
  return this.modelClass;
459
791
  }
792
+ /**
793
+ * Returns the current transactional session information, if present.
794
+ */
795
+ getTransaction(options) {
796
+ return options?.transaction ?? transactionContext.getStore();
797
+ }
460
798
  /**
461
799
  * Creates a new instance of obj scoped to the correct model class or sub-class.
462
800
  */
@@ -470,55 +808,81 @@ export class RepoUtils {
470
808
  * Search for existing object based on passed in id and version and product uid.
471
809
  *
472
810
  * The result of this function is compatible with all `Repository.find()` functions.
811
+ *
812
+ * @param includeDeleted Set to false to exclude soft-deleted `RecoverableBaseEntity` records from matching.
813
+ * Defaults to true; pass false when the result is exposed directly to an API client (e.g. `findOne`, `exists`)
814
+ * so a soft-deleted record isn't returned as if it still existed.
473
815
  */
474
- searchIdQuery(id, version) {
475
- return ModelUtils.buildIdSearchQuery(this.repo, this.modelClass, id, typeof version === "string" ? parseInt(version, 10) : version);
816
+ searchIdQuery(id, version, includeDeleted = true) {
817
+ return ModelUtils.buildIdSearchQuery(this.repo, this.modelClass, id, typeof version === "string" ? parseInt(version, 10) : version, includeDeleted);
476
818
  }
477
819
  async truncate(query, options) {
478
820
  if (!this.repo) {
479
821
  throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
480
822
  }
823
+ const txInfo = this.getTransaction(options);
481
824
  // Check user permissions. Don't check if record-level ACLs are used as this will be done
482
825
  // per record later.
483
826
  if (this.aclUtils?.enabled && !options.ignoreACL && !this.modelClass.recordACL) {
484
- if (!(await this.aclUtils.hasPermission(options.user, this.defaultACLUid, ACLAction.DELETE))) {
827
+ if (!(await this.aclUtils.hasPermission(options.user, this.defaultACLUid, ACLAction.TRUNCATE))) {
485
828
  throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, ApiErrorMessages.AUTH_PERMISSION_FAILURE);
486
829
  }
487
830
  }
488
831
  try {
489
- let uids = [];
490
- if (this.repo instanceof MongoRepository) {
491
- if (Array.isArray(query)) {
492
- uids = await this.repo.distinct("uid", query[0].$match);
493
- }
494
- else {
495
- uids = await this.repo.distinct("uid", query["$match"] ? query["$match"] : query);
496
- }
497
- }
498
- else {
499
- (await this.repo.find(query)).forEach((obj) => uids.push(obj.uid));
500
- }
832
+ const searchQuery = ModelUtils.buildSearchQuery(this.modelClass, this.repo, query, true, options?.user);
833
+ const uids = await this.findAllUids(searchQuery, options);
501
834
  if (uids.length > 0) {
502
835
  let finalUids = uids;
503
836
  // Check if this class uses record level ACLs. If so, we need to check the perms of
504
837
  // each one. We will remove any from our list that the user does not have permission to
505
- // delete.
838
+ // truncate.
506
839
  if (this.aclUtils?.enabled && this.modelClass.recordACL) {
507
- finalUids = [];
508
- for (const uid of uids) {
509
- if (!options.ignoreACL) {
510
- if (await this.aclUtils.hasPermission(options.user, uid, ACLAction.DELETE)) {
511
- finalUids.push(uid);
512
- }
513
- }
840
+ if (options.ignoreACL) {
841
+ // Caller has already authorized this truncate; skip the per-record permission
842
+ // narrowing entirely rather than defaulting to an empty (i.e. no-op) delete set.
843
+ finalUids = uids;
844
+ }
845
+ else {
846
+ finalUids = await this.filterPermittedUids(uids, ACLAction.TRUNCATE, options);
514
847
  }
515
848
  }
849
+ const cleansUpRecordACLs = !!(this.aclUtils?.enabled && this.modelClass.recordACL);
516
850
  // Now delete all records that were found
517
851
  if (this.repo instanceof MongoRepository) {
518
- await this.repo.deleteMany({ uid: { $in: finalUids } });
852
+ await this.repo.deleteMany({ uid: { $in: finalUids } }, {
853
+ session: txInfo?.session,
854
+ });
519
855
  }
520
856
  else {
521
- await this.repo.delete(finalUids);
857
+ const repo = txInfo?.entityManager
858
+ ? txInfo.entityManager.getRepository(this.modelClass)
859
+ : this.repo;
860
+ // A plain array of ids only maps to a WHERE ... IN clause when the primary key is a single
861
+ // column — for a trackChanges entity the SQL primary key is the composite (uid, version),
862
+ // so an explicit In() on the uid column is used instead of relying on that implicit form.
863
+ const { In } = ModelUtils.orm;
864
+ await repo.delete({ uid: In(finalUids) });
865
+ }
866
+ if (cleansUpRecordACLs && finalUids.length > 0) {
867
+ // `removeACLs()` returns exactly what it deleted (captured atomically, not via a separate
868
+ // earlier read - see `removeACL()`'s doc comment) - used directly as the restore snapshot.
869
+ // It commits independently, on the `acl` connection's own transaction; if this (entity-side)
870
+ // transaction later fails, its own abort can't undo that removal, so the rollback hook
871
+ // restores the snapshot in that case. `saveACLs()` restores each ACL's exact prior version
872
+ // (see `saveACL()`'s `preserveVersion` option) and refuses — rather than clobbers — any of
873
+ // these uids that already has something at it again by the time the restore runs.
874
+ const removedAcls = await this.aclUtils.removeACLs(finalUids);
875
+ if (removedAcls.length > 0) {
876
+ registerRollbackHook(async () => {
877
+ try {
878
+ await this.aclUtils.saveACLs(removedAcls);
879
+ }
880
+ catch (err) {
881
+ this.logger?.warn(`RepoUtils: Failed to restore ${removedAcls.length} ACL(s) after a failed truncate().`);
882
+ this.logger?.debug(err);
883
+ }
884
+ });
885
+ }
522
886
  }
523
887
  if (!options?.skipPush) {
524
888
  let channels = options?.pushChannels || [];
@@ -543,6 +907,7 @@ export class RepoUtils {
543
907
  if (!this.repo) {
544
908
  throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
545
909
  }
910
+ const txInfo = this.getTransaction(options);
546
911
  if (this.aclUtils?.enabled && !options?.ignoreACL) {
547
912
  const acl = await this.aclUtils.findACL(existing.uid);
548
913
  if (!(await this.aclUtils.hasPermission(options?.user, acl ? acl : this.defaultACLUid, ACLAction.UPDATE))) {
@@ -559,6 +924,15 @@ export class RepoUtils {
559
924
  if (existing.uid !== obj.uid) {
560
925
  throw new ApiError(ApiErrors.OBJECT_ID_MISMATCH, 400, ApiErrorMessages.OBJECT_ID_MISMATCH);
561
926
  }
927
+ // Force system-managed fields back to their persisted value, discarding whatever the client sent (or
928
+ // didn't send) for them. `dateCreated` is always protected; `@ReadOnly`-decorated properties are an
929
+ // app-level opt-in for anything else (roles, ownership fields, etc.) that must never be client-settable.
930
+ if (existing instanceof BaseEntity) {
931
+ obj.dateCreated = existing.dateCreated;
932
+ }
933
+ for (const prop of ModelUtils.getReadOnlyPropertyNames(this.modelClass)) {
934
+ obj[prop] = existing[prop];
935
+ }
562
936
  // When using MongoDB we need to copy the _id property in order to prevent duplicate entries
563
937
  if (existing instanceof BaseMongoEntity) {
564
938
  obj._id = existing._id;
@@ -569,12 +943,24 @@ export class RepoUtils {
569
943
  if (this.repo instanceof MongoRepository) {
570
944
  if (existing instanceof BaseEntity) {
571
945
  if (keepPrevious) {
572
- result = this.instantiateObject(await this.repo.save({
573
- ...obj,
574
- _id: undefined, // Ensure we save a new document
575
- dateModified: new Date(),
576
- version: obj.version + 1,
577
- }));
946
+ // Same (uid, version) unique index race as RepoUtils.create(): two concurrent updates of
947
+ // the same version can both pass the optimistic-lock check above and both attempt to
948
+ // insert (uid, version + 1). The database rejects the loser; treat that the same as a
949
+ // lost optimistic-lock race rather than letting the raw duplicate-key error escape.
950
+ try {
951
+ result = this.instantiateObject(await this.repo.save({
952
+ ...obj,
953
+ _id: undefined, // Ensure we save a new document
954
+ dateModified: new Date(),
955
+ version: obj.version + 1,
956
+ }, { session: txInfo?.session }));
957
+ }
958
+ catch (err) {
959
+ if (err?.code === 11000) {
960
+ throw new ApiError(ApiErrors.INVALID_OBJECT_VERSION, 409, ApiErrorMessages.INVALID_OBJECT_VERSION);
961
+ }
962
+ throw err;
963
+ }
578
964
  }
579
965
  else {
580
966
  await this.repo.updateOne({ uid: obj.uid, version: obj.version }, {
@@ -583,22 +969,32 @@ export class RepoUtils {
583
969
  dateModified: new Date(),
584
970
  version: obj.version + 1,
585
971
  },
972
+ }, {
973
+ session: txInfo?.session,
586
974
  });
587
975
  }
588
976
  }
589
977
  else if (obj.uid) {
590
978
  if (keepPrevious) {
591
- result = this.instantiateObject(await this.repo.save({
592
- ...obj,
593
- version: obj.version + 1,
594
- }));
979
+ try {
980
+ result = this.instantiateObject(await this.repo.save({
981
+ ...obj,
982
+ version: obj.version + 1,
983
+ }, { session: txInfo?.session }));
984
+ }
985
+ catch (err) {
986
+ if (err?.code === 11000) {
987
+ throw new ApiError(ApiErrors.INVALID_OBJECT_VERSION, 409, ApiErrorMessages.INVALID_OBJECT_VERSION);
988
+ }
989
+ throw err;
990
+ }
595
991
  }
596
992
  else {
597
993
  await this.repo.updateOne({ uid: obj.uid }, {
598
994
  $set: {
599
995
  ...obj,
600
996
  },
601
- });
997
+ }, { session: txInfo?.session });
602
998
  }
603
999
  }
604
1000
  else {
@@ -606,20 +1002,21 @@ export class RepoUtils {
606
1002
  if (keepPrevious) {
607
1003
  toSave.version += 1;
608
1004
  }
609
- result = await this.repo.save(toSave);
1005
+ result = await this.repo.save(toSave, { session: txInfo?.session });
610
1006
  }
611
1007
  }
612
1008
  else {
1009
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
613
1010
  if (existing instanceof BaseEntity) {
614
1011
  if (keepPrevious) {
615
- await this.repo.insert({
1012
+ await repo.insert({
616
1013
  ...obj,
617
1014
  dateModified: new Date(),
618
1015
  version: obj.version + 1,
619
1016
  });
620
1017
  }
621
1018
  else {
622
- await this.repo.update(query.where, {
1019
+ await repo.update(query.where, {
623
1020
  ...obj,
624
1021
  dateModified: new Date(),
625
1022
  version: obj.version + 1,
@@ -630,31 +1027,43 @@ export class RepoUtils {
630
1027
  const toSave = obj;
631
1028
  if (keepPrevious) {
632
1029
  toSave.version += 1;
633
- result = await this.repo.save(toSave);
1030
+ // TypeORM's overloaded Repository.save() can't be resolved against `repo`'s inferred
1031
+ // `EntityManager | Repository<T>` union type — same class of friction as the "HAX" cast
1032
+ // above.
1033
+ result = await repo.save(toSave);
634
1034
  }
635
1035
  else {
636
- await this.repo.update(query.where, toSave);
1036
+ await repo.update(query.where, toSave);
637
1037
  }
638
1038
  }
639
1039
  }
640
- query = this.searchIdQuery(existing.uid, obj instanceof BaseEntity ? obj.version + 1 : undefined);
1040
+ query = this.searchIdQuery(existing.uid, existing instanceof BaseEntity ? existing.version + 1 : undefined);
641
1041
  if (!result) {
642
1042
  if (this.repo instanceof MongoRepository) {
643
- result = await this.repo.findOne(query["$match"] ? query["$match"] : query);
1043
+ result = await this.repo.findOne(query["$match"] ? query["$match"] : query, {
1044
+ session: txInfo?.session,
1045
+ });
644
1046
  }
645
1047
  else {
646
- result = await this.repo.findOne(query);
1048
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
1049
+ result = (await repo.findOne(query));
647
1050
  }
648
1051
  if (!result) {
649
1052
  throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
650
1053
  }
651
1054
  }
652
1055
  result = this.instantiateObject(result);
653
- if (result && this.cacheClient && this.modelClass.cacheTTL) {
654
- // Cache the object for faster retrieval
655
- void this.cacheClient.setex(`${this.baseCacheKey}.${this.hashQuery(query)}`, this.modelClass.cacheTTL, JSON.stringify(result));
656
- void this.cacheClient.setex(`${this.baseCacheKey}.${this.hashQuery(this.searchIdQuery(result.uid))}`, this.modelClass.cacheTTL, JSON.stringify(result));
1056
+ if (result && this.cache) {
1057
+ // Cache the object for faster retrieval.
1058
+ this.cache.save(this.hashQuery(query), result).catch((err) => this.logCacheError("save", err));
1059
+ this.cache
1060
+ .save(this.hashQuery(this.searchIdQuery(result.uid)), result)
1061
+ .catch((err) => this.logCacheError("save", err));
657
1062
  }
1063
+ // Process the result to remove any properties that have been scoped with @RequiresScope that the user
1064
+ // does not have access to. Done after caching (the cache must retain the full object for other
1065
+ // requests) but before the result is returned or broadcast to push subscribers.
1066
+ ObjectUtils.deleteScopedProps(result, options?.user, this.modelClass);
658
1067
  if (!options?.skipPush) {
659
1068
  let channels = [result.uid].concat(options?.pushChannels || []);
660
1069
  this.notificationUtils?.sendMessage(channels, this.modelClass.name, "update", result);
@@ -676,18 +1085,31 @@ export class RepoUtils {
676
1085
  // Instantiate the correct object type so that we can perform validation correctly. If we don't do this
677
1086
  // then the provided object will be missing all decorators and validation won't work as desired.
678
1087
  const metadataObj = this.instantiateObject(obj);
1088
+ // A separate, genuinely bare instance (constructed with no data at all) to source @ReadOnly
1089
+ // defaults from. `metadataObj` isn't safe for this: it's hydrated from the client-supplied
1090
+ // `obj`, and a model constructor that copies a same-named field from its `other` argument
1091
+ // (a common "hydrate from data" pattern) would carry the client's tampered value straight
1092
+ // through to `metadataObj` too, making the "reset to default" below a no-op.
1093
+ const defaultObj = this.instantiateObject(undefined, metadataObj.constructor);
679
1094
  ObjectUtils.validate(obj, metadataObj.constructor);
680
- // Iterate through all properties and look for `@Reference`
1095
+ // Iterate through all properties
681
1096
  for (const member of Object.getOwnPropertyNames(obj)) {
1097
+ // Reset any @ReadOnly properties, discarding whatever the client supplied.
1098
+ const isReadOnly = Reflect.getMetadata("rrst:readOnly", metadataObj, member);
1099
+ if (member in obj && isReadOnly) {
1100
+ // Override the value from our default object
1101
+ obj[member] = defaultObj[member];
1102
+ }
1103
+ // Check for @Reference
682
1104
  const clazz = Reflect.getMetadata("rrst:reference", metadataObj, member);
683
- if (clazz && clazz.datastore && obj[member]) {
1105
+ if (clazz && clazz.datasource && obj[member]) {
684
1106
  // Attempt to grab the repository for this reference type
685
- const conn = this.connectionManager?.connections.get(clazz.datastore);
1107
+ const conn = this.connectionManager?.connections.get(clazz.datasource);
686
1108
  const repo = conn instanceof MongoConnection || isSqlDataSource(conn)
687
1109
  ? conn.getRepository(clazz)
688
1110
  : undefined;
689
1111
  if (repo) {
690
- // Check to see if there are any objects with this UID in the datastore. If the value is an array
1112
+ // Check to see if there are any objects with this UID in the datasource. If the value is an array
691
1113
  // let's make sure that every uid is valid.
692
1114
  const uids = Array.isArray(obj[member]) ? obj[member] : [obj[member]];
693
1115
  const query = ModelUtils.buildIdSearchQuery(repo, clazz, uids);
@@ -701,7 +1123,7 @@ export class RepoUtils {
701
1123
  }
702
1124
  }
703
1125
  catch (err) {
704
- throw new ApiError(ApiErrorMessages.INVALID_REQUEST, 400, err.message);
1126
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, err.message);
705
1127
  }
706
1128
  }
707
1129
  }
@@ -709,10 +1131,6 @@ __decorate([
709
1131
  Inject("ACLUtils"),
710
1132
  __metadata("design:type", Function)
711
1133
  ], RepoUtils.prototype, "aclUtils", void 0);
712
- __decorate([
713
- RedisConnection("cache"),
714
- __metadata("design:type", Redis)
715
- ], RepoUtils.prototype, "cacheClient", void 0);
716
1134
  __decorate([
717
1135
  Config(),
718
1136
  __metadata("design:type", Object)
@@ -725,10 +1143,6 @@ __decorate([
725
1143
  Logger,
726
1144
  __metadata("design:type", Object)
727
1145
  ], RepoUtils.prototype, "logger", void 0);
728
- __decorate([
729
- Inject(ObjectFactory),
730
- __metadata("design:type", ObjectFactory)
731
- ], RepoUtils.prototype, "objectFactory", void 0);
732
1146
  __decorate([
733
1147
  Inject(NotificationUtils),
734
1148
  __metadata("design:type", NotificationUtils)
@@ -743,4 +1157,28 @@ __decorate([
743
1157
  __metadata("design:paramtypes", []),
744
1158
  __metadata("design:returntype", Promise)
745
1159
  ], RepoUtils.prototype, "init", null);
1160
+ __decorate([
1161
+ Transactional(),
1162
+ __metadata("design:type", Function),
1163
+ __metadata("design:paramtypes", [Object, Object]),
1164
+ __metadata("design:returntype", Promise)
1165
+ ], RepoUtils.prototype, "create", null);
1166
+ __decorate([
1167
+ Transactional(),
1168
+ __metadata("design:type", Function),
1169
+ __metadata("design:paramtypes", [String, Object]),
1170
+ __metadata("design:returntype", Promise)
1171
+ ], RepoUtils.prototype, "delete", null);
1172
+ __decorate([
1173
+ Transactional(),
1174
+ __metadata("design:type", Function),
1175
+ __metadata("design:paramtypes", [Object, Object]),
1176
+ __metadata("design:returntype", Promise)
1177
+ ], RepoUtils.prototype, "truncate", null);
1178
+ __decorate([
1179
+ Transactional(),
1180
+ __metadata("design:type", Function),
1181
+ __metadata("design:paramtypes", [Object, Object, Object]),
1182
+ __metadata("design:returntype", Promise)
1183
+ ], RepoUtils.prototype, "update", null);
746
1184
  //# sourceMappingURL=RepoUtils.js.map