@lenne.tech/nest-server 11.32.4 → 11.33.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 (260) 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/core/common/helpers/content-disposition.helper.d.ts +1 -0
  7. package/dist/core/common/helpers/content-disposition.helper.js +14 -0
  8. package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
  9. package/dist/core/common/helpers/file.helper.d.ts +7 -0
  10. package/dist/core/common/helpers/file.helper.js +21 -4
  11. package/dist/core/common/helpers/file.helper.js.map +1 -1
  12. package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
  13. package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
  14. package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
  15. package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
  16. package/dist/core/common/helpers/project-name.helper.js +38 -0
  17. package/dist/core/common/helpers/project-name.helper.js.map +1 -0
  18. package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
  19. package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
  20. package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
  21. package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
  22. package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
  23. package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
  24. package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
  25. package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
  26. package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
  27. package/dist/core/common/services/core-cron-jobs.service.js +278 -27
  28. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  29. package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
  30. package/dist/core/common/services/core-redis-pubsub.js +112 -0
  31. package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
  32. package/dist/core/common/services/core-redis.service.d.ts +25 -0
  33. package/dist/core/common/services/core-redis.service.js +154 -0
  34. package/dist/core/common/services/core-redis.service.js.map +1 -0
  35. package/dist/core/common/services/core-s3.service.d.ts +51 -0
  36. package/dist/core/common/services/core-s3.service.js +261 -0
  37. package/dist/core/common/services/core-s3.service.js.map +1 -0
  38. package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
  39. package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
  40. package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
  41. package/dist/core/common/services/rate-limit-store.d.ts +61 -0
  42. package/dist/core/common/services/rate-limit-store.js +235 -0
  43. package/dist/core/common/services/rate-limit-store.js.map +1 -0
  44. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
  45. package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
  46. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  47. package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
  48. package/dist/core/modules/ai/services/core-ai.service.js +24 -20
  49. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  50. package/dist/core/modules/auth/core-auth.module.js +4 -1
  51. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  52. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
  53. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
  54. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
  55. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
  56. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
  57. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
  58. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
  59. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
  60. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  61. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
  62. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
  63. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  64. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
  65. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
  66. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  67. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
  68. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  69. package/dist/core/modules/file/core-file.controller.d.ts +11 -2
  70. package/dist/core/modules/file/core-file.controller.js +52 -15
  71. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  72. package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
  73. package/dist/core/modules/file/core-file.resolver.js +23 -16
  74. package/dist/core/modules/file/core-file.resolver.js.map +1 -1
  75. package/dist/core/modules/file/core-file.service.d.ts +41 -7
  76. package/dist/core/modules/file/core-file.service.js +227 -7
  77. package/dist/core/modules/file/core-file.service.js.map +1 -1
  78. package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
  79. package/dist/core/modules/file/file-metadata.helper.js +33 -0
  80. package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
  81. package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
  82. package/dist/core/modules/file/file-roles.helper.js +53 -0
  83. package/dist/core/modules/file/file-roles.helper.js.map +1 -0
  84. package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
  85. package/dist/core/modules/file/file-storage.helper.js +65 -0
  86. package/dist/core/modules/file/file-storage.helper.js.map +1 -0
  87. package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
  88. package/dist/core/modules/file/filesystem-file.helper.js +77 -0
  89. package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
  90. package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
  91. package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
  92. package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
  93. package/dist/core/modules/file/s3-file.helper.js +81 -0
  94. package/dist/core/modules/file/s3-file.helper.js.map +1 -0
  95. package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
  96. package/dist/core/modules/hub/core-hub.controller.js +24 -22
  97. package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
  98. package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
  99. package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
  100. package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
  101. package/dist/core/modules/hub/hub-buffer.js +101 -0
  102. package/dist/core/modules/hub/hub-buffer.js.map +1 -0
  103. package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
  104. package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
  105. package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
  106. package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
  107. package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
  108. package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
  109. package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
  110. package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
  111. package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
  112. package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
  113. package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
  114. package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
  115. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
  116. package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
  117. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  118. package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
  119. package/dist/core/modules/migrate/migration-runner.js +4 -0
  120. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  121. package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
  122. package/dist/core/modules/migrate/mongo-state-store.js +98 -9
  123. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  124. package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
  125. package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
  126. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  127. package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
  128. package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
  129. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  130. package/dist/core/modules/tenant/core-tenant.module.js +3 -4
  131. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  132. package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
  133. package/dist/core/modules/tus/core-tus.controller.js +29 -3
  134. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  135. package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
  136. package/dist/core/modules/tus/core-tus.service.js +182 -28
  137. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  138. package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
  139. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  140. package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
  141. package/dist/core/modules/tus/tus-redis-locker.js +87 -0
  142. package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
  143. package/dist/core/modules/tus/tus.module.d.ts +1 -0
  144. package/dist/core/modules/tus/tus.module.js +30 -4
  145. package/dist/core/modules/tus/tus.module.js.map +1 -1
  146. package/dist/core.module.js +18 -1
  147. package/dist/core.module.js.map +1 -1
  148. package/dist/index.d.ts +10 -0
  149. package/dist/index.js +10 -0
  150. package/dist/index.js.map +1 -1
  151. package/dist/main.js +2 -1
  152. package/dist/main.js.map +1 -1
  153. package/dist/server/modules/file/file.controller.js.map +1 -1
  154. package/dist/server/modules/file/file.resolver.js +1 -12
  155. package/dist/server/modules/file/file.resolver.js.map +1 -1
  156. package/dist/server/modules/file/file.service.d.ts +5 -1
  157. package/dist/server/modules/file/file.service.js +12 -3
  158. package/dist/server/modules/file/file.service.js.map +1 -1
  159. package/dist/server/modules/user/avatar.controller.d.ts +5 -1
  160. package/dist/server/modules/user/avatar.controller.js +26 -10
  161. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  162. package/dist/server/modules/user/user.model.js +3 -2
  163. package/dist/server/modules/user/user.model.js.map +1 -1
  164. package/dist/server/modules/user/user.module.js +6 -2
  165. package/dist/server/modules/user/user.module.js.map +1 -1
  166. package/dist/server/modules/user/user.service.d.ts +1 -1
  167. package/dist/server/modules/user/user.service.js +6 -13
  168. package/dist/server/modules/user/user.service.js.map +1 -1
  169. package/dist/tsconfig.build.tsbuildinfo +1 -1
  170. package/docs/REQUEST-LIFECYCLE.md +57 -8
  171. package/docs/security-overrides.md +21 -3
  172. package/migration-guides/11.32.3-to-11.32.4.md +15 -3
  173. package/migration-guides/11.32.x-to-11.33.x.md +1551 -0
  174. package/package.json +74 -42
  175. package/src/core/common/helpers/content-disposition.helper.ts +90 -0
  176. package/src/core/common/helpers/file.helper.ts +53 -10
  177. package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
  178. package/src/core/common/helpers/project-name.helper.ts +71 -0
  179. package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
  180. package/src/core/common/interfaces/server-options.interface.ts +439 -2
  181. package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
  182. package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
  183. package/src/core/common/services/core-cron-jobs.service.ts +589 -47
  184. package/src/core/common/services/core-redis-pubsub.ts +183 -0
  185. package/src/core/common/services/core-redis.service.ts +249 -0
  186. package/src/core/common/services/core-s3.service.ts +465 -0
  187. package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
  188. package/src/core/common/services/rate-limit-store.ts +492 -0
  189. package/src/core/modules/ai/README.md +25 -0
  190. package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
  191. package/src/core/modules/ai/services/core-ai.service.ts +33 -24
  192. package/src/core/modules/auth/core-auth.module.ts +7 -1
  193. package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
  194. package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
  195. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
  196. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
  197. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
  198. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
  199. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +108 -0
  200. package/src/core/modules/file/README.md +277 -53
  201. package/src/core/modules/file/core-file.controller.ts +168 -12
  202. package/src/core/modules/file/core-file.resolver.ts +46 -12
  203. package/src/core/modules/file/core-file.service.ts +482 -16
  204. package/src/core/modules/file/file-metadata.helper.ts +142 -0
  205. package/src/core/modules/file/file-roles.helper.ts +110 -0
  206. package/src/core/modules/file/file-storage.helper.ts +163 -0
  207. package/src/core/modules/file/filesystem-file.helper.ts +184 -0
  208. package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
  209. package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
  210. package/src/core/modules/file/s3-file.helper.ts +209 -0
  211. package/src/core/modules/hub/README.md +48 -0
  212. package/src/core/modules/hub/core-hub.controller.ts +18 -16
  213. package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
  214. package/src/core/modules/hub/hub-buffer.ts +198 -0
  215. package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
  216. package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
  217. package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
  218. package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
  219. package/src/core/modules/migrate/README.md +33 -6
  220. package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
  221. package/src/core/modules/migrate/migration-runner.ts +10 -1
  222. package/src/core/modules/migrate/mongo-state-store.ts +179 -11
  223. package/src/core/modules/system-setup/README.md +7 -2
  224. package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
  225. package/src/core/modules/tenant/README.md +21 -1
  226. package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
  227. package/src/core/modules/tenant/core-tenant.module.ts +8 -4
  228. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
  229. package/src/core/modules/tus/README.md +35 -2
  230. package/src/core/modules/tus/core-tus.controller.ts +64 -12
  231. package/src/core/modules/tus/core-tus.service.ts +358 -34
  232. package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
  233. package/src/core/modules/tus/tus-redis-locker.ts +139 -0
  234. package/src/core/modules/tus/tus.module.ts +78 -6
  235. package/src/core.module.ts +26 -1
  236. package/src/index.ts +10 -0
  237. package/src/main.ts +7 -1
  238. package/src/server/modules/file/file.controller.ts +7 -3
  239. package/src/server/modules/file/file.resolver.ts +5 -17
  240. package/src/server/modules/file/file.service.ts +42 -3
  241. package/src/server/modules/user/avatar.controller.ts +39 -9
  242. package/src/server/modules/user/user.model.ts +9 -3
  243. package/src/server/modules/user/user.module.ts +12 -3
  244. package/src/server/modules/user/user.service.ts +20 -17
  245. package/src/test/README.md +7 -2
  246. package/src/core/common/helpers/file.helper.spec.ts +0 -145
  247. package/src/core/common/helpers/logging.helper.spec.ts +0 -61
  248. package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
  249. package/src/core/common/services/brevo.service.spec.ts +0 -266
  250. package/src/core/modules/file/core-file.controller.spec.ts +0 -164
  251. package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
  252. package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
  253. package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
  254. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
  255. package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
  256. package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
  257. package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
  258. package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
  259. package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
  260. package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
