@lenne.tech/nest-server 12.1.0 → 12.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/.claude/rules/architecture.md +1 -0
  2. package/.claude/rules/better-auth.md +18 -6
  3. package/.claude/rules/configurable-features.md +5 -4
  4. package/.claude/rules/package-management.md +16 -1
  5. package/.claude/rules/persistence.md +26 -0
  6. package/.claude/rules/testing.md +102 -28
  7. package/CLAUDE.md +3 -2
  8. package/FRAMEWORK-API.md +17 -2
  9. package/README.md +24 -3
  10. package/dist/core/common/helpers/file.helper.js +3 -4
  11. package/dist/core/common/helpers/file.helper.js.map +1 -1
  12. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
  13. package/dist/core/common/interfaces/server-options.interface.d.ts +41 -0
  14. package/dist/core/common/interfaces/service-options.interface.d.ts +3 -0
  15. package/dist/core/common/persistence/core-persistence.module.js +12 -2
  16. package/dist/core/common/persistence/core-persistence.module.js.map +1 -1
  17. package/dist/core/common/persistence/data-source.runtime.js +14 -19
  18. package/dist/core/common/persistence/data-source.runtime.js.map +1 -1
  19. package/dist/core/common/persistence/data-store.interface.d.ts +24 -9
  20. package/dist/core/common/persistence/id-format.d.ts +2 -0
  21. package/dist/core/common/persistence/id-format.js +14 -0
  22. package/dist/core/common/persistence/id-format.js.map +1 -1
  23. package/dist/core/common/persistence/model-descriptor.interface.d.ts +6 -0
  24. package/dist/core/common/persistence/model-metadata.registry.d.ts +2 -1
  25. package/dist/core/common/persistence/model-metadata.registry.js +20 -2
  26. package/dist/core/common/persistence/model-metadata.registry.js.map +1 -1
  27. package/dist/core/common/persistence/mongo/sync-schema.plugin.d.ts +18 -0
  28. package/dist/core/common/persistence/mongo/sync-schema.plugin.js +368 -0
  29. package/dist/core/common/persistence/mongo/sync-schema.plugin.js.map +1 -0
  30. package/dist/core/common/persistence/mongoose-data-store.d.ts +19 -9
  31. package/dist/core/common/persistence/mongoose-data-store.js +107 -31
  32. package/dist/core/common/persistence/mongoose-data-store.js.map +1 -1
  33. package/dist/core/common/persistence/persisted-model.decorator.d.ts +3 -0
  34. package/dist/core/common/persistence/persisted-model.decorator.js +9 -0
  35. package/dist/core/common/persistence/persisted-model.decorator.js.map +1 -1
  36. package/dist/core/common/persistence/persistence-runtime.registry.d.ts +5 -1
  37. package/dist/core/common/persistence/persistence-runtime.registry.js.map +1 -1
  38. package/dist/core/common/persistence/policies/sync.policy.d.ts +17 -0
  39. package/dist/core/common/persistence/policies/sync.policy.js +77 -0
  40. package/dist/core/common/persistence/policies/sync.policy.js.map +1 -0
  41. package/dist/core/common/persistence/sql/mysql/mysql-data-store.d.ts +12 -8
  42. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js +82 -29
  43. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js.map +1 -1
  44. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.d.ts +10 -0
  45. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js +61 -4
  46. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js.map +1 -1
  47. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.d.ts +2 -0
  48. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +145 -4
  49. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js.map +1 -1
  50. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.d.ts +10 -0
  51. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js +41 -2
  52. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js.map +1 -1
  53. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js +98 -10
  54. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js.map +1 -1
  55. package/dist/core/common/persistence/sql/sql-data-store.d.ts +38 -14
  56. package/dist/core/common/persistence/sql/sql-data-store.js +212 -38
  57. package/dist/core/common/persistence/sql/sql-data-store.js.map +1 -1
  58. package/dist/core/common/persistence/sql/sql-dialect.d.ts +2 -0
  59. package/dist/core/common/persistence/sql/sql-dialect.js +14 -2
  60. package/dist/core/common/persistence/sql/sql-dialect.js.map +1 -1
  61. package/dist/core/common/persistence/sql/sql-errors.d.ts +2 -1
  62. package/dist/core/common/persistence/sql/sql-errors.js +3 -1
  63. package/dist/core/common/persistence/sql/sql-errors.js.map +1 -1
  64. package/dist/core/common/persistence/sync-access.registry.d.ts +17 -0
  65. package/dist/core/common/persistence/sync-access.registry.js +8 -0
  66. package/dist/core/common/persistence/sync-access.registry.js.map +1 -0
  67. package/dist/core/common/persistence/sync-model.d.ts +29 -0
  68. package/dist/core/common/persistence/sync-model.js +138 -0
  69. package/dist/core/common/persistence/sync-model.js.map +1 -0
  70. package/dist/core/common/services/core-cron-jobs.service.d.ts +27 -0
  71. package/dist/core/common/services/core-cron-jobs.service.js +269 -13
  72. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  73. package/dist/core/common/services/core-redis-pubsub.js +5 -5
  74. package/dist/core/common/services/core-redis-pubsub.js.map +1 -1
  75. package/dist/core/common/services/core-redis.service.d.ts +6 -0
  76. package/dist/core/common/services/core-redis.service.js +57 -0
  77. package/dist/core/common/services/core-redis.service.js.map +1 -1
  78. package/dist/core/common/services/crud.service.d.ts +13 -1
  79. package/dist/core/common/services/crud.service.js +174 -16
  80. package/dist/core/common/services/crud.service.js.map +1 -1
  81. package/dist/core/modules/better-auth/better-auth-sql.helper.d.ts +1 -0
  82. package/dist/core/modules/better-auth/better-auth-sql.helper.js +41 -14
  83. package/dist/core/modules/better-auth/better-auth-sql.helper.js.map +1 -1
  84. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  85. package/dist/core/modules/better-auth/core-better-auth-sql.store.d.ts +4 -7
  86. package/dist/core/modules/better-auth/core-better-auth-sql.store.js +23 -16
  87. package/dist/core/modules/better-auth/core-better-auth-sql.store.js.map +1 -1
  88. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +12 -2
  89. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +82 -5
  90. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  91. package/dist/core/modules/better-auth/core-better-auth.module.js +1 -1
  92. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  93. package/dist/core/modules/error-code/error-codes.d.ts +54 -0
  94. package/dist/core/modules/error-code/error-codes.js +48 -0
  95. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  96. package/dist/core/modules/hub/hub-buffer.js +3 -4
  97. package/dist/core/modules/hub/hub-buffer.js.map +1 -1
  98. package/dist/core/modules/migrate/index.js.map +1 -1
  99. package/dist/core/modules/migrate/migration-runner.d.ts +6 -0
  100. package/dist/core/modules/migrate/migration-runner.js +28 -2
  101. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  102. package/dist/core/modules/migrate/mongo-state-store.d.ts +0 -1
  103. package/dist/core/modules/migrate/mongo-state-store.js +0 -5
  104. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  105. package/dist/core/modules/migrate/sql-migration.helper.d.ts +15 -1
  106. package/dist/core/modules/migrate/sql-migration.helper.js +90 -5
  107. package/dist/core/modules/migrate/sql-migration.helper.js.map +1 -1
  108. package/dist/core/modules/sync/core-sync-base.controller.d.ts +7 -0
  109. package/dist/core/modules/sync/core-sync-base.controller.js +44 -0
  110. package/dist/core/modules/sync/core-sync-base.controller.js.map +1 -0
  111. package/dist/core/modules/sync/core-sync-cursor.d.ts +19 -0
  112. package/dist/core/modules/sync/core-sync-cursor.js +77 -0
  113. package/dist/core/modules/sync/core-sync-cursor.js.map +1 -0
  114. package/dist/core/modules/sync/core-sync-hints.controller.d.ts +4 -0
  115. package/dist/core/modules/sync/core-sync-hints.controller.js +55 -0
  116. package/dist/core/modules/sync/core-sync-hints.controller.js.map +1 -0
  117. package/dist/core/modules/sync/core-sync-hints.service.d.ts +28 -0
  118. package/dist/core/modules/sync/core-sync-hints.service.js +155 -0
  119. package/dist/core/modules/sync/core-sync-hints.service.js.map +1 -0
  120. package/dist/core/modules/sync/core-sync-infrastructure.models.d.ts +2 -0
  121. package/dist/core/modules/sync/core-sync-infrastructure.models.js +17 -0
  122. package/dist/core/modules/sync/core-sync-infrastructure.models.js.map +1 -0
  123. package/dist/core/modules/sync/core-sync-mutation.store.d.ts +43 -0
  124. package/dist/core/modules/sync/core-sync-mutation.store.js +88 -0
  125. package/dist/core/modules/sync/core-sync-mutation.store.js.map +1 -0
  126. package/dist/core/modules/sync/core-sync-state.store.d.ts +18 -0
  127. package/dist/core/modules/sync/core-sync-state.store.js +64 -0
  128. package/dist/core/modules/sync/core-sync-state.store.js.map +1 -0
  129. package/dist/core/modules/sync/core-sync.constants.d.ts +28 -0
  130. package/dist/core/modules/sync/core-sync.constants.js +23 -0
  131. package/dist/core/modules/sync/core-sync.constants.js.map +1 -0
  132. package/dist/core/modules/sync/core-sync.controller.d.ts +8 -0
  133. package/dist/core/modules/sync/core-sync.controller.js +79 -0
  134. package/dist/core/modules/sync/core-sync.controller.js.map +1 -0
  135. package/dist/core/modules/sync/core-sync.decorators.d.ts +12 -0
  136. package/dist/core/modules/sync/core-sync.decorators.js +28 -0
  137. package/dist/core/modules/sync/core-sync.decorators.js.map +1 -0
  138. package/dist/core/modules/sync/core-sync.module.d.ts +16 -0
  139. package/dist/core/modules/sync/core-sync.module.js +50 -0
  140. package/dist/core/modules/sync/core-sync.module.js.map +1 -0
  141. package/dist/core/modules/sync/core-sync.resolver.d.ts +9 -0
  142. package/dist/core/modules/sync/core-sync.resolver.js +46 -0
  143. package/dist/core/modules/sync/core-sync.resolver.js.map +1 -0
  144. package/dist/core/modules/sync/core-sync.service.d.ts +142 -0
  145. package/dist/core/modules/sync/core-sync.service.js +1208 -0
  146. package/dist/core/modules/sync/core-sync.service.js.map +1 -0
  147. package/dist/core/modules/sync/interfaces/core-sync.interfaces.d.ts +56 -0
  148. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js +2 -0
  149. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js.map +1 -0
  150. package/dist/core/modules/sync/models/core-sync-hint.model.d.ts +4 -0
  151. package/dist/core/modules/sync/models/core-sync-hint.model.js +30 -0
  152. package/dist/core/modules/sync/models/core-sync-hint.model.js.map +1 -0
  153. package/dist/core/modules/tenant/core-tenant.guard.js +8 -3
  154. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  155. package/dist/core.module.js +14 -0
  156. package/dist/core.module.js.map +1 -1
  157. package/dist/index.d.ts +16 -0
  158. package/dist/index.js +16 -0
  159. package/dist/index.js.map +1 -1
  160. package/dist/server/modules/error-code/error-codes.d.ts +6 -0
  161. package/dist/server/modules/sync-note/inputs/sync-note-create.input.d.ts +4 -0
  162. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js +27 -0
  163. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js.map +1 -0
  164. package/dist/server/modules/sync-note/inputs/sync-note.input.d.ts +6 -0
  165. package/dist/server/modules/sync-note/inputs/sync-note.input.js +37 -0
  166. package/dist/server/modules/sync-note/inputs/sync-note.input.js.map +1 -0
  167. package/dist/server/modules/sync-note/sync-note.model.d.ts +10 -0
  168. package/dist/server/modules/sync-note/sync-note.model.js +73 -0
  169. package/dist/server/modules/sync-note/sync-note.model.js.map +1 -0
  170. package/dist/server/modules/sync-note/sync-note.module.d.ts +2 -0
  171. package/dist/server/modules/sync-note/sync-note.module.js +21 -0
  172. package/dist/server/modules/sync-note/sync-note.module.js.map +1 -0
  173. package/dist/server/modules/sync-note/sync-note.service.d.ts +10 -0
  174. package/dist/server/modules/sync-note/sync-note.service.js +35 -0
  175. package/dist/server/modules/sync-note/sync-note.service.js.map +1 -0
  176. package/docs/REQUEST-LIFECYCLE.md +3 -2
  177. package/docs/choosing-a-database.md +7 -4
  178. package/docs/offline-sync-protocol.md +410 -0
  179. package/docs/security-overrides.md +14 -0
  180. package/migration-guides/12.0.x-to-12.1.x.md +9 -4
  181. package/migration-guides/12.1.0-to-12.1.1.md +125 -0
  182. package/migration-guides/12.1.x-to-12.2.x.md +154 -0
  183. package/package.json +10 -8
  184. package/src/core/common/helpers/file.helper.ts +7 -5
  185. package/src/core/common/helpers/graphql-ws-connection.helper.ts +3 -2
  186. package/src/core/common/interfaces/cron-job-config.interface.ts +7 -2
  187. package/src/core/common/interfaces/server-options.interface.ts +149 -0
  188. package/src/core/common/interfaces/service-options.interface.ts +15 -0
  189. package/src/core/common/persistence/core-persistence.module.ts +21 -2
  190. package/src/core/common/persistence/data-source.runtime.ts +22 -23
  191. package/src/core/common/persistence/data-store.interface.ts +65 -12
  192. package/src/core/common/persistence/id-format.ts +26 -0
  193. package/src/core/common/persistence/model-descriptor.interface.ts +12 -0
  194. package/src/core/common/persistence/model-metadata.registry.ts +31 -2
  195. package/src/core/common/persistence/mongo/sync-schema.plugin.ts +508 -0
  196. package/src/core/common/persistence/mongoose-data-store.ts +171 -35
  197. package/src/core/common/persistence/persisted-model.decorator.ts +26 -0
  198. package/src/core/common/persistence/persistence-runtime.registry.ts +16 -5
  199. package/src/core/common/persistence/policies/sync.policy.ts +121 -0
  200. package/src/core/common/persistence/sql/mysql/mysql-data-store.ts +108 -30
  201. package/src/core/common/persistence/sql/mysql/mysql-schema.compiler.ts +107 -4
  202. package/src/core/common/persistence/sql/mysql/mysql-schema.migrator.ts +194 -3
  203. package/src/core/common/persistence/sql/postgres/postgres-schema.compiler.ts +74 -2
  204. package/src/core/common/persistence/sql/postgres/postgres-schema.migrator.ts +139 -7
  205. package/src/core/common/persistence/sql/sql-data-store.ts +325 -44
  206. package/src/core/common/persistence/sql/sql-dialect.ts +38 -1
  207. package/src/core/common/persistence/sql/sql-errors.ts +5 -1
  208. package/src/core/common/persistence/sql/sql-executor.interface.ts +4 -1
  209. package/src/core/common/persistence/sync-access.registry.ts +50 -0
  210. package/src/core/common/persistence/sync-model.ts +270 -0
  211. package/src/core/common/services/core-cron-jobs.service.ts +441 -27
  212. package/src/core/common/services/core-redis-pubsub.ts +8 -6
  213. package/src/core/common/services/core-redis.service.ts +99 -0
  214. package/src/core/common/services/crud.service.ts +257 -19
  215. package/src/core/modules/better-auth/README.md +25 -1
  216. package/src/core/modules/better-auth/better-auth-sql.helper.ts +56 -24
  217. package/src/core/modules/better-auth/better-auth.config.ts +4 -3
  218. package/src/core/modules/better-auth/core-better-auth-sql.store.ts +28 -21
  219. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +122 -6
  220. package/src/core/modules/better-auth/core-better-auth.module.ts +2 -2
  221. package/src/core/modules/error-code/error-codes.ts +59 -0
  222. package/src/core/modules/hub/README.md +5 -2
  223. package/src/core/modules/hub/hub-buffer.ts +13 -7
  224. package/src/core/modules/migrate/README.md +20 -11
  225. package/src/core/modules/migrate/index.ts +1 -0
  226. package/src/core/modules/migrate/migration-runner.ts +68 -3
  227. package/src/core/modules/migrate/mongo-state-store.ts +7 -30
  228. package/src/core/modules/migrate/sql-migration.helper.ts +141 -7
  229. package/src/core/modules/sync/INTEGRATION-CHECKLIST.md +95 -0
  230. package/src/core/modules/sync/README.md +188 -0
  231. package/src/core/modules/sync/SECURITY.md +65 -0
  232. package/src/core/modules/sync/core-sync-base.controller.ts +43 -0
  233. package/src/core/modules/sync/core-sync-cursor.ts +117 -0
  234. package/src/core/modules/sync/core-sync-hints.controller.ts +52 -0
  235. package/src/core/modules/sync/core-sync-hints.service.ts +178 -0
  236. package/src/core/modules/sync/core-sync-infrastructure.models.ts +40 -0
  237. package/src/core/modules/sync/core-sync-mutation.store.ts +146 -0
  238. package/src/core/modules/sync/core-sync-state.store.ts +89 -0
  239. package/src/core/modules/sync/core-sync.constants.ts +51 -0
  240. package/src/core/modules/sync/core-sync.controller.ts +68 -0
  241. package/src/core/modules/sync/core-sync.decorators.ts +84 -0
  242. package/src/core/modules/sync/core-sync.module.ts +75 -0
  243. package/src/core/modules/sync/core-sync.resolver.ts +35 -0
  244. package/src/core/modules/sync/core-sync.service.ts +1728 -0
  245. package/src/core/modules/sync/interfaces/core-sync.interfaces.ts +85 -0
  246. package/src/core/modules/sync/models/core-sync-hint.model.ts +18 -0
  247. package/src/core/modules/tenant/README.md +1 -0
  248. package/src/core/modules/tenant/core-tenant.guard.ts +16 -3
  249. package/src/core.module.ts +25 -0
  250. package/src/index.ts +21 -0
  251. package/src/server/modules/sync-note/inputs/sync-note-create.input.ts +14 -0
  252. package/src/server/modules/sync-note/inputs/sync-note.input.ts +23 -0
  253. package/src/server/modules/sync-note/sync-note.model.ts +77 -0
  254. package/src/server/modules/sync-note/sync-note.module.ts +16 -0
  255. package/src/server/modules/sync-note/sync-note.service.ts +25 -0
