@lenne.tech/nest-server 11.32.4 → 11.33.1

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 (264) hide show
  1. package/.claude/rules/architecture.md +16 -0
  2. package/.claude/rules/configurable-features.md +11 -3
  3. package/.claude/rules/testing.md +101 -1
  4. package/CLAUDE.md +5 -0
  5. package/FRAMEWORK-API.md +58 -3
  6. package/dist/config.env.js +6 -0
  7. package/dist/config.env.js.map +1 -1
  8. package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
  9. package/dist/core/common/helpers/content-disposition.helper.js +14 -0
  10. package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
  11. package/dist/core/common/helpers/file.helper.d.ts +7 -0
  12. package/dist/core/common/helpers/file.helper.js +21 -4
  13. package/dist/core/common/helpers/file.helper.js.map +1 -1
  14. package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
  15. package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
  16. package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
  17. package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
  18. package/dist/core/common/helpers/project-name.helper.js +38 -0
  19. package/dist/core/common/helpers/project-name.helper.js.map +1 -0
  20. package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
  21. package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
  22. package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
  23. package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
  24. package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
  25. package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
  26. package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
  27. package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
  28. package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
  29. package/dist/core/common/services/core-cron-jobs.service.js +278 -27
  30. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  31. package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
  32. package/dist/core/common/services/core-redis-pubsub.js +112 -0
  33. package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
  34. package/dist/core/common/services/core-redis.service.d.ts +25 -0
  35. package/dist/core/common/services/core-redis.service.js +154 -0
  36. package/dist/core/common/services/core-redis.service.js.map +1 -0
  37. package/dist/core/common/services/core-s3.service.d.ts +51 -0
  38. package/dist/core/common/services/core-s3.service.js +261 -0
  39. package/dist/core/common/services/core-s3.service.js.map +1 -0
  40. package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
  41. package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
  42. package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
  43. package/dist/core/common/services/rate-limit-store.d.ts +61 -0
  44. package/dist/core/common/services/rate-limit-store.js +235 -0
  45. package/dist/core/common/services/rate-limit-store.js.map +1 -0
  46. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
  47. package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
  48. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  49. package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
  50. package/dist/core/modules/ai/services/core-ai.service.js +24 -20
  51. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  52. package/dist/core/modules/auth/core-auth.module.js +4 -1
  53. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  54. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
  55. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
  56. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
  57. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
  58. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
  59. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
  60. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
  61. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
  62. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  63. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
  64. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
  65. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  66. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
  67. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
  68. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  69. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
  70. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  71. package/dist/core/modules/file/core-file.controller.d.ts +11 -2
  72. package/dist/core/modules/file/core-file.controller.js +52 -15
  73. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  74. package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
  75. package/dist/core/modules/file/core-file.resolver.js +23 -16
  76. package/dist/core/modules/file/core-file.resolver.js.map +1 -1
  77. package/dist/core/modules/file/core-file.service.d.ts +41 -7
  78. package/dist/core/modules/file/core-file.service.js +228 -8
  79. package/dist/core/modules/file/core-file.service.js.map +1 -1
  80. package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
  81. package/dist/core/modules/file/file-metadata.helper.js +33 -0
  82. package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
  83. package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
  84. package/dist/core/modules/file/file-roles.helper.js +53 -0
  85. package/dist/core/modules/file/file-roles.helper.js.map +1 -0
  86. package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
  87. package/dist/core/modules/file/file-storage.helper.js +65 -0
  88. package/dist/core/modules/file/file-storage.helper.js.map +1 -0
  89. package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
  90. package/dist/core/modules/file/filesystem-file.helper.js +77 -0
  91. package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
  92. package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
  93. package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
  94. package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
  95. package/dist/core/modules/file/s3-file.helper.js +81 -0
  96. package/dist/core/modules/file/s3-file.helper.js.map +1 -0
  97. package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
  98. package/dist/core/modules/hub/core-hub.controller.js +24 -22
  99. package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
  100. package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
  101. package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
  102. package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
  103. package/dist/core/modules/hub/hub-buffer.js +101 -0
  104. package/dist/core/modules/hub/hub-buffer.js.map +1 -0
  105. package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
  106. package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
  107. package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
  108. package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
  109. package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
  110. package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
  111. package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
  112. package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
  113. package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
  114. package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
  115. package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
  116. package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
  117. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
  118. package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
  119. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  120. package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
  121. package/dist/core/modules/migrate/migration-runner.js +4 -0
  122. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  123. package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
  124. package/dist/core/modules/migrate/mongo-state-store.js +98 -9
  125. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  126. package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
  127. package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
  128. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  129. package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
  130. package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
  131. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  132. package/dist/core/modules/tenant/core-tenant.module.js +3 -4
  133. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  134. package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
  135. package/dist/core/modules/tus/core-tus.controller.js +29 -3
  136. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  137. package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
  138. package/dist/core/modules/tus/core-tus.service.js +182 -28
  139. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  140. package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
  141. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  142. package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
  143. package/dist/core/modules/tus/tus-redis-locker.js +87 -0
  144. package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
  145. package/dist/core/modules/tus/tus.module.d.ts +1 -0
  146. package/dist/core/modules/tus/tus.module.js +30 -4
  147. package/dist/core/modules/tus/tus.module.js.map +1 -1
  148. package/dist/core.module.js +18 -1
  149. package/dist/core.module.js.map +1 -1
  150. package/dist/index.d.ts +10 -0
  151. package/dist/index.js +10 -0
  152. package/dist/index.js.map +1 -1
  153. package/dist/main.js +2 -1
  154. package/dist/main.js.map +1 -1
  155. package/dist/server/modules/file/file.controller.js +2 -2
  156. package/dist/server/modules/file/file.controller.js.map +1 -1
  157. package/dist/server/modules/file/file.resolver.js +3 -14
  158. package/dist/server/modules/file/file.resolver.js.map +1 -1
  159. package/dist/server/modules/file/file.service.d.ts +10 -2
  160. package/dist/server/modules/file/file.service.js +23 -3
  161. package/dist/server/modules/file/file.service.js.map +1 -1
  162. package/dist/server/modules/user/avatar.controller.d.ts +5 -1
  163. package/dist/server/modules/user/avatar.controller.js +26 -10
  164. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  165. package/dist/server/modules/user/user.model.js +3 -2
  166. package/dist/server/modules/user/user.model.js.map +1 -1
  167. package/dist/server/modules/user/user.module.js +6 -2
  168. package/dist/server/modules/user/user.module.js.map +1 -1
  169. package/dist/server/modules/user/user.service.d.ts +1 -1
  170. package/dist/server/modules/user/user.service.js +6 -13
  171. package/dist/server/modules/user/user.service.js.map +1 -1
  172. package/dist/tsconfig.build.tsbuildinfo +1 -1
  173. package/docs/REQUEST-LIFECYCLE.md +57 -8
  174. package/docs/security-overrides.md +21 -3
  175. package/migration-guides/11.32.3-to-11.32.4.md +15 -3
  176. package/migration-guides/11.32.x-to-11.33.x.md +1584 -0
  177. package/package.json +74 -42
  178. package/src/config.env.ts +45 -0
  179. package/src/core/common/helpers/content-disposition.helper.ts +90 -0
  180. package/src/core/common/helpers/file.helper.ts +53 -10
  181. package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
  182. package/src/core/common/helpers/project-name.helper.ts +71 -0
  183. package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
  184. package/src/core/common/interfaces/server-options.interface.ts +439 -2
  185. package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
  186. package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
  187. package/src/core/common/services/core-cron-jobs.service.ts +589 -47
  188. package/src/core/common/services/core-redis-pubsub.ts +183 -0
  189. package/src/core/common/services/core-redis.service.ts +249 -0
  190. package/src/core/common/services/core-s3.service.ts +465 -0
  191. package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
  192. package/src/core/common/services/rate-limit-store.ts +492 -0
  193. package/src/core/modules/ai/README.md +25 -0
  194. package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
  195. package/src/core/modules/ai/services/core-ai.service.ts +33 -24
  196. package/src/core/modules/auth/core-auth.module.ts +7 -1
  197. package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
  198. package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
  199. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
  200. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
  201. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
  202. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
  203. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +120 -0
  204. package/src/core/modules/file/README.md +308 -53
  205. package/src/core/modules/file/core-file.controller.ts +168 -12
  206. package/src/core/modules/file/core-file.resolver.ts +46 -12
  207. package/src/core/modules/file/core-file.service.ts +517 -17
  208. package/src/core/modules/file/file-metadata.helper.ts +142 -0
  209. package/src/core/modules/file/file-roles.helper.ts +110 -0
  210. package/src/core/modules/file/file-storage.helper.ts +163 -0
  211. package/src/core/modules/file/filesystem-file.helper.ts +184 -0
  212. package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
  213. package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
  214. package/src/core/modules/file/s3-file.helper.ts +209 -0
  215. package/src/core/modules/hub/README.md +48 -0
  216. package/src/core/modules/hub/core-hub.controller.ts +18 -16
  217. package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
  218. package/src/core/modules/hub/hub-buffer.ts +198 -0
  219. package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
  220. package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
  221. package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
  222. package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
  223. package/src/core/modules/migrate/README.md +33 -6
  224. package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
  225. package/src/core/modules/migrate/migration-runner.ts +10 -1
  226. package/src/core/modules/migrate/mongo-state-store.ts +179 -11
  227. package/src/core/modules/system-setup/README.md +7 -2
  228. package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
  229. package/src/core/modules/tenant/README.md +21 -1
  230. package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
  231. package/src/core/modules/tenant/core-tenant.module.ts +8 -4
  232. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
  233. package/src/core/modules/tus/README.md +35 -2
  234. package/src/core/modules/tus/core-tus.controller.ts +64 -12
  235. package/src/core/modules/tus/core-tus.service.ts +358 -34
  236. package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
  237. package/src/core/modules/tus/tus-redis-locker.ts +139 -0
  238. package/src/core/modules/tus/tus.module.ts +78 -6
  239. package/src/core.module.ts +26 -1
  240. package/src/index.ts +10 -0
  241. package/src/main.ts +7 -1
  242. package/src/server/modules/file/file.controller.ts +14 -5
  243. package/src/server/modules/file/file.resolver.ts +11 -19
  244. package/src/server/modules/file/file.service.ts +89 -4
  245. package/src/server/modules/user/avatar.controller.ts +46 -9
  246. package/src/server/modules/user/user.model.ts +9 -3
  247. package/src/server/modules/user/user.module.ts +12 -3
  248. package/src/server/modules/user/user.service.ts +20 -17
  249. package/src/test/README.md +7 -2
  250. package/src/core/common/helpers/file.helper.spec.ts +0 -145
  251. package/src/core/common/helpers/logging.helper.spec.ts +0 -61
  252. package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
  253. package/src/core/common/services/brevo.service.spec.ts +0 -266
  254. package/src/core/modules/file/core-file.controller.spec.ts +0 -164
  255. package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
  256. package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
  257. package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
  258. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
  259. package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
  260. package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
  261. package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
  262. package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
  263. package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
  264. package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