@@ -7,12 +7,14 @@ import {
7
7
  LogLevel,
8
8
  OnModuleDestroy,
9
9
  OnModuleInit,
10
+ Optional,
10
11
  } from '@nestjs/common';
11
12
  import { inspect } from 'util';
12
13
 
13
14
  import { redactSensitiveText } from '../../../common/helpers/logging.helper';
15
+ import { CoreRedisService } from '../../../common/services/core-redis.service';
14
16
  import { HUB_CONFIG } from '../hub.constants';
15
- import { HubRingBuffer } from '../hub-ring-buffer';
17
+ import { HubBuffer } from '../hub-buffer';
16
18
  import { HubLogRecord, HubLogsData } from '../interfaces/hub-panels.interface';
17
19
  import { ResolvedHubConfig } from '../interfaces/hub-config.interface';
18
20
 
@@ -90,22 +92,32 @@ class HubDelegatingLogger implements LoggerService {
90
92
  */
91
93
  @Injectable()
92
94
  export class HubLogBufferService implements OnModuleDestroy, OnModuleInit {
93
- private buffer?: HubRingBuffer<BufferedLog>;
95
+ private buffer?: HubBuffer<BufferedLog>;
94
96
  private cfg?: Exclude<ResolvedHubConfig['collectors']['logs'], false>;
95
97
  private delegating?: HubDelegatingLogger;
96
98
  private excludeContexts = new Set<string>();
99
+
100
+ /**
101
+ * Set by onModuleDestroy. The Nest Logger override is PROCESS-GLOBAL, and with a shutdown
102
+ * delay the HTTP server keeps serving while the app drains — so a Hub poll arriving in that
103
+ * window could re-install a logger belonging to an app that no longer exists.
104
+ */
105
+ private destroyed = false;
97
106
  private previousRef?: LoggerService;
98
107
 
99
- constructor(@Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig) {}
108
+ constructor(
109
+ @Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig,
110
+ @Optional() protected readonly redis?: CoreRedisService,
111
+ ) {}
100
112
 
101
113
  /** Idempotent install of the delegating logger. Public so reads can self-heal after a foreign override. */
102
114
  attach(): void {
103
- if (this.config.collectors.logs === false) {
115
+ if (this.destroyed || this.config.collectors.logs === false) {
104
116
  return;
105
117
  }
106
118
  if (!this.cfg) {
107
119
  this.cfg = this.config.collectors.logs;
108
- this.buffer = new HubRingBuffer<BufferedLog>(this.cfg.capacity);
120
+ this.buffer = new HubBuffer<BufferedLog>(this.cfg.capacity, 'logs', this.redis);
109
121
  this.excludeContexts = new Set(this.cfg.excludeContexts);
110
122
  }
111
123
  const current = this.staticRef();
@@ -149,13 +161,17 @@ export class HubLogBufferService implements OnModuleDestroy, OnModuleInit {
149
161
  return this.config.collectors.logs !== false;
150
162
  }
151
163
 
152
- getData(since?: number): HubLogsData {
164
+ async getData(since?: number): Promise<HubLogsData> {
153
165
  this.selfHeal();
154
- const records = !this.buffer ? [] : since === undefined ? this.buffer.recent() : this.buffer.since(since);
155
- return { cursor: this.buffer?.lastSeq ?? -1, dropped: this.buffer?.firstRetainedSeq ?? -1, records };
166
+ if (!this.buffer) {
167
+ return { cursor: -1, dropped: -1, records: [] };
168
+ }
169
+ const { cursor, dropped, entries } = await this.buffer.read(since);
170
+ return { cursor, dropped, records: entries };
156
171
  }
157
172
 
158
173
  onModuleDestroy(): void {
174
+ this.destroyed = true;
159
175
  this.detach();
160
176
  }
161
177
 
@@ -3,8 +3,9 @@ import { InjectConnection } from '@nestjs/mongoose';
3
3
  import { Connection } from 'mongoose';
4
4
 
5
5
  import { redactSensitiveText } from '../../../common/helpers/logging.helper';
6
+ import { CoreRedisService } from '../../../common/services/core-redis.service';
6
7
  import { HUB_CONFIG, HUB_QUERY_PENDING_LIMIT } from '../hub.constants';
7
- import { HubRingBuffer } from '../hub-ring-buffer';
8
+ import { HubBuffer } from '../hub-buffer';
8
9
  import { normalizeCommandShape } from '../helpers/hub-command-shape.helper';
9
10
  import { HubQueriesData, HubQueryRecord, HubQueryTemplate } from '../interfaces/hub-panels.interface';
10
11
  import { ResolvedHubConfig } from '../interfaces/hub-config.interface';
@@ -58,7 +59,7 @@ const SHAPED_FIELDS = ['filter', 'query', 'pipeline', 'sort', 'projection', 'q',
58
59
  export class HubQueryProfilerService implements OnModuleDestroy, OnModuleInit {
59
60
  protected readonly logger = new Logger(HubQueryProfilerService.name);
60
61
 
61
- private buffer?: HubRingBuffer<ProfilerRecord>;
62
+ private buffer?: HubBuffer<ProfilerRecord>;
62
63
  private client?: {
63
64
  off?: (e: string, l: (...a: any[]) => void) => void;
64
65
  on: (e: string, l: (...a: any[]) => void) => void;
@@ -78,6 +79,7 @@ export class HubQueryProfilerService implements OnModuleDestroy, OnModuleInit {
78
79
  constructor(
79
80
  @Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig,
80
81
  @Optional() @InjectConnection() protected readonly connection?: Connection,
82
+ @Optional() protected readonly redis?: CoreRedisService,
81
83
  ) {}
82
84
 
83
85
  /** Clear the buffer (Hub action). */
@@ -90,10 +92,11 @@ export class HubQueryProfilerService implements OnModuleDestroy, OnModuleInit {
90
92
  return this.config.collectors.queries !== false;
91
93
  }
92
94
 
93
- getData(): HubQueriesData {
94
- const records = this.buffer?.recent() ?? [];
95
+ async getData(): Promise<HubQueriesData> {
96
+ const data = this.buffer ? await this.buffer.read() : { cursor: -1, entries: [] };
97
+ const records = data.entries;
95
98
  return {
96
- cursor: this.buffer?.lastSeq ?? -1,
99
+ cursor: data.cursor,
97
100
  recent: records.slice(-100),
98
101
  slowest: [...records].sort((a, b) => b.durationMs - a.durationMs).slice(0, 10),
99
102
  summary: this.summary(records),
@@ -118,7 +121,7 @@ export class HubQueryProfilerService implements OnModuleDestroy, OnModuleInit {
118
121
  return; // zero cost when disabled
119
122
  }
120
123
  this.cfg = this.config.collectors.queries;
121
- this.buffer = new HubRingBuffer<ProfilerRecord>(this.cfg.capacity);
124
+ this.buffer = new HubBuffer<ProfilerRecord>(this.cfg.capacity, 'queries', this.redis);
122
125
  if (this.cfg.ignoreCommands?.length) {
123
126
  this.ignore = new Set(this.cfg.ignoreCommands.map((c) => c.toLowerCase()));
124
127
  }
@@ -1,8 +1,9 @@
1
- import { Inject, Injectable } from '@nestjs/common';
1
+ import { Inject, Injectable, Optional } from '@nestjs/common';
2
2
  import { Request, Response } from 'express';
3
3
 
4
+ import { CoreRedisService } from '../../../common/services/core-redis.service';
4
5
  import { HUB_CONFIG } from '../hub.constants';
5
- import { HubRingBuffer } from '../hub-ring-buffer';
6
+ import { HubBuffer } from '../hub-buffer';
6
7
  import { HubTraceRecord, HubTracesData } from '../interfaces/hub-panels.interface';
7
8
  import { ResolvedHubConfig } from '../interfaces/hub-config.interface';
8
9
 
@@ -19,14 +20,17 @@ interface TraceRecord extends HubTraceRecord {
19
20
  */
20
21
  @Injectable()
21
22
  export class HubTraceBufferService {
22
- private readonly buffer?: HubRingBuffer<TraceRecord>;
23
+ private readonly buffer?: HubBuffer<TraceRecord>;
23
24
  private readonly cfg?: Exclude<ResolvedHubConfig['collectors']['traces'], false>;
24
25
  private readonly excludePrefixes: string[];
25
26
 
26
- constructor(@Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig) {
27
+ constructor(
28
+ @Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig,
29
+ @Optional() protected readonly redis?: CoreRedisService,
30
+ ) {
27
31
  if (config.collectors.traces !== false) {
28
32
  this.cfg = config.collectors.traces;
29
- this.buffer = new HubRingBuffer<TraceRecord>(this.cfg.capacity);
33
+ this.buffer = new HubBuffer<TraceRecord>(this.cfg.capacity, 'traces', redis);
30
34
  }
31
35
  // Always exclude the Hub's own routes so polling does not flood the trace list with self-noise.
32
36
  this.excludePrefixes = [...(this.cfg?.excludePaths ?? []), '/' + config.path];
@@ -40,8 +44,9 @@ export class HubTraceBufferService {
40
44
  return this.config.collectors.traces !== false;
41
45
  }
42
46
 
43
- getData(since?: number): HubTracesData {
44
- const records = !this.buffer ? [] : since === undefined ? this.buffer.recent() : this.buffer.since(since);
47
+ async getData(since?: number): Promise<HubTracesData> {
48
+ const data = this.buffer ? await this.buffer.read(since) : { cursor: -1, dropped: -1, entries: [] };
49
+ const records = data.entries;
45
50
  let totalMs = 0;
46
51
  let slowCount = 0;
47
52
  let errorCount = 0;
@@ -55,8 +60,8 @@ export class HubTraceBufferService {
55
60
  }
56
61
  }
57
62
  return {
58
- cursor: this.buffer?.lastSeq ?? -1,
59
- dropped: this.buffer?.firstRetainedSeq ?? -1,
63
+ cursor: data.cursor,
64
+ dropped: data.dropped,
60
65
  summary: { avgMs: records.length ? totalMs / records.length : 0, errorCount, slowCount, total: records.length },
61
66
  traces: records,
62
67
  };
@@ -230,7 +230,7 @@ The migration state is stored in a MongoDB collection (default: `migrations`) as
230
230
 
231
231
  ### Locking Mechanism
232
232
 
233
- When using `synchronizedMigration` or `synchronizedUp`:
233
+ How the lock works, regardless of which entry point uses it:
234
234
 
235
235
  1. A unique index is created on the lock collection
236
236
  2. The migration process attempts to insert a lock document
@@ -240,6 +240,27 @@ When using `synchronizedMigration` or `synchronizedUp`:
240
240
 
241
241
  This ensures that in a cluster with multiple nodes, migrations run on only one machine at a time.
242
242
 
243
+ **Active by default for `migrate up`.** Stores built by `createMigrationStore()` use the
244
+ lock collection `migrations_lock` unless another name is given, and `MigrationRunner.up()`
245
+ (the CLI's `up` command) acquires that lock around the whole run. This matters because the
246
+ container entrypoint runs migrations on **every** boot: without the lock, N replicas
247
+ starting together each read the same empty state and apply the same pending migration N
248
+ times. A replica that waited re-reads the state inside the lock and finds nothing pending.
249
+
250
+ With a single replica nothing changes — the lock is acquired and released uncontended.
251
+
252
+ Opt out by passing an empty lock collection name:
253
+
254
+ ```javascript
255
+ module.exports = createMigrationStore(uri, 'migrations', ''); // no locking
256
+ ```
257
+
258
+ A `MongoStateStore` constructed **directly** still locks only when you pass
259
+ `lockCollectionName` yourself; the default lives in `createMigrationStore()`.
260
+
261
+ The legacy `synchronizedMigration` / `synchronizedUp` helpers use the same lock and still
262
+ require `lockCollectionName` to be set explicitly (they throw otherwise).
263
+
243
264
  ## Examples
244
265
 
245
266
  ### Example: Migration File
@@ -356,11 +377,17 @@ Factory function to create a migration store class for use with the migrate CLI:
356
377
  const { createMigrationStore } = require('@lenne.tech/nest-server');
357
378
  const config = require('../src/config.env');
358
379
 
359
- module.exports = createMigrationStore(
360
- config.default.mongoose.uri,
361
- 'migrations', // optional collection name
362
- 'migration_lock', // optional lock collection for clusters
363
- );
380
+ module.exports = createMigrationStore(config.default.mongoose.uri);
381
+ ```
382
+
383
+ **Locking is on by default.** The third parameter defaults to `'migrations_lock'`, so
384
+ `migrate up` is serialized across replicas without any project change (see
385
+ [Locking Mechanism](#locking-mechanism)):
386
+
387
+ ```javascript
388
+ createMigrationStore(uri); // collection 'migrations', lock 'migrations_lock'
389
+ createMigrationStore(uri, 'migrations', 'custom_lock'); // custom lock collection
390
+ createMigrationStore(uri, 'migrations', ''); // opt out: no locking
364
391
  ```
365
392
 
366
393
  ### getDb()
@@ -2,6 +2,8 @@ import * as fs from 'fs';
2
2
  import { Db, GridFSBucket, MongoClient, ObjectId } from 'mongodb';
3
3
  import * as path from 'path';
4
4
 
5
+ import { MongoStateStore } from '../mongo-state-store';
6
+
5
7
  /**
6
8
  * Migration helper functions for database operations
7
9
  */
@@ -247,9 +249,13 @@ export const uploadFileToGridFS = async (
247
249
  /**
248
250
  * Create a migration state store factory
249
251
  *
252
+ * The lock collection is set by DEFAULT, so `migrate up` from N replicas booting at
253
+ * the same time serializes instead of applying the same migration twice (the container
254
+ * entrypoint runs migrations on every boot). Pass an empty string to opt out.
255
+ *
250
256
  * @param mongoUrl - MongoDB connection URI
251
257
  * @param collectionName - Optional collection name (default: 'migrations')
252
- * @param lockCollectionName - Optional lock collection name for cluster environments
258
+ * @param lockCollectionName - Lock collection name (default: 'migrations_lock'); `''` disables locking
253
259
  * @returns MongoStateStore class that can be used with migrate CLI
254
260
  *
255
261
  * @example
@@ -264,15 +270,16 @@ export const uploadFileToGridFS = async (
264
270
  export const createMigrationStore = (
265
271
  mongoUrl: string,
266
272
  collectionName: string = 'migrations',
267
- lockCollectionName?: string,
268
- ) => {
269
- const { MongoStateStore } = require('../mongo-state-store');
270
-
273
+ lockCollectionName: string = 'migrations_lock',
274
+ // Explicit return type: the returned class is anonymous to the declaration emitter, and
275
+ // MongoStateStore has private members, so an inferred type cannot be written to the .d.ts
276
+ // (TS4094).
277
+ ): new () => MongoStateStore => {
271
278
  return class MigrationStateStore extends MongoStateStore {
272
279
  constructor() {
273
280
  super({
274
281
  collectionName,
275
- lockCollectionName,
282
+ lockCollectionName: lockCollectionName || undefined,
276
283
  uri: mongoUrl,
277
284
  });
278
285
  }
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
 
4
- import { MongoStateStore } from './mongo-state-store';
4
+ import { MongoStateStore, withMigrationLock } from './mongo-state-store';
5
5
 
6
6
  /**
7
7
  * Migration file interface
@@ -221,8 +221,17 @@ export class MigrationRunner {
221
221
 
222
222
  /**
223
223
  * Run all pending migrations (up)
224
+ *
225
+ * Serialized via the state store's lock collection when it has one (the default for
226
+ * stores built by `createMigrationStore()`). Reading the state INSIDE the lock is what
227
+ * makes it safe: a replica that waited sees the migrations the holder just applied and
228
+ * finds nothing pending, instead of applying them a second time.
224
229
  */
225
230
  async up(): Promise<void> {
231
+ await withMigrationLock(this.options.stateStore, () => this.runUp());
232
+ }
233
+
234
+ protected async runUp(): Promise<void> {
226
235
  const { _endMigration, _startMigration } = await import('./helpers/migration.helper');
227
236
 
228
237
  const allMigrations = await this.loadMigrationFiles();
@@ -1,4 +1,6 @@
1
+ import { randomUUID } from 'crypto';
1
2
  import { Db, MongoClient } from 'mongodb';
3
+ import { hostname } from 'os';
2
4
  import { promisify } from 'util';
3
5
 
4
6
  /**
@@ -181,14 +183,20 @@ export async function synchronizedMigration(
181
183
  throw new Error('`lockCollectionName` in MongoStateStore is not set');
182
184
  }
183
185
 
186
+ let heartbeat: undefined | { stop: () => void };
187
+ let lockToken: string | undefined;
184
188
  try {
185
- await acquireLock(stateStore.mongodbHost, lockCollectionName);
189
+ lockToken = await acquireLock(stateStore.mongodbHost, lockCollectionName);
190
+ heartbeat = startLockHeartbeat(stateStore.mongodbHost, lockCollectionName, lockToken);
186
191
 
187
192
  // Load migration set using async method
188
193
  const set = await stateStore.loadAsync();
189
194
  await callback(set);
190
195
  } finally {
191
- await releaseLock(stateStore.mongodbHost, lockCollectionName);
196
+ heartbeat?.stop();
197
+ if (lockToken) {
198
+ await releaseLock(stateStore.mongodbHost, lockCollectionName, lockToken);
199
+ }
192
200
  }
193
201
  }
194
202
 
@@ -217,14 +225,119 @@ export async function synchronizedUp(opts: MigrationOptions): Promise<void> {
217
225
  });
218
226
  }
219
227
 
228
+ /**
229
+ * Runs `fn` while holding the migration lock of the given state store
230
+ *
231
+ * When the store has no `lockCollectionName`, `fn` runs unsynchronized — a single
232
+ * replica keeps behaving exactly as before. Stores built by `createMigrationStore()`
233
+ * carry the default lock collection, so concurrent `migrate up` runs from several
234
+ * replicas serialize here: the second replica waits, then re-reads the migration
235
+ * state and finds nothing pending.
236
+ *
237
+ * @param stateStore - State store holding the connection URI and lock collection name
238
+ * @param fn - Work to execute under the lock
239
+ * @returns Promise with the result of `fn`
240
+ */
241
+ export async function withMigrationLock<T>(stateStore: MongoStateStore, fn: () => Promise<T>): Promise<T> {
242
+ const lockCollectionName = stateStore.lockCollectionName;
243
+
244
+ if (!lockCollectionName) {
245
+ return fn();
246
+ }
247
+
248
+ const token = await acquireLock(stateStore.mongodbHost, lockCollectionName);
249
+ const heartbeat = startLockHeartbeat(stateStore.mongodbHost, lockCollectionName, token);
250
+ try {
251
+ return await fn();
252
+ } finally {
253
+ heartbeat.stop();
254
+ await releaseLock(stateStore.mongodbHost, lockCollectionName, token);
255
+ }
256
+ }
257
+
258
+ /**
259
+ * How long a lock may go without a heartbeat before another replica may break it.
260
+ *
261
+ * Only a lock whose holder stopped refreshing it is ever broken, so this is not a cap
262
+ * on migration runtime — a migration running for hours keeps its lock as long as the
263
+ * process lives.
264
+ */
265
+ const LOCK_STALE_AFTER_MS = 60_000;
266
+
267
+ /** Heartbeat interval — comfortably below {@link LOCK_STALE_AFTER_MS} */
268
+ const LOCK_HEARTBEAT_INTERVAL_MS = 15_000;
269
+
270
+ /** How long a replica waits for a held lock before giving up with a diagnosable error */
271
+ const LOCK_WAIT_TIMEOUT_MS = 15 * 60_000;
272
+
273
+ /**
274
+ * Keep refreshing `acquiredAt` while the migration runs.
275
+ *
276
+ * This is what makes lock-breaking safe: a lock is only ever taken over when its holder
277
+ * stopped refreshing it, which for a live process cannot happen. Without the heartbeat,
278
+ * breaking a stale lock and a long-running migration would be indistinguishable.
279
+ */
280
+ function startLockHeartbeat(url: string, lockCollectionName: string, token: string): { stop: () => void } {
281
+ // ONE connection for the whole heartbeat, rather than `dbRequest()`'s connect-and-close per
282
+ // call: that opened and tore down a MongoClient every 15 seconds for the entire migration run —
283
+ // a full handshake each time, against the database at its busiest moment. The client is created
284
+ // on the first tick (so a heartbeat that never fires costs nothing) and closed by stop().
285
+ let client: MongoClient | undefined;
286
+ let stopped = false;
287
+
288
+ const beat = async (): Promise<void> => {
289
+ if (stopped) {
290
+ return;
291
+ }
292
+ if (!client) {
293
+ const connected = await MongoClient.connect(url);
294
+ if (stopped) {
295
+ // stop() ran while we were connecting — nothing would ever close this one.
296
+ await connected.close();
297
+ return;
298
+ }
299
+ client = connected;
300
+ }
301
+ // Scoped: once our lock has been broken and re-taken, our heartbeat must not keep the NEW
302
+ // holder's lock alive on our behalf.
303
+ await client
304
+ .db()
305
+ .collection(lockCollectionName)
306
+ .updateOne({ lock: 'lock', owner: token }, { $set: { acquiredAt: new Date() } });
307
+ };
308
+
309
+ const timer = setInterval(() => {
310
+ beat().catch((error) => {
311
+ // A missed heartbeat is not fatal on its own — the next one may succeed, and only
312
+ // a sustained gap makes the lock breakable. Drop the connection though: it may itself be
313
+ // what failed, and a held-open broken client would fail every remaining tick.
314
+ console.warn(`Migration lock heartbeat failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
315
+ const broken = client;
316
+ client = undefined;
317
+ void broken?.close().catch(() => undefined);
318
+ });
319
+ }, LOCK_HEARTBEAT_INTERVAL_MS);
320
+ timer.unref?.();
321
+
322
+ return {
323
+ stop: () => {
324
+ stopped = true;
325
+ clearInterval(timer);
326
+ const open = client;
327
+ client = undefined;
328
+ void open?.close().catch(() => undefined);
329
+ },
330
+ };
331
+ }
332
+
220
333
  /**
221
334
  * Acquires a lock in MongoDB to ensure only one migration runs at a time
222
335
  *
223
336
  * @param url - MongoDB connection URI
224
337
  * @param lockCollectionName - Name of the collection to use for locking
225
338
  */
226
- async function acquireLock(url: string, lockCollectionName: string): Promise<void> {
227
- await dbRequest(url, async (db) => {
339
+ async function acquireLock(url: string, lockCollectionName: string): Promise<string> {
340
+ return dbRequest(url, async (db) => {
228
341
  const collection = db.collection(lockCollectionName);
229
342
 
230
343
  // Create unique index for atomicity
@@ -233,15 +346,58 @@ async function acquireLock(url: string, lockCollectionName: string): Promise<voi
233
346
  // https://stackoverflow.com/questions/33346175/mongodb-upsert-operation-seems-not-atomic-which-throws-duplicatekeyexception/34784533
234
347
  await collection.createIndex({ lock: 1 }, { unique: true });
235
348
 
349
+ const token = lockOwnerId();
350
+ const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS;
236
351
  let showMessage = true;
237
352
 
238
353
  for (;;) {
239
- // Use updateOne with upsert for atomic lock acquisition (same as original package)
240
- const result = await collection.updateOne({ lock: 'lock' }, { $set: { lock: 'lock' } }, { upsert: true });
241
- const lockAcquired = result.upsertedCount > 0;
354
+ // Use updateOne with upsert for atomic lock acquisition (same as original package).
355
+ // `$setOnInsert` stamps the acquisition time only for the winner, so a loser's
356
+ // update never refreshes the holder's staleness clock.
357
+ const result = await collection.updateOne(
358
+ { lock: 'lock' },
359
+ { $set: { lock: 'lock' }, $setOnInsert: { acquiredAt: new Date(), owner: token } },
360
+ { upsert: true },
361
+ );
362
+
363
+ if (result.upsertedCount > 0) {
364
+ return token;
365
+ }
366
+
367
+ // The holder may be gone: migrations run on every container boot, so a replica
368
+ // SIGKILLed mid-migration (OOM, node drain, failed deploy) would otherwise leave a
369
+ // lock nobody holds — and every future boot of every replica would wait on it
370
+ // forever. Only a lock that stopped heart-beating is broken; see LOCK_STALE_AFTER_MS.
371
+ const holder = await collection.findOne({ lock: 'lock' });
372
+ if (holder) {
373
+ const acquiredAt = holder.acquiredAt instanceof Date ? holder.acquiredAt : undefined;
374
+ if (!acquiredAt) {
375
+ // Written by a version that did not stamp the lock. Start its clock now rather
376
+ // than breaking it immediately — the holder may well be alive.
377
+ await collection.updateOne(
378
+ { _id: holder._id, acquiredAt: { $exists: false } },
379
+ { $set: { acquiredAt: new Date() } },
380
+ );
381
+ } else if (Date.now() - acquiredAt.getTime() > LOCK_STALE_AFTER_MS) {
382
+ console.warn(
383
+ `Breaking stale migration lock in "${lockCollectionName}" (last heartbeat ${acquiredAt.toISOString()}, ` +
384
+ `owner ${holder.owner ?? 'unknown'}) — its holder is gone.`,
385
+ );
386
+ // Matching on acquiredAt makes the break safe under concurrency: if another
387
+ // waiter already broke and re-acquired the lock, the timestamp differs and this
388
+ // deletes nothing.
389
+ await collection.deleteOne({ _id: holder._id, acquiredAt });
390
+ continue;
391
+ }
392
+ }
242
393
 
243
- if (lockAcquired) {
244
- break;
394
+ if (Date.now() > deadline) {
395
+ throw new Error(
396
+ `Timed out after ${Math.round(LOCK_WAIT_TIMEOUT_MS / 60_000)} minutes waiting for the migration lock in ` +
397
+ `collection "${lockCollectionName}". Another replica is still migrating, or the lock is held by a process ` +
398
+ `that is alive but stuck. Inspect it with: db.getCollection("${lockCollectionName}").find({}) — and remove ` +
399
+ `the document only once you are sure no migration is running.`,
400
+ );
245
401
  }
246
402
 
247
403
  if (showMessage) {
@@ -254,6 +410,15 @@ async function acquireLock(url: string, lockCollectionName: string): Promise<voi
254
410
  });
255
411
  }
256
412
 
413
+ /**
414
+ * Identifies the process holding the lock, so a stale-lock warning names something actionable
415
+ */
416
+ function lockOwnerId(): string {
417
+ // Random suffix, not just host+pid: a restarted process can reuse a pid, and the token is what
418
+ // decides whose lock a release may delete.
419
+ return `${hostname()}:${process.pid}:${randomUUID()}`;
420
+ }
421
+
257
422
  /**
258
423
  * Executes database operations with automatic connection management
259
424
  *
@@ -278,6 +443,9 @@ async function dbRequest<T>(url: string, callback: (db: Db) => Promise<T> | T):
278
443
  * @param url - MongoDB connection URI
279
444
  * @param lockCollectionName - Name of the collection used for locking
280
445
  */
281
- async function releaseLock(url: string, lockCollectionName: string): Promise<void> {
282
- await dbRequest(url, (db) => db.collection(lockCollectionName).deleteOne({ lock: 'lock' }));
446
+ async function releaseLock(url: string, lockCollectionName: string, token: string): Promise<void> {
447
+ // Scoped to OUR token. An unscoped delete removes whatever lock exists — so after a missed
448
+ // heartbeat let another replica break and re-take the lock, the original holder's `finally`
449
+ // would delete the NEW holder's lock and let a third replica start a concurrent migration.
450
+ await dbRequest(url, (db) => db.collection(lockCollectionName).deleteOne({ lock: 'lock', owner: token }));
283
451
  }
@@ -164,7 +164,11 @@ NEST_SERVER_CONFIG='{ "systemSetup": { "initialAdmin": { "email": "admin@example
164
164
  - The admin is created automatically during application bootstrap (`OnApplicationBootstrap`)
165
165
  - Same zero-user guard applies: only works when no users exist
166
166
  - If users already exist, auto-creation is silently skipped (no error)
167
- - Race conditions between multiple instances are handled gracefully
167
+ - Race conditions between multiple instances are handled gracefully: the zero-user check alone is
168
+ check-then-act, so N replicas booting at the same time would each pass it. Before creating, an
169
+ instance claims the setup by upserting the marker `{ _id: 'initial-admin' }` into the
170
+ `system-setup-locks` collection — an atomic operation only one instance wins. The others log a
171
+ debug message and skip. If creation fails, the marker is removed again so a later boot can retry.
168
172
 
169
173
  ### Security Best Practices
170
174
 
@@ -179,7 +183,8 @@ NEST_SERVER_CONFIG='{ "systemSetup": { "initialAdmin": { "email": "admin@example
179
183
 
180
184
  1. **Zero-user guard** - Init only works when `countDocuments({}) === 0`
181
185
  2. **Enabled by default** - Safe because endpoints are permanently locked once any user exists
182
- 3. **Race condition protection** - MongoDB unique email index prevents duplicates
186
+ 3. **Race condition protection** - MongoDB unique email index prevents duplicates; auto-creation on
187
+ bootstrap is additionally claimed atomically via the `system-setup-locks` marker
183
188
  4. **Permanent lock** - Once any user exists, init returns 403
184
189
  5. **BetterAuth required** - Returns 403 if BetterAuth is not enabled
185
190