@@ -44,6 +44,12 @@ export class CoreRedisService implements OnApplicationShutdown, OnModuleInit {
44
44
  /** Dedicated subscriber connection (a subscribing client cannot run commands) */
45
45
  protected subscriberClient?: Redis;
46
46
 
47
+ /**
48
+ * Channels the shared subscriber must stay subscribed to, with the number of holders of each. Kept so every `ready`
49
+ * of the subscriber connection can subscribe them again — see subscribeChannel().
50
+ */
51
+ protected readonly subscribedChannels = new Map<string, number>();
52
+
47
53
  constructor(protected readonly configService: ConfigService) {
48
54
  const raw = this.configService.getFastButReadOnly<boolean | IRedisConfig | undefined>('redis');
49
55
  if (raw === undefined || raw === null || raw === false) {
@@ -77,6 +83,14 @@ export class CoreRedisService implements OnApplicationShutdown, OnModuleInit {
77
83
  return !!this.config;
78
84
  }
79
85
 
86
+ /**
87
+ * Whether the shared connection is up right now. `enabled` only says Redis is configured; a consumer that wants to
88
+ * know whether a command can succeed asks this. `false` without Redis.
89
+ */
90
+ isReady(): boolean {
91
+ return this.sharedClient?.status === 'ready';
92
+ }
93
+
80
94
  /**
81
95
  * Normalized Redis configuration, or undefined when disabled
82
96
  */
@@ -113,6 +127,45 @@ export class CoreRedisService implements OnApplicationShutdown, OnModuleInit {
113
127
  }
114
128
  this.sharedClient = this.newConnection('shared');
115
129
  this.logActivationSummary();
130
+ await this.checkReachableAtBoot();
131
+ }
132
+
133
+ /**
134
+ * Ask Redis once at boot and say so when it does not answer.
135
+ *
136
+ * The boot continues either way: failing it would take the whole fleet down with Redis, and most consumers degrade
137
+ * (rate limits, cron leases, the MCP session registry and the tenant broadcast fall back to process-local behaviour).
138
+ * GraphQL subscriptions and TUS upload locks have no fallback and fail until Redis answers — which is why the outage
139
+ * deserves a line here; before, it showed up only as each user's own timeout. Bounded, so an unreachable Redis
140
+ * delays the boot by at most a few seconds.
141
+ */
142
+ protected async checkReachableAtBoot(timeoutMs = 3000): Promise<void> {
143
+ const client = this.sharedClient;
144
+ if (!client) {
145
+ return;
146
+ }
147
+ const ping = client.ping();
148
+ // The ping may settle after the timeout; its rejection must not surface as unhandled.
149
+ ping.catch(() => undefined);
150
+ let timer: ReturnType<typeof setTimeout> | undefined;
151
+ try {
152
+ await Promise.race([
153
+ ping,
154
+ new Promise((_resolve, reject) => {
155
+ timer = setTimeout(() => reject(new Error(`no answer within ${timeoutMs} ms`)), timeoutMs);
156
+ timer.unref?.();
157
+ }),
158
+ ]);
159
+ } catch (error) {
160
+ this.logger.warn(
161
+ `Redis is configured but not reachable at boot (${error instanceof Error ? error.message : String(error)}). ` +
162
+ 'The application starts anyway. Rate limits, cron leases and the tenant broadcast fall back to ' +
163
+ 'process-local behaviour; GraphQL subscriptions and resumable uploads (TUS) fail until Redis answers. ' +
164
+ 'Subscriptions are established once it does.',
165
+ );
166
+ } finally {
167
+ clearTimeout(timer);
168
+ }
116
169
  }
117
170
 
118
171
  /**
@@ -183,10 +236,55 @@ export class CoreRedisService implements OnApplicationShutdown, OnModuleInit {
183
236
  this.getClient();
184
237
  if (!this.subscriberClient) {
185
238
  this.subscriberClient = this.newConnection('subscriber');
239
+ // ioredis re-subscribes on reconnect only the channels whose SUBSCRIBE succeeded. One that failed while Redis was
240
+ // down (at boot, typically) is lost for good, so every `ready` subscribes the registered channels again.
241
+ this.subscriberClient.on?.('ready', this.onSubscriberReady);
186
242
  }
187
243
  return this.subscriberClient;
188
244
  }
189
245
 
246
+ /**
247
+ * Subscribe the shared subscriber connection to `channel` and keep it subscribed.
248
+ *
249
+ * The channel stays registered until releaseChannel() and is subscribed again on every `ready` of the subscriber
250
+ * connection (Redis SUBSCRIBE is idempotent). Rejects when Redis does not answer; the channel stays registered then,
251
+ * so the next `ready` subscribes it. Several holders of one channel are counted.
252
+ */
253
+ async subscribeChannel(channel: string): Promise<void> {
254
+ const subscriber = this.getSubscriber();
255
+ this.subscribedChannels.set(channel, (this.subscribedChannels.get(channel) ?? 0) + 1);
256
+ await subscriber.subscribe(channel);
257
+ }
258
+
259
+ /**
260
+ * Release one hold on `channel`; the last holder unsubscribes it
261
+ */
262
+ async releaseChannel(channel: string): Promise<void> {
263
+ const holders = (this.subscribedChannels.get(channel) ?? 0) - 1;
264
+ if (holders > 0) {
265
+ this.subscribedChannels.set(channel, holders);
266
+ return;
267
+ }
268
+ this.subscribedChannels.delete(channel);
269
+ await this.subscriberClient?.unsubscribe(channel);
270
+ }
271
+
272
+ /**
273
+ * Subscribe every registered channel again. An arrow function, so it keeps `this` as an event listener; `void` is
274
+ * safe because the failure is caught here.
275
+ */
276
+ protected readonly onSubscriberReady = (): void => {
277
+ const channels = [...this.subscribedChannels.keys()];
278
+ if (!channels.length || !this.subscriberClient) {
279
+ return;
280
+ }
281
+ void this.subscriberClient.subscribe(...channels).catch((error: unknown) => {
282
+ this.logger.warn(
283
+ `Re-subscribing ${channels.length} Redis channel(s) failed: ${error instanceof Error ? error.message : String(error)}`,
284
+ );
285
+ });
286
+ };
287
+
190
288
  /**
191
289
  * Create a NEW dedicated connection (e.g. for BullMQ, blocking commands).
192
290
  * The connection is tracked and closed on application shutdown.
@@ -215,6 +313,7 @@ export class CoreRedisService implements OnApplicationShutdown, OnModuleInit {
215
313
  this.clients = [];
216
314
  this.sharedClient = undefined;
217
315
  this.subscriberClient = undefined;
316
+ this.subscribedChannels.clear();
218
317
  }
219
318
 
220
319
  /**
@@ -9,7 +9,19 @@ import { convertFilterArgsToQuery } from '../helpers/filter.helper.js';
9
9
  import { mergePlain, prepareServiceOptionsForCreate } from '../helpers/input.helper.js';
10
10
  import { ServiceOptions } from '../interfaces/service-options.interface.js';
11
11
  import type { FindOptions, Where } from '../persistence/data-store.interface.js';
12
+ import { getModelDescriptor } from '../persistence/model-metadata.registry.js';
13
+ import { getSyncAccessListener } from '../persistence/sync-access.registry.js';
12
14
  import { UnsupportedCapabilityError } from '../persistence/unsupported-capability.error.js';
15
+ import { isDuplicateIdError } from '../persistence/mongoose-data-store.js';
16
+ import { includeTombstones, syncOfSchema } from '../persistence/mongo/sync-schema.plugin.js';
17
+ import type { SyncDescriptor } from '../persistence/model-descriptor.interface.js';
18
+ import {
19
+ assertClientId,
20
+ assertExpectedVersion,
21
+ insertWithServerId,
22
+ suppliedId,
23
+ VersionConflictError,
24
+ } from '../persistence/policies/sync.policy.js';
13
25
  import { CoreModel } from '../models/core-model.model.js';
14
26
  import { PaginationInfo } from '../models/pagination-info.model.js';
15
27
  import { PlainObject } from '../types/plain-object.type.js';
@@ -74,14 +86,36 @@ export abstract class CrudService<
74
86
  return this.process(
75
87
  async (data) => {
76
88
  const currentUserId = serviceOptions?.currentUser?.id;
89
+ const createId = serviceOptions?.createId;
77
90
  if (this.usesNeutralStore) {
78
- return this.dataStore.insert({ ...data.input, createdBy: currentUserId, updatedBy: currentUserId } as any);
91
+ return this.syncChanged(
92
+ await this.dataStore.insert({
93
+ ...data.input,
94
+ ...(createId === undefined || createId === null ? {} : { id: createId }),
95
+ createdBy: currentUserId,
96
+ updatedBy: currentUserId,
97
+ } as any),
98
+ );
79
99
  }
80
- return new (this.mainDbModel as MongooseModel<Document & Model>)({
81
- ...data.input,
82
- createdBy: currentUserId,
83
- updatedBy: currentUserId,
84
- }).save();
100
+ const build = (id?: string) =>
101
+ new (this.mainDbModel as MongooseModel<Document & Model>)({
102
+ ...data.input,
103
+ ...(id === undefined ? {} : { _id: id }),
104
+ createdBy: currentUserId,
105
+ updatedBy: currentUserId,
106
+ });
107
+ const sync = this.mongoSync;
108
+ if (!sync) {
109
+ return build(createId ?? undefined).save();
110
+ }
111
+ // A synchronized model keeps a client's id in its format and replaces a taken server id (plan §3.3, E6)
112
+ const supplied = createId ?? suppliedId(data.input ?? {});
113
+ if (supplied !== undefined && supplied !== null) {
114
+ return this.syncChanged(
115
+ await build(assertClientId(supplied, sync.idFormat, this.mainDbModel.modelName)).save(),
116
+ );
117
+ }
118
+ return this.syncChanged(await insertWithServerId(sync.idFormat, (id) => build(id).save(), isDuplicateIdError));
85
119
  },
86
120
  { input, serviceOptions },
87
121
  );
@@ -110,8 +144,8 @@ export abstract class CrudService<
110
144
  /**
111
145
  * Get distinct values of a property
112
146
  */
113
- distinct(property: string): Promise<string[]> {
114
- return this.dataStore.distinct(property) as Promise<string[]>;
147
+ distinct(property: string, serviceOptions?: ServiceOptions): Promise<string[]> {
148
+ return this.dataStore.distinct(property, undefined, tombstoneOption(serviceOptions)) as Promise<string[]>;
115
149
  }
116
150
 
117
151
  /**
@@ -119,13 +153,16 @@ export abstract class CrudService<
119
153
  */
120
154
  async get(id: string, serviceOptions?: ServiceOptions): Promise<Model> {
121
155
  if (this.usesNeutralStore) {
122
- const record = await this.dataStore.findById(id, neutralSelect(serviceOptions?.select));
156
+ const record = await this.dataStore.findById(id, {
157
+ ...neutralSelect(serviceOptions?.select),
158
+ ...tombstoneOption(serviceOptions),
159
+ });
123
160
  if (!record) {
124
161
  throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
125
162
  }
126
163
  return this.process(async () => record, { dbObject: record, serviceOptions });
127
164
  }
128
- const query = this.mainDbModel.findById(id);
165
+ const query = this.withTombstones(this.mainDbModel.findById(id), serviceOptions);
129
166
  if (serviceOptions?.select) {
130
167
  query.select(serviceOptions.select);
131
168
  }
@@ -190,7 +227,10 @@ export abstract class CrudService<
190
227
  }
191
228
 
192
229
  // Find in DB
193
- let find = this.mainDbModel.find(filterQuery.filterQuery, null, filterQuery.queryOptions);
230
+ let find = this.withTombstones(
231
+ this.mainDbModel.find(filterQuery.filterQuery, null, filterQuery.queryOptions),
232
+ serviceOptions,
233
+ );
194
234
  const collation = serviceOptions?.collation || ConfigService.get('mongoose.collation');
195
235
  if (collation) {
196
236
  find = find.collation(collation);
@@ -261,7 +301,7 @@ export abstract class CrudService<
261
301
  const { options, where } = neutralQuery(filterQuery, serviceOptions);
262
302
  const [items, totalCount] = await Promise.all([
263
303
  this.dataStore.find(where, filter?.samples ? { ...options, sample: filter.samples } : options),
264
- this.dataStore.count(where),
304
+ this.dataStore.count(where, tombstoneOption(serviceOptions)),
265
305
  ]);
266
306
  const queryOptions = filterQuery.queryOptions || {};
267
307
  return {
@@ -323,7 +363,12 @@ export abstract class CrudService<
323
363
  // Find and process db items
324
364
  const collation = serviceOptions?.collation || ConfigService.get('mongoose.collation');
325
365
  const dbResult =
326
- (await this.mainDbModel.aggregate(aggregation, collation ? { collation } : {}).exec())[0] || {};
366
+ (
367
+ await this.withTombstones(
368
+ this.mainDbModel.aggregate(aggregation, collation ? { collation } : {}),
369
+ serviceOptions,
370
+ ).exec()
371
+ )[0] || {};
327
372
  dbResult.totalCount = dbResult.totalCount?.[0]?.total || 0;
328
373
  dbResult.items = dbResult.items?.map((item) => this.mainDbModel.hydrate(item)) || [];
329
374
 
@@ -443,13 +488,17 @@ export abstract class CrudService<
443
488
  return this.dataStore.findOne(where, {
444
489
  collation: options.collation,
445
490
  exclude: options.exclude,
491
+ includeTombstones: options.includeTombstones,
446
492
  select: options.select,
447
493
  sort: options.sort,
448
494
  });
449
495
  }
450
496
 
451
497
  // Find in DB
452
- let find = this.mainDbModel.findOne(filterQuery.filterQuery, null, filterQuery.queryOptions);
498
+ let find = this.withTombstones(
499
+ this.mainDbModel.findOne(filterQuery.filterQuery, null, filterQuery.queryOptions),
500
+ serviceOptions,
501
+ );
453
502
  const collation = serviceOptions?.collation || ConfigService.get('mongoose.collation');
454
503
  if (collation) {
455
504
  find = find.collation(collation);
@@ -657,18 +706,31 @@ export abstract class CrudService<
657
706
  * Update item via ID
658
707
  */
659
708
  async update(id: string, input: PlainObject<UpdateInput>, serviceOptions?: ServiceOptions): Promise<Model> {
709
+ const expectedVersion = this.expectedVersionOf(serviceOptions);
660
710
  if (this.usesNeutralStore) {
661
711
  const stored = await this.dataStore.findById(id);
662
712
  if (!stored) {
663
713
  throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
664
714
  }
715
+ this.assertVersion(id, stored, expectedVersion);
665
716
  return this.process(
666
717
  async (data) => {
667
718
  // The same write as on MongoDB: the stored record merged with the input, written back whole — minus
668
719
  // the id, which never changes, and the update time, which the store stamps
720
+ const before = this.syncAccessSnapshot(stored);
669
721
  const merged = mergePlain(stored, data.input, { updatedBy: serviceOptions?.currentUser?.id });
670
722
  const { _id: _mongoId, id: _id, updatedAt: _updatedAt, ...patch } = merged as Record<string, any>;
671
- return this.dataStore.updateById(id, patch as any);
723
+ const updated =
724
+ expectedVersion === undefined
725
+ ? await this.dataStore.updateById(id, patch as any)
726
+ : // One statement with the version as its condition: of two changes made on the same version, one applies
727
+ ((await this.dataStore.updateOne({ id, version: expectedVersion } as Where, patch as any)) ??
728
+ this.versionConflict(id));
729
+ if (updated) {
730
+ await this.reportSyncAccessMove(before, merged);
731
+ this.reportSyncChange(before, merged);
732
+ }
733
+ return updated;
672
734
  },
673
735
  { dbObject: stored, input, serviceOptions },
674
736
  );
@@ -677,15 +739,28 @@ export abstract class CrudService<
677
739
  if (!dbObject) {
678
740
  throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
679
741
  }
742
+ this.assertVersion(id, dbObject, expectedVersion);
680
743
  return this.process(
681
744
  async (data) => {
682
745
  const currentUserId = serviceOptions?.currentUser?.id;
746
+ const before = this.syncAccessSnapshot(dbObject);
683
747
  const merged = mergePlain(dbObject, data.input, { updatedBy: currentUserId });
684
- const query = this.mainDbModel.findByIdAndUpdate(id, merged, { returnDocument: 'after' });
748
+ const query =
749
+ expectedVersion === undefined
750
+ ? this.mainDbModel.findByIdAndUpdate(id, merged, { returnDocument: 'after' })
751
+ : this.mainDbModel.findOneAndUpdate({ _id: id, version: expectedVersion } as any, merged, {
752
+ returnDocument: 'after',
753
+ });
685
754
  if (serviceOptions?.select) {
686
755
  query.select(serviceOptions.select);
687
756
  }
688
- return await query.exec();
757
+ const updated = await query.exec();
758
+ if (!updated) {
759
+ return expectedVersion === undefined ? updated : this.versionConflict(id);
760
+ }
761
+ await this.reportSyncAccessMove(before, merged);
762
+ this.reportSyncChange(before, merged);
763
+ return updated;
689
764
  },
690
765
  { dbObject, input, serviceOptions },
691
766
  );
@@ -715,14 +790,17 @@ export abstract class CrudService<
715
790
  * Delete item via ID
716
791
  */
717
792
  async delete(id: string, serviceOptions?: ServiceOptions): Promise<Model> {
793
+ const expectedVersion = this.expectedVersionOf(serviceOptions);
718
794
  if (this.usesNeutralStore) {
719
795
  const stored = await this.dataStore.findById(id, neutralSelect(serviceOptions?.select));
720
796
  if (!stored) {
721
797
  throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
722
798
  }
799
+ this.assertVersion(id, stored, expectedVersion);
723
800
  return this.process(
724
801
  async () => {
725
- await this.dataStore.deleteById(id);
802
+ await this.removeOrTombstone(id, expectedVersion);
803
+ this.reportSyncChange(stored);
726
804
  return stored;
727
805
  },
728
806
  { dbObject: stored, serviceOptions },
@@ -736,15 +814,169 @@ export abstract class CrudService<
736
814
  if (!dbObject) {
737
815
  throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
738
816
  }
817
+ this.assertVersion(id, dbObject, expectedVersion);
739
818
  return this.process(
740
819
  async () => {
741
- await this.mainDbModel.findByIdAndDelete(id).exec();
820
+ if (this.mongoSync) {
821
+ // A synchronized model's delete leaves a tombstone, which is how clients learn of it
822
+ await this.removeOrTombstone(id, expectedVersion);
823
+ this.reportSyncChange(dbObject);
824
+ } else {
825
+ await this.mainDbModel.findByIdAndDelete(id).exec();
826
+ }
742
827
  return dbObject;
743
828
  },
744
829
  { dbObject, serviceOptions },
745
830
  );
746
831
  }
747
832
 
833
+ // -- synchronized models (`@PersistedModel({ sync })`) ------------------------------------------------------
834
+
835
+ /** The sync declaration of the main model on its MongoDB path, read from the Mongoose schema */
836
+ protected get mongoSync(): SyncDescriptor | undefined {
837
+ return this.usesNeutralStore ? undefined : syncOfSchema(this.mainDbModel?.schema);
838
+ }
839
+
840
+ /** Whether the main model is synchronized, on whichever database it lives */
841
+ protected get isSynchronized(): boolean {
842
+ return !!this.dataStore?.capabilities.changeOrder;
843
+ }
844
+
845
+ /** A Mongoose query or aggregation that also sees tombstones when the caller asked (`includeTombstones`) */
846
+ protected withTombstones<Q extends object>(query: Q, serviceOptions?: ServiceOptions): Q {
847
+ return serviceOptions?.includeTombstones && this.mongoSync ? includeTombstones(query) : query;
848
+ }
849
+
850
+ /** `serviceOptions.expectedVersion`, checked; only a synchronized model has a version */
851
+ protected expectedVersionOf(serviceOptions?: ServiceOptions): number | undefined {
852
+ if (serviceOptions?.expectedVersion === undefined || serviceOptions.expectedVersion === null) {
853
+ return undefined;
854
+ }
855
+ if (!this.isSynchronized) {
856
+ throw new UnsupportedCapabilityError(
857
+ `expectedVersion: ${this.mainModelConstructor?.name ?? this.dataStore?.modelName} is not a synchronized model ` +
858
+ '(@PersistedModel({ sync }))',
859
+ );
860
+ }
861
+ return assertExpectedVersion(serviceOptions.expectedVersion);
862
+ }
863
+
864
+ /** The early answer to a conditional change on a record that already has another version */
865
+ protected assertVersion(id: string, stored: any, expectedVersion: number | undefined): void {
866
+ if (expectedVersion !== undefined && stored?.version !== expectedVersion) {
867
+ this.versionConflict(id);
868
+ }
869
+ }
870
+
871
+ protected versionConflict(id: string): never {
872
+ throw new VersionConflictError(this.mainModelConstructor?.name ?? this.dataStore.modelName, id);
873
+ }
874
+
875
+ /**
876
+ * The tenant and scope fields of a stored record as they are BEFORE a write — taken before `mergePlain()`, which
877
+ * merges the input INTO the stored object. `undefined` when nobody listens or the model is not synchronized.
878
+ */
879
+ protected syncAccessSnapshot(stored: Record<string, any> | undefined): Record<string, any> | undefined {
880
+ if (!stored || !getSyncAccessListener() || !this.isSynchronized) {
881
+ return undefined;
882
+ }
883
+ const scope = getModelDescriptor(this.dataStore.modelName)?.sync?.scope ?? [];
884
+ return Object.fromEntries(
885
+ ['tenantId', ...scope].map((field) => [field, Array.isArray(stored[field]) ? [...stored[field]] : stored[field]]),
886
+ );
887
+ }
888
+
889
+ /**
890
+ * Tell the sync module that this write took a synchronized record away from users who could see it: moved to
891
+ * another tenant, or a scope field (`sync.scope`, e.g. `ownerId`) changed away from a user (offline-sync plan §3.8).
892
+ * Their clients then answer the next pull with a full resync and lose the local copy. A no-op without the module.
893
+ * A failure is logged, never thrown: the write it follows has happened.
894
+ */
895
+ protected async reportSyncAccessMove(
896
+ stored: Record<string, any> | undefined,
897
+ written: Record<string, any>,
898
+ ): Promise<void> {
899
+ const listener = getSyncAccessListener();
900
+ if (!listener || !stored) {
901
+ return;
902
+ }
903
+ const model = this.dataStore.modelName;
904
+ const ids = (value: unknown): string[] =>
905
+ value === undefined || value === null
906
+ ? []
907
+ : (Array.isArray(value) ? value : [value]).flatMap((item) =>
908
+ item === undefined || item === null ? [] : [String(getStringIds(item))],
909
+ );
910
+ const after = (field: string) => (field in written ? written[field] : stored[field]);
911
+ const [tenantBefore] = ids(stored.tenantId);
912
+ const tenantChanged = !!tenantBefore && ids(after('tenantId'))[0] !== tenantBefore;
913
+ const usersLost = (getModelDescriptor(model)?.sync?.scope ?? []).flatMap((field) => {
914
+ const now = new Set(ids(after(field)));
915
+ return ids(stored[field]).filter((user) => !now.has(user));
916
+ });
917
+ if (!tenantChanged && !usersLost.length) {
918
+ return;
919
+ }
920
+ try {
921
+ await listener.recordMoved({ model, tenantChanged, tenantId: tenantBefore, usersLost: [...new Set(usersLost)] });
922
+ } catch (error) {
923
+ Logger.error(
924
+ `Sync access epoch for a moved ${model} record not bumped — affected clients keep their copy until their next ` +
925
+ `full resync: ${(error as Error)?.message ?? error}`,
926
+ this.constructor.name,
927
+ );
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Tell the sync module that a write of a synchronized record succeeded, so the subscribers of its hints learn of it
933
+ * (offline-sync plan §3.9) — with the tenants the record was in before and after the write. A no-op without the
934
+ * module and on any other model; a failure is logged, never thrown: the write has happened.
935
+ */
936
+ protected reportSyncChange(...records: (Record<string, any> | null | undefined)[]): void {
937
+ const listener = getSyncAccessListener();
938
+ if (!listener?.recordChanged || !this.isSynchronized) {
939
+ return;
940
+ }
941
+ const tenantIds = new Set<string>();
942
+ for (const record of records) {
943
+ const tenantId = record?.tenantId;
944
+ if (tenantId !== undefined && tenantId !== null && tenantId !== '') {
945
+ tenantIds.add(String(getStringIds(tenantId)));
946
+ }
947
+ }
948
+ try {
949
+ listener.recordChanged({ model: this.dataStore.modelName, tenantIds: [...tenantIds] });
950
+ } catch (error) {
951
+ Logger.warn(
952
+ `Sync hint for a ${this.dataStore.modelName} write not sent — clients learn of it at their next pull: ` +
953
+ `${(error as Error)?.message ?? error}`,
954
+ this.constructor.name,
955
+ );
956
+ }
957
+ }
958
+
959
+ /** `reportSyncChange()` for a created record, which it answers */
960
+ protected syncChanged<R>(created: R): R {
961
+ this.reportSyncChange(created as any);
962
+ return created;
963
+ }
964
+
965
+ /**
966
+ * Delete through the store: a synchronized model's record becomes a tombstone (conditional on `expectedVersion`),
967
+ * any other model's is removed
968
+ */
969
+ protected async removeOrTombstone(id: string, expectedVersion: number | undefined): Promise<void> {
970
+ if (expectedVersion === undefined) {
971
+ await this.dataStore.deleteById(id);
972
+ return;
973
+ }
974
+ const removed = await this.dataStore.deleteOne({ id, version: expectedVersion } as Where);
975
+ if (!removed) {
976
+ this.versionConflict(id);
977
+ }
978
+ }
979
+
748
980
  /**
749
981
  * Delete item via ID without checks or restrictions
750
982
  * Warning: Disables the handling of rights and restrictions!
@@ -897,6 +1129,11 @@ function neutralSelect(select: unknown): Pick<FindOptions, 'exclude' | 'select'>
897
1129
  };
898
1130
  }
899
1131
 
1132
+ /** `includeTombstones` of the service options as a store option */
1133
+ function tombstoneOption(serviceOptions?: ServiceOptions): { includeTombstones?: boolean } {
1134
+ return serviceOptions?.includeTombstones ? { includeTombstones: true } : {};
1135
+ }
1136
+
900
1137
  /** The filter and query options of a CrudService call as the neutral filter and find options */
901
1138
  function neutralQuery(
902
1139
  query: { filterQuery?: unknown; queryOptions?: QueryOptions },
@@ -906,6 +1143,7 @@ function neutralQuery(
906
1143
  const options: FindOptions = {
907
1144
  collation: (serviceOptions?.collation || ConfigService.get('mongoose.collation')) as Record<string, unknown>,
908
1145
  ...neutralSelect(serviceOptions?.select),
1146
+ ...tombstoneOption(serviceOptions),
909
1147
  };
910
1148
  const skip = queryOptions.skip ?? queryOptions.offset;
911
1149
  const limit = queryOptions.limit ?? queryOptions.take;
@@ -1090,7 +1090,9 @@ built-in Kysely adapter on that source's application pool and shares ONE `users`
1090
1090
  Its tables are snake_case (`session`, `account`, `verification`, `jwks`, `two_factor`, `passkey`), ids keep the
1091
1091
  ObjectId format, and its migrations run at boot as the owner (`database.ownerUrl`) — without an owner connection the
1092
1092
  boot stops while any are pending. The two MongoDB steps above are skipped there. Nothing is copied from MongoDB.
1093
- Details: `.claude/rules/better-auth.md` §11.
1093
+ This holds on PostgreSQL and, since 12.1.1, on MySQL 8.4 and MariaDB 11.8: there Better-Auth's tables are created in the
1094
+ framework's collation `utf8mb4_bin` whatever the database's default, and Better-Auth's own column types apply
1095
+ (`timestamp(3)`, `varchar(36)` ids). Details: `.claude/rules/better-auth.md` §11.
1094
1096
 
1095
1097
  ---
1096
1098
 
@@ -1596,6 +1598,28 @@ export class MyService {
1596
1598
  | `hasRole(roles)` | function | Check if user has any of the specified roles |
1597
1599
  | `_authenticatedViaBetterAuth` | true | Marker for Better-Auth authenticated users |
1598
1600
 
1601
+ ### Role and Verification Cache
1602
+
1603
+ `mapSessionUser()` reads `roles` and `verified` from the user record on every request, and keeps the answer for
1604
+ **15 seconds** per user (at most 500 users, about 100 bytes each). Under test runners and `lt dev` the cache is off,
1605
+ so a test that changes a user's roles directly in the database sees the change at once.
1606
+
1607
+ A change must not wait for that bound, so `invalidateUserCache(iamId)` drops the entry. `CoreUserService.setRoles()`
1608
+ and `CoreUserService.update()` (when the input touches `roles`, `verified` or `emailVerified`) call it themselves.
1609
+ Call it yourself when your project changes these fields in another way.
1610
+
1611
+ | Configuration | What `invalidateUserCache()` reaches |
1612
+ | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1613
+ | without `redis` | the cache of this process |
1614
+ | with `redis` | the cache of **every replica**: the invalidation is broadcast on `<keyPrefix>:iam-user-cache:invalidate`, and each replica that receives it also re-checks the user's open GraphQL WebSockets at once |
1615
+
1616
+ Without the broadcast, a withdrawn admin role kept working for up to 15 seconds on every replica except the one that
1617
+ withdrew it, and an open subscription on another replica until its next periodic re-check
1618
+ (`graphQl.subscriptionRevalidationMs`). Each replica subscribes again after a Redis restart. A broadcast that cannot
1619
+ be sent is logged as a warning; the other replicas then follow within the 15 seconds.
1620
+
1621
+ A subclass may change the duration through the `protected` field `userCacheTtlMs` (`0` switches the cache off).
1622
+
1599
1623
  ## CoreModule.forRoot() Signatures
1600
1624
 
1601
1625
  Two signatures are available for different use cases: