@rapidrest/service-core 1.0.0-rc.9 → 1.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 (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 -213
  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,98 +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(uids[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
- }
335
- catch (err) {
336
- // It doesn't matter if this fails
337
- }
555
+ if (!options?.skipCache && this.cache) {
556
+ const cached = await this.cache.loadSet(searchQueryHash);
557
+ if (cached) {
558
+ results = cached.filter((obj) => obj !== undefined);
338
559
  }
339
560
  }
340
561
  // If the query wasn't cached retrieve from the database
341
562
  if (results.length === 0) {
563
+ const searchQuery = ModelUtils.buildSearchQuery(this.modelClass, this.repo, query, true, options?.user);
342
564
  if (this.repo instanceof MongoRepository) {
343
565
  const skip = page * limit;
344
- if (Array.isArray(query)) {
345
- 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();
346
572
  }
347
573
  else {
348
574
  results = await this.repo
349
- .find(query["$match"] ? query["$match"] : query, {
575
+ .find(searchQuery["$match"] ? searchQuery["$match"] : searchQuery, {
350
576
  limit,
577
+ session: txInfo?.session,
351
578
  skip,
352
- sort: query["$sort"],
579
+ sort: searchQuery["$sort"],
353
580
  })
354
581
  .toArray();
355
582
  }
356
583
  }
357
584
  else {
358
- 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 }));
359
590
  }
360
591
  // Cache the results for future requests. Don't bother if there were no results.
361
- if (results.length > 0 && this.cacheClient && this.modelClass.cacheTTL) {
362
- const cmds = [];
363
- // Add the query to the cache with only the list of uids. This ensures that changes to the underlying
364
- // objects stays accurate.
365
- const uids = results.map((obj) => obj.uid);
366
- cmds.push([
367
- "setex",
368
- `${this.baseCacheKey}.${searchQueryHash}`,
369
- this.modelClass.cacheTTL,
370
- JSON.stringify(uids),
371
- ]);
372
- // Also seed individual object caches so the next request is fully cache-warm
373
- for (const obj of results) {
374
- const query = this.searchIdQuery(obj.uid);
375
- const cacheKey = `${this.baseCacheKey}.${this.hashQuery(query)}`;
376
- cmds.push(["setex", cacheKey, this.modelClass.cacheTTL, JSON.stringify(obj)]);
377
- }
378
- // Now send all commands to redis at once
379
- void this.cacheClient.multi(cmds);
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));
380
597
  }
381
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);
382
623
  return results;
383
624
  }
384
625
  /**
@@ -392,39 +633,78 @@ export class RepoUtils {
392
633
  if (!this.repo) {
393
634
  throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
394
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.
395
642
  const query = this.searchIdQuery(id, options?.version);
396
- if (!options?.skipCache && this.cacheClient && this.modelClass.cacheTTL) {
397
- // First attempt to retrieve the object from the cache
398
- const json = await this.cacheClient.get(`${this.baseCacheKey}.${this.hashQuery(query)}`);
399
- if (json) {
400
- try {
401
- const existing = JSON.parse(json);
402
- if (existing) {
403
- 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;
404
690
  }
405
691
  }
406
- catch (err) {
407
- // 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
+ }
408
697
  }
409
698
  }
410
699
  }
411
- let existing = null;
412
- if (this.repo instanceof MongoRepository) {
413
- existing = await this.repo
414
- .find(query["$match"] ? query["$match"] : query, {
415
- sort: { version: -1 },
416
- })
417
- .next();
418
- }
419
- else {
420
- existing = await this.repo.findOne(query);
421
- }
422
- if (existing && this.cacheClient && this.modelClass.cacheTTL) {
423
- // Cache the object for faster retrieval
424
- 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);
425
705
  }
426
706
  // Make sure we return the correct data type
427
- return existing ? this.instantiateObject(existing) : undefined;
707
+ return result;
428
708
  }
429
709
  /**
430
710
  * Returns the default access control list governing the model type. Returning a value of `undefined` will grant
@@ -442,13 +722,47 @@ export class RepoUtils {
442
722
  }
443
723
  return result;
444
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
+ }
445
748
  /**
446
749
  * Hashes the given query object to a unique string.
447
750
  * @param query The query object to hash.
448
751
  */
