@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
@@ -4,7 +4,7 @@ import { getMigrations } from 'better-auth/db/migration';
4
4
  import { DataSourceConfigError } from '../../common/persistence/data-source.config.js';
5
5
  import { generateId } from '../../common/persistence/id-format.js';
6
6
  import { type IamSqlStorage, registerForeignColumns } from '../../common/persistence/persistence-runtime.registry.js';
7
- import { columnName, quoteIdentifier } from '../../common/persistence/sql/sql-naming.js';
7
+ import { columnName, quoteIdentifier, quoteMysqlIdentifier } from '../../common/persistence/sql/sql-naming.js';
8
8
 
9
9
  /** The table Better-Auth shares with the user model — created and owned by the framework */
10
10
  const USERS_TABLE = 'users';
@@ -15,15 +15,38 @@ const USERS_TABLE = 'users';
15
15
  */
16
16
  registerForeignColumns(USERS_TABLE, ['email_verified', 'image', 'name']);
17
17
 
18
- /** Postgres column type of a Better-Auth field, for a column the framework adds before Better-Auth's migration */
19
- const SQL_TYPES: Record<string, string> = {
20
- boolean: 'boolean',
21
- date: 'timestamptz',
22
- json: 'jsonb',
23
- number: 'integer',
24
- 'number[]': 'jsonb',
25
- string: 'text',
26
- 'string[]': 'jsonb',
18
+ /**
19
+ * Column type of a Better-Auth field per database, for a column the framework adds before Better-Auth's migration —
20
+ * the type Better-Auth's own migrator would choose, so it finds nothing to report about the column afterwards
21
+ */
22
+ const SQL_TYPES: Record<IamSqlStorage['driver'], Record<string, string>> = {
23
+ mariadb: {
24
+ boolean: 'boolean',
25
+ date: 'timestamp(3)',
26
+ json: 'json',
27
+ number: 'integer',
28
+ 'number[]': 'json',
29
+ string: 'text',
30
+ 'string[]': 'json',
31
+ },
32
+ mysql: {
33
+ boolean: 'boolean',
34
+ date: 'timestamp(3)',
35
+ json: 'json',
36
+ number: 'integer',
37
+ 'number[]': 'json',
38
+ string: 'text',
39
+ 'string[]': 'json',
40
+ },
41
+ postgres: {
42
+ boolean: 'boolean',
43
+ date: 'timestamptz',
44
+ json: 'jsonb',
45
+ number: 'integer',
46
+ 'number[]': 'jsonb',
47
+ string: 'text',
48
+ 'string[]': 'jsonb',
49
+ },
27
50
  };
28
51
 
29
52
  /** The tables Better-Auth's own options name (models of plugins come from the plugins) */
@@ -32,8 +55,9 @@ const CORE_MODELS = ['user', 'session', 'account', 'verification'] as const;
32
55
  type Options = Record<string, any>;
33
56
 
34
57
  /**
35
- * Better-Auth on a SQL data source: its built-in Kysely adapter on the given `pg` pool (Better-Auth recognises the
36
- * pool and builds its own Kysely instance — the core imports no Kysely), ids in the framework's format, and every table
58
+ * Better-Auth on a SQL data source: its built-in Kysely adapter on the given pool — `pg`, or on MySQL / MariaDB the
59
+ * `mysql2` pool's promise wrapper (Better-Auth recognises the pool and builds its own Kysely instance — the core imports
60
+ * no Kysely), ids in the framework's format, and every table
37
61
  * and column named the way the framework names it.
38
62
  *
39
63
  * Naming is not cosmetic. Better-Auth and the user model share ONE row per user in `users`: the framework creates that
@@ -114,8 +138,10 @@ export function betterAuthOwnedTables(options: Options): string[] {
114
138
  * Without an owner connection it only checks, and a pending change stops the boot like a framework table would.
115
139
  */
116
140
  export async function applyBetterAuthSchema(options: Options, storage: IamSqlStorage): Promise<void> {
117
- await storage.applySchema(async (pool, asOwner) => {
118
- const nullable = asOwner ? await addRequiredUserColumnsAsNullable(options, pool as PgPool) : new Set<string>();
141
+ await storage.applySchema(async (pool, asOwner, query) => {
142
+ const nullable = asOwner
143
+ ? await addRequiredUserColumnsAsNullable(options, query, storage.driver)
144
+ : new Set<string>();
119
145
  const { runMigrations, toBeAdded, toBeCreated } = await getMigrations({
120
146
  ...options,
121
147
  database: pool,
@@ -135,10 +161,6 @@ export async function applyBetterAuthSchema(options: Options, storage: IamSqlSto
135
161
  }, betterAuthOwnedTables(options));
136
162
  }
137
163
 
138
- interface PgPool {
139
- query(text: string, params?: unknown[]): Promise<{ rows: any[] }>;
140
- }
141
-
142
164
  /**
143
165
  * A field Better-Auth declares required without a default (`name`) becomes a NOT NULL column when Better-Auth adds it —
144
166
  * and every user the FRAMEWORK creates (legacy sign-up, user service, system setup) would then fail to insert, because the
@@ -146,10 +168,19 @@ interface PgPool {
146
168
  * first, nullable like every column it creates (the database does not enforce `required`, as on MongoDB); Better-Auth
147
169
  * then finds them and adds nothing. Returns every required user column that is nullable by the framework's design.
148
170
  */
149
- async function addRequiredUserColumnsAsNullable(options: Options, pool: PgPool): Promise<Set<string>> {
171
+ export async function addRequiredUserColumnsAsNullable(
172
+ options: Options,
173
+ query: IamSqlStorage['query'],
174
+ driver: IamSqlStorage['driver'],
175
+ ): Promise<Set<string>> {
150
176
  const users = (getAuthTables(options as any) as Record<string, any>).user;
151
- const { rows } = await pool.query(
152
- 'SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1',
177
+ const postgres = driver === 'postgres';
178
+ const quote = postgres ? quoteIdentifier : quoteMysqlIdentifier;
179
+ const types = SQL_TYPES[driver];
180
+ const { rows } = await query(
181
+ postgres
182
+ ? 'SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1'
183
+ : 'SELECT COLUMN_NAME AS column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = $1',
153
184
  [users.modelName],
154
185
  );
155
186
  const existing = new Set(rows.map((row) => row.column_name as string));
@@ -161,9 +192,10 @@ async function addRequiredUserColumnsAsNullable(options: Options, pool: PgPool):
161
192
  }
162
193
  // Every column the framework created for a field is nullable — the user model's `email` and timestamps included
163
194
  nullable.add(column);
164
- if (field.defaultValue === undefined && !existing.has(column) && SQL_TYPES[field.type]) {
165
- await pool.query(
166
- `ALTER TABLE ${quoteIdentifier(users.modelName)} ADD COLUMN IF NOT EXISTS ${quoteIdentifier(column)} ${SQL_TYPES[field.type]}`,
195
+ if (field.defaultValue === undefined && !existing.has(column) && types[field.type]) {
196
+ // MySQL has no ADD COLUMN IF NOT EXISTS; the catalog read above and the schema lock make it unnecessary
197
+ await query(
198
+ `ALTER TABLE ${quote(users.modelName)} ADD COLUMN ${postgres ? 'IF NOT EXISTS ' : ''}${quote(column)} ${types[field.type]}`,
167
199
  );
168
200
  }
169
201
  }
@@ -233,9 +233,10 @@ export interface CreateBetterAuthOptions {
233
233
  serverEnv?: string;
234
234
 
235
235
  /**
236
- * The `pg` pool of the SQL data source the `users` table lives in (the application role's pool of the configured
237
- * engine). Set, Better-Auth runs its built-in Kysely adapter on it instead of MongoDB, with the framework's ids and
238
- * table and column names — see `withSqlStorage()`.
236
+ * The pool of the SQL data source the `users` table lives in, as `SqlDialect.foreignPool()` hands it out: the
237
+ * application role's `pg` pool, or on MySQL / MariaDB its `mysql2` pool's promise wrapper. Set, Better-Auth runs its
238
+ * built-in Kysely adapter on it instead of MongoDB, with the framework's ids and table and column names — see
239
+ * `withSqlStorage()`.
239
240
  */
240
241
  sqlPool?: unknown;
241
242
  }
@@ -1,5 +1,6 @@
1
1
  import type { DataStore } from '../../common/persistence/data-store.interface.js';
2
- import { quoteIdentifier } from '../../common/persistence/sql/sql-naming.js';
2
+ import type { IamSqlStorage } from '../../common/persistence/persistence-runtime.registry.js';
3
+ import { quoteIdentifier, quoteMysqlIdentifier } from '../../common/persistence/sql/sql-naming.js';
3
4
  import { RequestContext } from '../../common/services/request-context.service.js';
4
5
  import type { BetterAuthInstance } from './better-auth.config.js';
5
6
 
@@ -28,10 +29,8 @@ export interface IamMigrationCounts {
28
29
  withIamId: number;
29
30
  }
30
31
 
31
- /** What the store needs of the `pg` pool Better-Auth runs on */
32
- export interface IamSqlQueryable {
33
- query(text: string, values?: unknown[]): Promise<{ rows: any[] }>;
34
- }
32
+ /** What the store needs to count in the database Better-Auth runs on: which one it is, and a statement runner */
33
+ export type IamSqlQueryable = Pick<IamSqlStorage, 'driver' | 'query'>;
35
34
 
36
35
  /** A passkey challenge mapping as `CoreBetterAuthChallengeService` keeps it */
37
36
  export interface IamChallengeMapping {
@@ -60,7 +59,7 @@ export class BetterAuthSqlStore {
60
59
  constructor(
61
60
  readonly users: DataStore<any>,
62
61
  protected readonly auth: BetterAuthInstance,
63
- protected readonly pool?: IamSqlQueryable,
62
+ protected readonly sql?: IamSqlQueryable,
64
63
  ) {}
65
64
 
66
65
  // ---------------------------------------------------------------------------------------------
@@ -144,39 +143,47 @@ export class BetterAuthSqlStore {
144
143
  * The IAM migration counts, computed in the database over every user and account — the definitions of the MongoDB
145
144
  * aggregation (`CoreBetterAuthUserMapper.getMigrationStatus()`). Never a page of rows: Better-Auth's `findMany()`
146
145
  * stops at 100 without a limit, which capped the status at 100 migrated users. Table and column names come from
147
- * Better-Auth's own schema, so they follow what `withSqlStorage()` mapped.
146
+ * Better-Auth's own schema, so they follow what `withSqlStorage()` mapped. The same statements on every SQL database,
147
+ * only the identifier quotes differ; counts are converted here because MySQL answers them as strings.
148
148
  */
149
149
  async migrationCounts(pendingEmailLimit: number): Promise<IamMigrationCounts> {
150
- if (!this.pool) {
151
- throw new Error('BetterAuthSqlStore: migration counts need the pool Better-Auth runs on');
150
+ if (!this.sql) {
151
+ throw new Error('BetterAuthSqlStore: migration counts need the database Better-Auth runs on');
152
152
  }
153
+ const quote = this.sql.driver === 'postgres' ? quoteIdentifier : quoteMysqlIdentifier;
153
154
  const { tables } = await this.context();
154
- const table = (model: string) => quoteIdentifier(tables[model].modelName);
155
- const column = (model: string, field: string) => quoteIdentifier(tables[model].fields[field]?.fieldName ?? field);
155
+ const table = (model: string) => quote(tables[model].modelName);
156
+ const column = (model: string, field: string) => quote(tables[model].fields[field]?.fieldName ?? field);
156
157
  const users = table('user');
157
158
  const accounts = table('account');
158
159
  const iamId = column('user', 'iamId');
159
160
  const userId = column('account', 'userId');
160
161
  const providerId = column('account', 'providerId');
161
- const migrated = `EXISTS (SELECT 1 FROM ${accounts} a WHERE a.${userId} = u."id" AND a.${providerId} = $1)`;
162
+ const migrated = `EXISTS (SELECT 1 FROM ${accounts} a WHERE a.${userId} = u.${quote('id')} AND a.${providerId} = $1)`;
162
163
  const {
163
164
  rows: [counts],
164
- } = await this.pool.query(
165
- `SELECT (SELECT count(*) FROM ${users})::int AS "total",
166
- (SELECT count(*) FROM ${users} WHERE ${iamId} IS NOT NULL)::int AS "withIamId",
167
- (SELECT count(DISTINCT ${userId}) FROM ${accounts} WHERE ${providerId} = $1)::int AS "withIamAccount",
168
- (SELECT count(*) FROM ${users} u WHERE u.${iamId} IS NOT NULL AND ${migrated})::int AS "fullyMigrated"`,
165
+ } = await this.sql.query(
166
+ `SELECT (SELECT count(*) FROM ${users}) AS ${quote('total')},
167
+ (SELECT count(*) FROM ${users} WHERE ${iamId} IS NOT NULL) AS ${quote('withIamId')},
168
+ (SELECT count(DISTINCT ${userId}) FROM ${accounts} WHERE ${providerId} = $1) AS ${quote('withIamAccount')},
169
+ (SELECT count(*) FROM ${users} u WHERE u.${iamId} IS NOT NULL AND ${migrated}) AS ${quote('fullyMigrated')}`,
169
170
  ['credential'],
170
171
  );
171
172
  const pending =
172
173
  pendingEmailLimit > 0
173
- ? await this.pool.query(
174
- `SELECT u.${column('user', 'email')} AS "email" FROM ${users} u
175
- WHERE u.${iamId} IS NULL OR NOT ${migrated} ORDER BY u."id" LIMIT $2`,
174
+ ? await this.sql.query(
175
+ `SELECT u.${column('user', 'email')} AS ${quote('email')} FROM ${users} u
176
+ WHERE u.${iamId} IS NULL OR NOT ${migrated} ORDER BY u.${quote('id')} LIMIT $2`,
176
177
  ['credential', pendingEmailLimit],
177
178
  )
178
179
  : { rows: [] };
179
- return { ...counts, pendingEmails: pending.rows.map((row) => row.email).filter(Boolean) };
180
+ return {
181
+ fullyMigrated: Number(counts.fullyMigrated),
182
+ pendingEmails: pending.rows.map((row) => row.email).filter(Boolean),
183
+ total: Number(counts.total),
184
+ withIamAccount: Number(counts.withIamAccount),
185
+ withIamId: Number(counts.withIamId),
186
+ };
180
187
  }
181
188
 
182
189
  /** A session by its token with its user; `null` when unknown — expiry is the caller's check, as on MongoDB */
@@ -1,4 +1,4 @@
1
- import { Injectable, Logger, Optional } from '@nestjs/common';
1
+ import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy, Optional } from '@nestjs/common';
2
2
  import { InjectConnection } from '../../common/persistence/mongo/mongoose-compat.js';
3
3
  import * as bcrypt from 'bcrypt';
4
4
  import { randomBytes, scrypt, ScryptOptions } from 'crypto';
@@ -17,7 +17,9 @@ const scryptPromise = (password: string, salt: string, keylen: number, options:
17
17
  };
18
18
 
19
19
  import { RoleEnum } from '../../common/enums/role.enum.js';
20
+ import { revalidateWsConnectionsOf } from '../../common/helpers/graphql-ws-connection.helper.js';
20
21
  import { maskEmail } from '../../common/helpers/logging.helper.js';
22
+ import { CoreRedisService } from '../../common/services/core-redis.service.js';
21
23
  import { legacyCredentialAccountIssuer } from './core-better-auth-account-issuer.helper.js';
22
24
  import { getBetterAuthSqlStore } from './core-better-auth-sql.registry.js';
23
25
  import type { BetterAuthSqlStore } from './core-better-auth-sql.store.js';
@@ -139,13 +141,15 @@ function isClosedConnectionError(error: unknown): boolean {
139
141
  }
140
142
 
141
143
  @Injectable()
142
- export class CoreBetterAuthUserMapper {
144
+ export class CoreBetterAuthUserMapper implements OnApplicationBootstrap, OnModuleDestroy {
143
145
  private readonly logger = new Logger(CoreBetterAuthUserMapper.name);
144
146
 
145
147
  /**
146
148
  * Lightweight TTL cache for user DB lookups.
147
149
  * Key: iamId (BetterAuth user ID), Value: minimal user data (id, roles, verified).
148
150
  * Only caches ~100 bytes per entry (no full documents). Max 500 entries = ~50KB.
151
+ *
152
+ * With `redis` configured, {@link invalidateUserCache} reaches the cache of every replica; without it, only this one.
149
153
  */
150
154
  private readonly userCache = new Map<string, CachedUserData>();
151
155
  private static readonly USER_CACHE_MAX = 500;
@@ -167,7 +171,64 @@ export class CoreBetterAuthUserMapper {
167
171
  ? 0
168
172
  : 15_000;
169
173
 
170
- constructor(@Optional() @InjectConnection() private readonly connection?: Connection) {}
174
+ /**
175
+ * How long a user's roles and verification state are answered from the cache, in ms. `0` switches the cache off.
176
+ * Defaults to {@link USER_CACHE_TTL_MS}; a subclass may set another value.
177
+ */
178
+ protected userCacheTtlMs = CoreBetterAuthUserMapper.USER_CACHE_TTL_MS;
179
+
180
+ /** The listener on the shared subscriber connection, kept to detach it again in {@link onModuleDestroy}. */
181
+ private invalidationListener?: (channel: string, message: string) => void;
182
+
183
+ constructor(
184
+ @Optional() @InjectConnection() private readonly connection?: Connection,
185
+ @Optional() protected readonly redisService?: CoreRedisService,
186
+ ) {}
187
+
188
+ /**
189
+ * Subscribe to the cache invalidations of the other replicas. Runs after every `onModuleInit`, so the Redis service is
190
+ * connected. A no-op without `redis`.
191
+ */
192
+ async onApplicationBootstrap(): Promise<void> {
193
+ if (!this.redisService?.enabled) {
194
+ return;
195
+ }
196
+ const channel = this.invalidationChannel(this.redisService);
197
+ try {
198
+ // Held in a field: the subscriber connection is shared between features, so a listener that is never detached
199
+ // keeps clearing a destroyed mapper's cache for the life of the process.
200
+ this.invalidationListener = (incomingChannel: string, message: string) => {
201
+ if (incomingChannel === channel) {
202
+ this.applyInvalidation(message);
203
+ }
204
+ };
205
+ this.redisService.getSubscriber().on('message', this.invalidationListener);
206
+ // Registered with the Redis service, so the subscription is established again after every reconnect — also when
207
+ // this first attempt fails because Redis is down at boot.
208
+ await this.redisService.subscribeChannel(channel);
209
+ } catch (error) {
210
+ this.logger.warn(
211
+ `IAM user cache invalidation subscribe failed: ${(error as Error).message}. Retried when the Redis connection ` +
212
+ 'is up; until then a role or verification change made on another replica reaches this one only when its ' +
213
+ 'cache entry expires and at the next periodic WebSocket re-check.',
214
+ );
215
+ }
216
+ }
217
+
218
+ onModuleDestroy(): void {
219
+ if (!this.invalidationListener) {
220
+ return;
221
+ }
222
+ try {
223
+ if (this.redisService) {
224
+ this.redisService.getSubscriber().off('message', this.invalidationListener);
225
+ void this.redisService.releaseChannel(this.invalidationChannel(this.redisService)).catch(() => undefined);
226
+ }
227
+ } catch {
228
+ // Redis already gone — nothing to detach from
229
+ }
230
+ this.invalidationListener = undefined;
231
+ }
171
232
 
172
233
  /**
173
234
  * Maps a Better-Auth session user to a user with role capabilities
@@ -203,7 +264,7 @@ export class CoreBetterAuthUserMapper {
203
264
 
204
265
  try {
205
266
  // Check lightweight cache first (only stores id, roles, verified — ~100 bytes per entry)
206
- const ttl = CoreBetterAuthUserMapper.USER_CACHE_TTL_MS;
267
+ const ttl = this.userCacheTtlMs;
207
268
  const now = Date.now();
208
269
  const cached = ttl > 0 ? this.userCache.get(sessionUser.id) : undefined;
209
270
  if (cached && now < cached.expiresAt) {
@@ -235,7 +296,7 @@ export class CoreBetterAuthUserMapper {
235
296
  const verified = dbUser.verified === true || sessionUser.emailVerified === true;
236
297
 
237
298
  // Cache only the minimal data needed (not the full document)
238
- if (CoreBetterAuthUserMapper.USER_CACHE_TTL_MS > 0) {
299
+ if (this.userCacheTtlMs > 0) {
239
300
  this.cacheUserData(sessionUser.id, { id: String(dbUser._id ?? dbUser.id), roles, verified });
240
301
  }
241
302
 
@@ -316,7 +377,7 @@ export class CoreBetterAuthUserMapper {
316
377
  }
317
378
  this.userCache.set(iamId, {
318
379
  ...data,
319
- expiresAt: Date.now() + CoreBetterAuthUserMapper.USER_CACHE_TTL_MS,
380
+ expiresAt: Date.now() + this.userCacheTtlMs,
320
381
  });
321
382
  }
322
383
 
@@ -324,10 +385,65 @@ export class CoreBetterAuthUserMapper {
324
385
  * Invalidate cached user data. Call when user roles or verified status change.
325
386
  * Called automatically from `CoreUserService.setRoles()` and `CoreUserService.update()`.
326
387
  *
388
+ * With `redis` configured, the invalidation is broadcast to every replica, which also re-checks the user's open
389
+ * GraphQL WebSockets — otherwise a withdrawn role would keep working there until the cache entry expires.
390
+ *
327
391
  * @param iamId - The BetterAuth user ID (from session/account, not MongoDB _id)
328
392
  */
329
393
  invalidateUserCache(iamId: string): void {
330
394
  this.userCache.delete(iamId);
395
+ this.publishInvalidation(iamId);
396
+ }
397
+
398
+ /**
399
+ * Apply an invalidation received from a replica (this one included — it is subscribed to its own broadcasts). Clears
400
+ * locally only: re-publishing would bounce the message around the cluster.
401
+ */
402
+ protected applyInvalidation(message: string): void {
403
+ let iamId: unknown;
404
+ try {
405
+ iamId = JSON.parse(message)?.iamId;
406
+ } catch {
407
+ iamId = undefined;
408
+ }
409
+ if (typeof iamId !== 'string' || !iamId) {
410
+ this.logger.debug(`Ignoring malformed IAM user cache invalidation message: ${message}`);
411
+ return;
412
+ }
413
+ this.userCache.delete(iamId);
414
+ // The replica that changed the user re-checks its own sockets; these are the ones on this replica. After the cache
415
+ // above, so the re-check reads the user as they are now.
416
+ revalidateWsConnectionsOf({ iamId });
417
+ }
418
+
419
+ /**
420
+ * Broadcast an invalidation to the other replicas (fire-and-forget). A no-op without `redis`.
421
+ *
422
+ * A failure is logged as a warning, not swallowed: until the entry expires on the other replicas, a withdrawn role
423
+ * keeps working there.
424
+ */
425
+ protected publishInvalidation(iamId: string): void {
426
+ const redisService = this.redisService;
427
+ if (!redisService?.enabled) {
428
+ return;
429
+ }
430
+ const warn = (error: Error) =>
431
+ this.logger.warn(
432
+ `IAM user cache invalidation could not be broadcast: ${error.message}. Other replicas keep the previous roles ` +
433
+ 'until their cache entry expires, and their open WebSockets until the next periodic re-check.',
434
+ );
435
+ try {
436
+ void redisService
437
+ .getClient()
438
+ .publish(this.invalidationChannel(redisService), JSON.stringify({ iamId }))
439
+ .catch(warn);
440
+ } catch (error) {
441
+ warn(error as Error);
442
+ }
443
+ }
444
+
445
+ private invalidationChannel(redisService: CoreRedisService): string {
446
+ return redisService.key('iam-user-cache', 'invalidate');
331
447
  }
332
448
 
333
449
  // ===================================================================================================================
@@ -34,7 +34,7 @@ import {
34
34
  } from './better-auth.config.js';
35
35
  import { applyBetterAuthSchema } from './better-auth-sql.helper.js';
36
36
  import { getBetterAuthSqlStore, setBetterAuthSqlStore } from './core-better-auth-sql.registry.js';
37
- import { BetterAuthSqlStore, type IamSqlQueryable } from './core-better-auth-sql.store.js';
37
+ import { BetterAuthSqlStore } from './core-better-auth-sql.store.js';
38
38
  import { DefaultBetterAuthResolver } from './better-auth.resolver.js';
39
39
  import { CoreBetterAuthApiMiddleware } from './core-better-auth-api.middleware.js';
40
40
  import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.service.js';
@@ -490,7 +490,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
490
490
  }
491
491
  const result = instantiateBetterAuth(built);
492
492
  if (storage) {
493
- setBetterAuthSqlStore(new BetterAuthSqlStore(storage.users, result.instance, storage.pool as IamSqlQueryable));
493
+ setBetterAuthSqlStore(new BetterAuthSqlStore(storage.users, result.instance, storage));
494
494
  }
495
495
  return result;
496
496
  }
@@ -22,6 +22,7 @@
22
22
  * - LTNS_0400-LTNS_0499: Resource errors
23
23
  * - LTNS_0500-LTNS_0599: File errors
24
24
  * - LTNS_0600-LTNS_0699: AI errors
25
+ * - LTNS_0700-LTNS_0799: Offline sync errors
25
26
  * - LTNS_0900-LTNS_0999: Internal errors
26
27
  *
27
28
  * @example
@@ -515,6 +516,15 @@ export const LtnsErrors = {
515
516
  },
516
517
  },
517
518
 
519
+ VERSION_CONFLICT: {
520
+ code: 'LTNS_0402',
521
+ message: 'Version conflict',
522
+ translations: {
523
+ de: 'Der Datensatz wurde inzwischen geändert.',
524
+ en: 'The record was changed in the meantime.',
525
+ },
526
+ },
527
+
518
528
  // =====================================================
519
529
  // File Errors (LTNS_0500-LTNS_0599)
520
530
  // =====================================================
@@ -702,6 +712,55 @@ export const LtnsErrors = {
702
712
  en: 'The AI connection has no valid base URL.',
703
713
  },
704
714
  },
715
+
716
+ // =====================================================
717
+ // Offline Sync Errors (LTNS_0700-LTNS_0799)
718
+ // =====================================================
719
+
720
+ SYNC_RESYNC_REQUIRED: {
721
+ code: 'LTNS_0700',
722
+ message: 'Resync required',
723
+ translations: {
724
+ de: 'Die lokalen Daten müssen vollständig neu geladen werden.',
725
+ en: 'The local data must be loaded again in full.',
726
+ },
727
+ },
728
+
729
+ SYNC_MODEL_NOT_FOUND: {
730
+ code: 'LTNS_0701',
731
+ message: 'Model is not available for sync',
732
+ translations: {
733
+ de: 'Diese Daten können nicht synchronisiert werden.',
734
+ en: 'This data cannot be synchronized.',
735
+ },
736
+ },
737
+
738
+ SYNC_INVALID_REQUEST: {
739
+ code: 'LTNS_0702',
740
+ message: 'Invalid sync request',
741
+ translations: {
742
+ de: 'Die Synchronisierungsanfrage ist ungültig.',
743
+ en: 'The synchronization request is invalid.',
744
+ },
745
+ },
746
+
747
+ SYNC_ORIGIN_REFUSED: {
748
+ code: 'LTNS_0703',
749
+ message: 'Origin not allowed for this request',
750
+ translations: {
751
+ de: 'Diese Anfrage ist von dieser Seite aus nicht erlaubt.',
752
+ en: 'This request is not allowed from this site.',
753
+ },
754
+ },
755
+
756
+ SYNC_STORE_UNAVAILABLE: {
757
+ code: 'LTNS_0704',
758
+ message: 'Sync store unavailable',
759
+ translations: {
760
+ de: 'Die Synchronisierung ist gerade nicht verfügbar. Bitte später erneut versuchen.',
761
+ en: 'Synchronization is unavailable right now. Please try again later.',
762
+ },
763
+ },
705
764
  } as const satisfies IErrorRegistry;
706
765
 
707
766
  // =====================================================
@@ -196,8 +196,11 @@ shows the merged cluster-wide view and "clear" clears it everywhere. Sequence nu
196
196
  from a shared counter, so the panels' cursor-based polling keeps working across replicas.
197
197
 
198
198
  Writes are fire-and-forget — the collectors sit in hot paths and their API stays synchronous.
199
- If Redis is unreachable a read silently falls back to the local buffer (a diagnostics panel
200
- must not be the thing that breaks), so a partial view during an outage is expected.
199
+ If Redis is unreachable a read answers with the client's cursor and no entries (a diagnostics
200
+ panel must not be the thing that breaks), so the panels show nothing new during an outage, and
201
+ the first poll after it delivers what was missed. Until 12.1.0 the read fell back to the local
202
+ buffer, whose sequence numbers count per replica: the next poll after the outage then repeated
203
+ or skipped entries.
201
204
 
202
205
  #### Cost: Redis write volume scales with your traffic
203
206
 
@@ -48,7 +48,8 @@ const SHARED_READ_CHUNK = 64;
48
48
  *
49
49
  * **Writes are fire-and-forget.** The collectors sit in hot paths (every log line, every
50
50
  * request) and their API towards callers must stay synchronous. A Redis failure is logged at
51
- * debug level; reads then fall back to the local buffer rather than failing the panel.
51
+ * debug level; a read then answers with the client's cursor and no entries rather than failing
52
+ * the panel, and the next successful poll delivers what was missed.
52
53
  */
53
54
  export class HubBuffer<T extends HubBufferEntry> {
54
55
  protected readonly listKey: string;
@@ -116,11 +117,16 @@ export class HubBuffer<T extends HubBufferEntry> {
116
117
  }
117
118
  }
118
119
 
119
- /** Entries oldest→newest, optionally only those newer than `since`. */
120
+ /**
121
+ * Entries oldest→newest, optionally only those newer than `since`.
122
+ *
123
+ * When the shared read fails, the answer keeps the client's cursor and carries no entries. The
124
+ * local buffer is no fallback there: its seq counts on this replica's own scale, and handing out
125
+ * its cursor made the next successful poll repeat or skip entries (DEV-3897).
126
+ */
120
127
  async read(since?: number, limit?: number): Promise<HubBufferData<T>> {
121
- const shared = this.redis ? await this.readShared(since, limit) : undefined;
122
- if (shared) {
123
- return shared;
128
+ if (this.redis) {
129
+ return (await this.readShared(since, limit)) ?? { cursor: since ?? -1, dropped: -1, entries: [] };
124
130
  }
125
131
  return {
126
132
  cursor: this.local.lastSeq,
@@ -130,7 +136,7 @@ export class HubBuffer<T extends HubBufferEntry> {
130
136
  }
131
137
 
132
138
  /**
133
- * Read the shared list, or undefined when Redis is unreachable (caller falls back to local).
139
+ * Read the shared list, or undefined when Redis is unreachable.
134
140
  *
135
141
  * The list is newest-first, so the entry with seq `s` sits at index `total - 1 - s` and the
136
142
  * entries a client with cursor `since` still needs are exactly the first `total - 1 - since`.
@@ -167,7 +173,7 @@ export class HubBuffer<T extends HubBufferEntry> {
167
173
  }
168
174
  } catch (err) {
169
175
  // console, NOT Logger — same re-entrancy as in mirror() above
170
- console.debug(`Hub buffer read failed (${this.listKey}), using the local buffer: ${(err as Error).message}`);
176
+ console.debug(`Hub buffer read failed (${this.listKey}): ${(err as Error).message}`);
171
177
  return undefined;
172
178
  }
173
179
  }
@@ -126,17 +126,21 @@ const migrationOptions = {
126
126
  migrationsDirectory: './migrations',
127
127
  };
128
128
 
129
- // Custom migration logic
129
+ // Run all pending migrations: a MigrationRunner under the lock
130
+ await synchronizedUp(migrationOptions);
131
+
132
+ // Or custom logic under the same lock. `migrationSet` is the loaded state (`lastRun`, `migrations`), not something
133
+ // that runs migrations
130
134
  await synchronizedMigration(migrationOptions, async (migrationSet) => {
131
135
  // Only one instance at a time will execute this code
132
- console.log('Running migrations...');
133
- await promisify(migrationSet.up).call(migrationSet);
136
+ // `migrations` is absent until the first migration was recorded
137
+ console.log(`${migrationSet.migrations?.length ?? 0} migration(s) recorded`);
134
138
  });
135
-
136
- // Or use the convenience function to run all pending migrations
137
- await synchronizedUp(migrationOptions);
138
139
  ```
139
140
 
141
+ `synchronizedUp()` threw on every call until 12.1.0 (it called `up` on the loaded state, which has none). Since 12.1.1
142
+ it runs the `MigrationRunner` — the same runner as `migrate up` — under the lock.
143
+
140
144
  ### SQL migrations (PostgreSQL data sources)
141
145
 
142
146
  On a PostgreSQL source the framework migrates the schema itself at boot (new tables, columns, declared renames,
@@ -257,10 +261,16 @@ async function synchronizedMigration(
257
261
 
258
262
  #### synchronizedUp
259
263
 
260
- Convenience function that executes all pending migrations in a synchronized manner.
264
+ Executes all pending migrations in a synchronized manner: a `MigrationRunner` under the state store's lock. Refuses a
265
+ `MongoStateStore` without `lockCollectionName` instead of running unsynchronized.
261
266
 
262
267
  ```typescript
263
- async function synchronizedUp(opts: MigrationOptions): Promise<void>;
268
+ interface SynchronizedUpOptions extends MigrationOptions {
269
+ migrationsDirectory?: string; // default: `migrations` in the working directory, as for the `migrate` CLI
270
+ pattern?: RegExp; // default: `*.ts` and `*.js`, never `*.d.ts`
271
+ }
272
+
273
+ async function synchronizedUp(opts: SynchronizedUpOptions): Promise<void>;
264
274
  ```
265
275
 
266
276
  ## How It Works
@@ -589,9 +599,8 @@ are **not** re-run after the switch, and no state rewrite is needed. If both `fo
589
599
  `foo.js` are present in the migrations directory (e.g. an overlapping `outDir`), they count
590
600
  as ONE migration: only the `.js` file is loaded and executed, and a warning is emitted.
591
601
 
592
- Note: identity normalization applies to the `MigrationRunner`/CLI path only. The legacy
593
- `synchronizedUp()` helper drives the external `migrate` package's own state handling and
594
- keeps raw-filename identity.
602
+ Identity normalization applies to every path that runs migrations: the `MigrationRunner`, the CLI and, since 12.1.1,
603
+ `synchronizedUp()`, which runs the `MigrationRunner`.
595
604
 
596
605
  ### Missing migration files & strict mode
597
606
 
@@ -21,6 +21,7 @@
21
21
  *
22
22
  * // Using synchronized migration for clustered environments
23
23
  * await synchronizedUp({
24
+ * migrationsDirectory: './migrations',
24
25
  * stateStore: new MongoStateStore({
25
26
  * uri: 'mongodb://localhost/mydb',
26
27
  * lockCollectionName: 'migration_lock'