@@ -0,0 +1,183 @@
1
+ import { Logger, OnModuleDestroy } from '@nestjs/common';
2
+ import { PubSubEngine } from 'graphql-subscriptions';
3
+
4
+ import type { CoreRedisService } from './core-redis.service';
5
+ import type { Redis } from 'ioredis';
6
+
7
+ /**
8
+ * Redis-backed drop-in replacement for the in-memory `PubSub` of `graphql-subscriptions`.
9
+ *
10
+ * Used as the `PUB_SUB` provider whenever Redis is enabled, so GraphQL subscriptions
11
+ * are delivered across ALL replicas instead of only the process that published them.
12
+ * `asyncIterableIterator()` is inherited from `PubSubEngine` and works unchanged.
13
+ *
14
+ * Payloads are JSON-serialized — they MUST be JSON-serializable. Class instances,
15
+ * `Date` objects, `Map`/`Set` and `undefined` values do not survive the round trip.
16
+ *
17
+ * The subscriber connection is shared process-wide (`CoreRedisService.getSubscriber()`),
18
+ * so this engine keeps its own channel → handler map and only issues a Redis
19
+ * SUBSCRIBE/UNSUBSCRIBE when the first handler for a channel arrives / the last one leaves.
20
+ */
21
+ /** ISO-8601 with a time component — the shape `Date.prototype.toJSON()` produces */
22
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
23
+
24
+ /**
25
+ * Turn ISO-8601 strings back into `Date`s while parsing.
26
+ *
27
+ * The in-memory PubSub passed live objects, so subscribers received real `Date`s; a JSON round
28
+ * trip hands them strings instead, and `DateScalar.serialize()` throws on those. Enabling Redis
29
+ * is documented as a drop-in, so it must not change the shape of what a subscriber receives.
30
+ *
31
+ * Only strings that look exactly like a serialized Date are converted, which cannot distinguish
32
+ * a genuine ISO-timestamp STRING from a Date — a deliberate trade: the in-memory behavior is
33
+ * the reference, and there a Date stays a Date.
34
+ */
35
+ function reviveDates(_key: string, value: unknown): unknown {
36
+ return typeof value === 'string' && ISO_DATE.test(value) ? new Date(value) : value;
37
+ }
38
+
39
+ export class CoreRedisPubSub extends PubSubEngine implements OnModuleDestroy {
40
+ protected readonly logger = new Logger(CoreRedisPubSub.name);
41
+
42
+ /** Channel → (subscription id → handler) */
43
+ protected readonly handlers = new Map<string, Map<number, (...args: any[]) => void>>();
44
+
45
+ /**
46
+ * The 'message' listener attached to the SHARED subscriber connection, kept so it can be
47
+ * removed again. Several features listen on that one connection, so a listener left behind by
48
+ * a closed app keeps dispatching into its dead handler maps for the life of the process.
49
+ */
50
+ protected messageListener?: (channel: string, message: string) => void;
51
+
52
+ /** Subscription id → channel */
53
+ protected readonly subscriptions = new Map<number, string>();
54
+
55
+ protected subIdCounter = 0;
56
+
57
+ constructor(protected readonly redisService: CoreRedisService) {
58
+ super();
59
+ }
60
+
61
+ async publish(triggerName: string, payload: any): Promise<void> {
62
+ try {
63
+ await this.redisService.getClient().publish(this.channel(triggerName), JSON.stringify(payload));
64
+ } catch (error) {
65
+ // The in-memory PubSub this replaces could not fail, so callers do not guard their
66
+ // publishes. Letting a Redis blip escape would turn a missed NOTIFICATION into a failed
67
+ // business operation — a user written to the database but reported as a failed sign-up.
68
+ this.logger.warn(
69
+ `Subscription publish to "${triggerName}" failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
70
+ );
71
+ }
72
+ }
73
+
74
+ async subscribe(triggerName: string, onMessage: (...args: any[]) => void): Promise<number> {
75
+ const channel = this.channel(triggerName);
76
+ let channelHandlers = this.handlers.get(channel);
77
+
78
+ // First handler for this channel: subscribe on Redis
79
+ if (!channelHandlers) {
80
+ channelHandlers = new Map();
81
+ this.handlers.set(channel, channelHandlers);
82
+ try {
83
+ await this.getSubscriber().subscribe(channel);
84
+ } catch (error) {
85
+ this.handlers.delete(channel);
86
+ throw error;
87
+ }
88
+ }
89
+
90
+ const subId = ++this.subIdCounter;
91
+ channelHandlers.set(subId, onMessage);
92
+ this.subscriptions.set(subId, channel);
93
+ return subId;
94
+ }
95
+
96
+ async unsubscribe(subId: number): Promise<void> {
97
+ const channel = this.subscriptions.get(subId);
98
+ if (!channel) {
99
+ return;
100
+ }
101
+ this.subscriptions.delete(subId);
102
+
103
+ const channelHandlers = this.handlers.get(channel);
104
+ if (!channelHandlers) {
105
+ return;
106
+ }
107
+ channelHandlers.delete(subId);
108
+
109
+ // Last handler for this channel: unsubscribe on Redis
110
+ if (channelHandlers.size === 0) {
111
+ this.handlers.delete(channel);
112
+ await this.getSubscriber().unsubscribe(channel);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Framework-namespaced Redis channel for a GraphQL trigger name
118
+ */
119
+ protected channel(triggerName: string): string {
120
+ return this.redisService.key('pubsub', triggerName);
121
+ }
122
+
123
+ /**
124
+ * Deliver an incoming Redis message to all handlers of that channel.
125
+ * Messages of channels this engine does not know (the subscriber connection is
126
+ * shared with other framework features) are ignored.
127
+ */
128
+ protected dispatch(channel: string, message: string): void {
129
+ const channelHandlers = this.handlers.get(channel);
130
+ if (!channelHandlers?.size) {
131
+ return;
132
+ }
133
+ let payload: any;
134
+ try {
135
+ payload = JSON.parse(message, reviveDates);
136
+ } catch {
137
+ this.logger.warn(`Ignoring non-JSON message on channel ${channel}`);
138
+ return;
139
+ }
140
+ for (const handler of channelHandlers.values()) {
141
+ handler(payload);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Shared subscriber connection with the 'message' listener attached once
147
+ */
148
+ /**
149
+ * Unsubscribe every channel and detach the shared listener.
150
+ *
151
+ * Without this the subscriptions and the 'message' listener outlive the application — and
152
+ * because the subscriber connection is shared, a second app in the same process (a test suite,
153
+ * any multi-app host) inherits the previous one's listener alongside its own.
154
+ */
155
+ async onModuleDestroy(): Promise<void> {
156
+ const channels = [...this.handlers.keys()];
157
+ this.handlers.clear();
158
+ this.subscriptions.clear();
159
+
160
+ try {
161
+ const subscriber = this.redisService.getSubscriber();
162
+ if (this.messageListener) {
163
+ subscriber.off('message', this.messageListener);
164
+ this.messageListener = undefined;
165
+ }
166
+ if (channels.length) {
167
+ await subscriber.unsubscribe(...channels);
168
+ }
169
+ } catch (error) {
170
+ // Redis already gone: the connection is being torn down anyway.
171
+ this.logger.debug(`PubSub teardown skipped: ${error instanceof Error ? error.message : 'Unknown error'}`);
172
+ }
173
+ }
174
+
175
+ protected getSubscriber(): Redis {
176
+ const subscriber = this.redisService.getSubscriber();
177
+ if (!this.messageListener) {
178
+ this.messageListener = (channel: string, message: string) => this.dispatch(channel, message);
179
+ subscriber.on('message', this.messageListener);
180
+ }
181
+ return subscriber;
182
+ }
183
+ }
@@ -0,0 +1,249 @@
1
+ import { Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
2
+
3
+ import { getProjectSlug } from '../helpers/project-name.helper';
4
+ import { ConfigService } from './config.service';
5
+
6
+ import type { IRedisConfig } from '../interfaces/server-options.interface';
7
+ import type { Redis, RedisOptions } from 'ioredis';
8
+
9
+ /**
10
+ * Fully normalized Redis configuration with all defaults applied
11
+ */
12
+ export type NormalizedRedisConfig = Required<Pick<IRedisConfig, 'db' | 'host' | 'keyPrefix' | 'port'>> &
13
+ Pick<IRedisConfig, 'options' | 'password' | 'url' | 'username'>;
14
+
15
+ /**
16
+ * Central Redis connection service (see IServerOptions.redis).
17
+ *
18
+ * All distributed framework features (rate limiting, cron deduplication, GraphQL
19
+ * subscriptions, caches, Hub collectors) share this single service. It follows the
20
+ * "presence implies enabled" pattern: without a `redis` config every consumer keeps
21
+ * its process-local fallback behavior and this service stays inert.
22
+ *
23
+ * The `ioredis` package is an OPTIONAL peer dependency and is lazy-imported at
24
+ * bootstrap — consumers without Redis never need it installed. When Redis is
25
+ * configured but the package is missing, the boot fails fast with an actionable
26
+ * error instead of a runtime crash on first use.
27
+ */
28
+ @Injectable()
29
+ export class CoreRedisService implements OnApplicationShutdown, OnModuleInit {
30
+ protected readonly logger = new Logger(CoreRedisService.name);
31
+
32
+ /** All connections created by this service, quit together on shutdown */
33
+ protected clients: Redis[] = [];
34
+
35
+ /** Normalized config; undefined when Redis is not enabled */
36
+ protected config?: NormalizedRedisConfig;
37
+
38
+ /** ioredis constructor, resolved by the lazy import at init */
39
+ protected RedisCtor?: new (...args: any[]) => Redis;
40
+
41
+ /** Shared general-purpose client */
42
+ protected sharedClient?: Redis;
43
+
44
+ /** Dedicated subscriber connection (a subscribing client cannot run commands) */
45
+ protected subscriberClient?: Redis;
46
+
47
+ constructor(protected readonly configService: ConfigService) {
48
+ const raw = this.configService.getFastButReadOnly<boolean | IRedisConfig | undefined>('redis');
49
+ if (raw === undefined || raw === null || raw === false) {
50
+ return;
51
+ }
52
+ const partial: IRedisConfig = typeof raw === 'boolean' ? {} : raw;
53
+ if (partial.enabled === false) {
54
+ return;
55
+ }
56
+ this.config = {
57
+ db: partial.db ?? 0,
58
+ host: partial.host ?? 'localhost',
59
+ // Default namespaced PER APPLICATION, not per framework. A constant default is invisible
60
+ // until two applications share one Redis — a normal staging setup — and then their
61
+ // identically-named cron jobs, rate-limit counters and Hub buffers collide on the same keys.
62
+ // The failure is silent: one app's BullMQ worker consumes the other's scheduled jobs, so the
63
+ // job never runs where it was defined. Override via `redis.keyPrefix` when you WANT sharing.
64
+ keyPrefix: partial.keyPrefix ?? getProjectSlug(),
65
+ options: partial.options,
66
+ password: partial.password,
67
+ port: partial.port ?? 6379,
68
+ url: partial.url,
69
+ username: partial.username,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Whether Redis is configured and enabled
75
+ */
76
+ get enabled(): boolean {
77
+ return !!this.config;
78
+ }
79
+
80
+ /**
81
+ * Normalized Redis configuration, or undefined when disabled
82
+ */
83
+ getConfig(): NormalizedRedisConfig | undefined {
84
+ return this.config;
85
+ }
86
+
87
+ /**
88
+ * Build a framework-managed Redis key: `<keyPrefix>:<part>:<part>...`
89
+ *
90
+ * The prefix is applied per key instead of via the ioredis `keyPrefix` option,
91
+ * which would conflict with BullMQ's own prefix handling.
92
+ */
93
+ key(...parts: string[]): string {
94
+ return [this.config?.keyPrefix ?? getProjectSlug(), ...parts].join(':');
95
+ }
96
+
97
+ /**
98
+ * Lazy-import ioredis and create the shared client.
99
+ * No-op when Redis is not enabled.
100
+ */
101
+ async onModuleInit(): Promise<void> {
102
+ if (!this.config) {
103
+ return;
104
+ }
105
+ try {
106
+ const mod: any = await import('ioredis');
107
+ this.RedisCtor = mod.Redis ?? mod.default;
108
+ } catch {
109
+ throw new Error(
110
+ 'Redis is configured (ServerOptions.redis) but the optional peer dependency "ioredis" is not installed. ' +
111
+ 'Run: pnpm add ioredis',
112
+ );
113
+ }
114
+ this.sharedClient = this.newConnection('shared');
115
+ this.logActivationSummary();
116
+ }
117
+
118
+ /**
119
+ * State the consequences of `redis` being configured, once, at boot.
120
+ *
121
+ * Configuring Redis is not a local decision — it silently changes the behavior of a dozen
122
+ * subsystems at once, and several of those changes are only observable in production. The worst
123
+ * case is a project that never opted in at all: `redis` is a generic name, so a project may
124
+ * already carry that key in its server options for its OWN client, and then a mere version bump
125
+ * flips everything over. Cron stops being per-replica, rate limits get stricter (they were
126
+ * effectively `max × replicas` before), diagnostics leave the process. All correct, none of it
127
+ * announced.
128
+ *
129
+ * So the summary lists what actually changed FOR THIS CONFIG — features that are switched off
130
+ * are omitted rather than listed as inactive, because a list of non-events is noise.
131
+ */
132
+ protected logActivationSummary(): void {
133
+ if (!this.config) {
134
+ return;
135
+ }
136
+
137
+ const cfg = this.configService.configFastButReadOnly ?? ({} as Record<string, any>);
138
+ const changed: string[] = [
139
+ 'rate limits (legacy auth, better-auth, AI) — now exact across replicas instead of per replica',
140
+ 'cron jobs — deduplicated fleet-wide; per-job opt-out via `distributed: false`',
141
+ 'email-verification cooldown — one slot fleet-wide instead of one per replica',
142
+ ];
143
+
144
+ if (cfg.graphQl !== false) {
145
+ changed.push('GraphQL subscriptions — via Redis pub/sub; payloads must be JSON-serializable');
146
+ }
147
+ if (cfg.multiTenancy) {
148
+ changed.push('tenant-membership cache — invalidation is broadcast to every replica');
149
+ }
150
+ if (cfg.hub) {
151
+ changed.push('Hub log/trace/query buffers — mirrored to Redis, so they outlive the process');
152
+ }
153
+ if (cfg.ai?.mcp) {
154
+ changed.push('MCP sessions — ownership registry (sticky sessions still required)');
155
+ }
156
+
157
+ this.logger.log(
158
+ `Redis configured (${this.config.url ? 'url' : `${this.config.host}:${this.config.port}`}, ` +
159
+ `db ${this.config.db}, keyPrefix "${this.config.keyPrefix}") — these subsystems are now ` +
160
+ `distributed:\n - ${changed.join('\n - ')}`,
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Shared general-purpose client.
166
+ * Throws when Redis is not enabled or the module has not been initialized yet.
167
+ */
168
+ getClient(): Redis {
169
+ if (!this.config) {
170
+ throw new Error('Redis is not configured/enabled (ServerOptions.redis)');
171
+ }
172
+ if (!this.sharedClient) {
173
+ throw new Error('CoreRedisService is not initialized yet (onModuleInit pending)');
174
+ }
175
+ return this.sharedClient;
176
+ }
177
+
178
+ /**
179
+ * Dedicated cached subscriber connection — a client in subscribe mode
180
+ * cannot execute regular commands, so it must not share the main connection.
181
+ */
182
+ getSubscriber(): Redis {
183
+ this.getClient();
184
+ if (!this.subscriberClient) {
185
+ this.subscriberClient = this.newConnection('subscriber');
186
+ }
187
+ return this.subscriberClient;
188
+ }
189
+
190
+ /**
191
+ * Create a NEW dedicated connection (e.g. for BullMQ, blocking commands).
192
+ * The connection is tracked and closed on application shutdown.
193
+ */
194
+ createClient(label = 'extra', overrides?: RedisOptions): Redis {
195
+ this.getClient();
196
+ return this.newConnection(label, overrides);
197
+ }
198
+
199
+ /**
200
+ * Quit all created connections
201
+ */
202
+ async onApplicationShutdown(): Promise<void> {
203
+ await Promise.allSettled(
204
+ this.clients.map(async (client) => {
205
+ try {
206
+ // A client in a reconnect loop QUEUES the quit rather than executing it, so the promise
207
+ // stays pending for the whole retry budget — while the reconnect timer, which is not
208
+ // unref'd, keeps the process alive. Bound the wait and take the socket down either way.
209
+ await Promise.race([client.quit(), new Promise((resolve) => setTimeout(resolve, 2000).unref())]);
210
+ } finally {
211
+ client.disconnect();
212
+ }
213
+ }),
214
+ );
215
+ this.clients = [];
216
+ this.sharedClient = undefined;
217
+ this.subscriberClient = undefined;
218
+ }
219
+
220
+ /**
221
+ * Create a tracked ioredis connection from the normalized config
222
+ */
223
+ protected newConnection(label: string, overrides?: RedisOptions): Redis {
224
+ if (!this.config || !this.RedisCtor) {
225
+ throw new Error('CoreRedisService is not initialized yet (onModuleInit pending)');
226
+ }
227
+ const { db, host, options, password, port, url, username } = this.config;
228
+ const baseOptions: RedisOptions = {
229
+ // Without a command timeout a command issued while Redis is down neither resolves nor
230
+ // rejects — it waits in the offline queue for the entire retry budget. The rate limiter
231
+ // degrades on a REJECTION, so a blip would stall every sign-in instead of falling back.
232
+ // Listed before the spread so a project's own `redis.options` can still override it.
233
+ commandTimeout: 2000,
234
+ ...(options as RedisOptions),
235
+ // Per-connection opt-out. A BLOCKING consumer (BullMQ's BZPOPMIN waits up to 10s by
236
+ // design) must not inherit the timeout: ioredis would abort the wait, BullMQ would
237
+ // classify it as a real error, and its fetch loop would stop for good.
238
+ ...overrides,
239
+ };
240
+ const client = url
241
+ ? new this.RedisCtor(url, baseOptions)
242
+ : new this.RedisCtor({ db, host, password, port, username, ...baseOptions });
243
+ client.on?.('error', (error: Error) => {
244
+ this.logger.error(`Redis connection error (${label}): ${error.message}`);
245
+ });
246
+ this.clients.push(client);
247
+ return client;
248
+ }
249
+ }