449
752
  hashQuery(query) {
450
753
  const queryStr = JSON.stringify(query);
451
- 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;
452
766
  }
453
767
  /**
454
768
  * Returns the class type (constructor) for the given object. This uses the `_fqn` or `_type` property of `obj` to
@@ -459,14 +773,28 @@ export class RepoUtils {
459
773
  */
460
774
  getClassType(obj) {
461
775
  const className = obj._fqn || obj._type;
462
- if (this.objectFactory) {
776
+ if (this._objectFactory) {
463
777
  if (className && typeof className === "string") {
464
- const clazz = this.objectFactory.classes.get(className) || this.objectFactory.classes.get(`models.${className}`);
465
- 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
+ }
466
788
  }
467
789
  }
468
790
  return this.modelClass;
469
791
  }
792
+ /**
793
+ * Returns the current transactional session information, if present.
794
+ */
795
+ getTransaction(options) {
796
+ return options?.transaction ?? transactionContext.getStore();
797
+ }
470
798
  /**
471
799
  * Creates a new instance of obj scoped to the correct model class or sub-class.
472
800
  */
@@ -480,55 +808,81 @@ export class RepoUtils {
480
808
  * Search for existing object based on passed in id and version and product uid.
481
809
  *
482
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.
483
815
  */
484
- searchIdQuery(id, version) {
485
- 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);
486
818
  }
487
819
  async truncate(query, options) {
488
820
  if (!this.repo) {
489
821
  throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
490
822
  }
823
+ const txInfo = this.getTransaction(options);
491
824
  // Check user permissions. Don't check if record-level ACLs are used as this will be done
492
825
  // per record later.
493
826
  if (this.aclUtils?.enabled && !options.ignoreACL && !this.modelClass.recordACL) {
494
- if (!(await this.aclUtils.hasPermission(options.user, this.defaultACLUid, ACLAction.DELETE))) {
827
+ if (!(await this.aclUtils.hasPermission(options.user, this.defaultACLUid, ACLAction.TRUNCATE))) {
495
828
  throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, ApiErrorMessages.AUTH_PERMISSION_FAILURE);
496
829
  }
497
830
  }
498
831
  try {
499
- let uids = [];
500
- if (this.repo instanceof MongoRepository) {
501
- if (Array.isArray(query)) {
502
- uids = await this.repo.distinct("uid", query[0].$match);
503
- }
504
- else {
505
- uids = await this.repo.distinct("uid", query["$match"] ? query["$match"] : query);
506
- }
507
- }
508
- else {
509
- (await this.repo.find(query)).forEach((obj) => uids.push(obj.uid));
510
- }
832
+ const searchQuery = ModelUtils.buildSearchQuery(this.modelClass, this.repo, query, true, options?.user);
833
+ const uids = await this.findAllUids(searchQuery, options);
511
834
  if (uids.length > 0) {
512
835
  let finalUids = uids;
513
836
  // Check if this class uses record level ACLs. If so, we need to check the perms of
514
837
  // each one. We will remove any from our list that the user does not have permission to
515
- // delete.
838
+ // truncate.
516
839
  if (this.aclUtils?.enabled && this.modelClass.recordACL) {
517
- finalUids = [];
518
- for (const uid of uids) {
519
- if (!options.ignoreACL) {
520
- if (await this.aclUtils.hasPermission(options.user, uid, ACLAction.DELETE)) {
521
- finalUids.push(uid);
522
- }
523
- }
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);
524
847
  }
525
848
  }
849
+ const cleansUpRecordACLs = !!(this.aclUtils?.enabled && this.modelClass.recordACL);
526
850
  // Now delete all records that were found
527
851
  if (this.repo instanceof MongoRepository) {
528
- await this.repo.deleteMany({ uid: { $in: finalUids } });
852
+ await this.repo.deleteMany({ uid: { $in: finalUids } }, {
853
+ session: txInfo?.session,
854
+ });
529
855
  }
530
856
  else {
531
- 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
+ }
532
886
  }
533
887
  if (!options?.skipPush) {
534
888
  let channels = options?.pushChannels || [];
@@ -553,6 +907,7 @@ export class RepoUtils {
553
907
  if (!this.repo) {
554
908
  throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
555
909
  }
910
+ const txInfo = this.getTransaction(options);
556
911
  if (this.aclUtils?.enabled && !options?.ignoreACL) {
557
912
  const acl = await this.aclUtils.findACL(existing.uid);
558
913
  if (!(await this.aclUtils.hasPermission(options?.user, acl ? acl : this.defaultACLUid, ACLAction.UPDATE))) {
@@ -569,6 +924,15 @@ export class RepoUtils {
569
924
  if (existing.uid !== obj.uid) {
570
925
  throw new ApiError(ApiErrors.OBJECT_ID_MISMATCH, 400, ApiErrorMessages.OBJECT_ID_MISMATCH);
571
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
+ }
572
936
  // When using MongoDB we need to copy the _id property in order to prevent duplicate entries
573
937
  if (existing instanceof BaseMongoEntity) {
574
938
  obj._id = existing._id;
@@ -579,12 +943,24 @@ export class RepoUtils {
579
943
  if (this.repo instanceof MongoRepository) {
580
944
  if (existing instanceof BaseEntity) {
581
945
  if (keepPrevious) {
582
- result = this.instantiateObject(await this.repo.save({
583
- ...obj,
584
- _id: undefined, // Ensure we save a new document
585
- dateModified: new Date(),
586
- version: obj.version + 1,
587
- }));
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
+ }
588
964
  }
589
965
  else {
590
966
  await this.repo.updateOne({ uid: obj.uid, version: obj.version }, {
@@ -593,22 +969,32 @@ export class RepoUtils {
593
969
  dateModified: new Date(),
594
970
  version: obj.version + 1,
595
971
  },
972
+ }, {
973
+ session: txInfo?.session,
596
974
  });
597
975
  }
598
976
  }
599
977
  else if (obj.uid) {
600
978
  if (keepPrevious) {
601
- result = this.instantiateObject(await this.repo.save({
602
- ...obj,
603
- version: obj.version + 1,
604
- }));
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
+ }
605
991
  }
606
992
  else {
607
993
  await this.repo.updateOne({ uid: obj.uid }, {
608
994
  $set: {
609
995
  ...obj,
610
996
  },
611
- });
997
+ }, { session: txInfo?.session });
612
998
  }
613
999
  }
614
1000
  else {
@@ -616,20 +1002,21 @@ export class RepoUtils {
616
1002
  if (keepPrevious) {
617
1003
  toSave.version += 1;
618
1004
  }
619
- result = await this.repo.save(toSave);
1005
+ result = await this.repo.save(toSave, { session: txInfo?.session });
620
1006
  }
621
1007
  }
622
1008
  else {
1009
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
623
1010
  if (existing instanceof BaseEntity) {
624
1011
  if (keepPrevious) {
625
- await this.repo.insert({
1012
+ await repo.insert({
626
1013
  ...obj,
627
1014
  dateModified: new Date(),
628
1015
  version: obj.version + 1,
629
1016
  });
630
1017
  }
631
1018
  else {
632
- await this.repo.update(query.where, {
1019
+ await repo.update(query.where, {
633
1020
  ...obj,
634
1021
  dateModified: new Date(),
635
1022
  version: obj.version + 1,
@@ -640,31 +1027,43 @@ export class RepoUtils {
640
1027
  const toSave = obj;
641
1028
  if (keepPrevious) {
642
1029
  toSave.version += 1;
643
- 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);
644
1034
  }
645
1035
  else {
646
- await this.repo.update(query.where, toSave);
1036
+ await repo.update(query.where, toSave);
647
1037
  }
648
1038
  }
649
1039
  }
650
- 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);
651
1041
  if (!result) {
652
1042
  if (this.repo instanceof MongoRepository) {
653
- 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
+ });
654
1046
  }
655
1047
  else {
656
- result = await this.repo.findOne(query);
1048
+ const repo = txInfo?.entityManager ? txInfo.entityManager.getRepository(this.modelClass) : this.repo;
1049
+ result = (await repo.findOne(query));
657
1050
  }
658
1051
  if (!result) {
659
1052
  throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
660
1053
  }
661
1054
  }
662
1055
  result = this.instantiateObject(result);
663
- if (result && this.cacheClient && this.modelClass.cacheTTL) {
664
- // Cache the object for faster retrieval
665
- void this.cacheClient.setex(`${this.baseCacheKey}.${this.hashQuery(query)}`, this.modelClass.cacheTTL, JSON.stringify(result));
666
- 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));
667
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);
668
1067
  if (!options?.skipPush) {
669
1068
  let channels = [result.uid].concat(options?.pushChannels || []);
670
1069
  this.notificationUtils?.sendMessage(channels, this.modelClass.name, "update", result);
@@ -686,18 +1085,31 @@ export class RepoUtils {
686
1085
  // Instantiate the correct object type so that we can perform validation correctly. If we don't do this
687
1086
  // then the provided object will be missing all decorators and validation won't work as desired.
688
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);
689
1094
  ObjectUtils.validate(obj, metadataObj.constructor);
690
- // Iterate through all properties and look for `@Reference`
1095
+ // Iterate through all properties
691
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
692
1104
  const clazz = Reflect.getMetadata("rrst:reference", metadataObj, member);
693
- if (clazz && clazz.datastore && obj[member]) {
1105
+ if (clazz && clazz.datasource && obj[member]) {
694
1106
  // Attempt to grab the repository for this reference type
695
- const conn = this.connectionManager?.connections.get(clazz.datastore);
1107
+ const conn = this.connectionManager?.connections.get(clazz.datasource);
696
1108
  const repo = conn instanceof MongoConnection || isSqlDataSource(conn)
697
1109
  ? conn.getRepository(clazz)
698
1110
  : undefined;
699
1111
  if (repo) {
700
- // 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
701
1113
  // let's make sure that every uid is valid.
702
1114
  const uids = Array.isArray(obj[member]) ? obj[member] : [obj[member]];
703
1115
  const query = ModelUtils.buildIdSearchQuery(repo, clazz, uids);
@@ -711,7 +1123,7 @@ export class RepoUtils {
711
1123
  }
712
1124
  }
713
1125
  catch (err) {
714
- throw new ApiError(ApiErrorMessages.INVALID_REQUEST, 400, err.message);
1126
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, err.message);
715
1127
  }
716
1128
  }
717
1129
  }
@@ -719,10 +1131,6 @@ __decorate([
719
1131
  Inject("ACLUtils"),
720
1132
  __metadata("design:type", Function)
721
1133
  ], RepoUtils.prototype, "aclUtils", void 0);
722
- __decorate([
723
- RedisConnection("cache"),
724
- __metadata("design:type", Redis)
725
- ], RepoUtils.prototype, "cacheClient", void 0);
726
1134
  __decorate([
727
1135
  Config(),
728
1136
  __metadata("design:type", Object)
@@ -735,10 +1143,6 @@ __decorate([
735
1143
  Logger,
736
1144
  __metadata("design:type", Object)
737
1145
  ], RepoUtils.prototype, "logger", void 0);
738
- __decorate([
739
- Inject(ObjectFactory),
740
- __metadata("design:type", ObjectFactory)
741
- ], RepoUtils.prototype, "objectFactory", void 0);
742
1146
  __decorate([
743
1147
  Inject(NotificationUtils),
744
1148
  __metadata("design:type", NotificationUtils)
@@ -753,4 +1157,28 @@ __decorate([
753
1157
  __metadata("design:paramtypes", []),
754
1158
  __metadata("design:returntype", Promise)
755
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);
756
1184
  //# sourceMappingURL=RepoUtils.js.map