@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
@@ -0,0 +1,154 @@
1
+ # Migration Guide: 12.1.x → 12.2.x
2
+
3
+ ## No breaking change
4
+
5
+ 12.2.0 adds **offline sync**: mobile apps (React Native, Capacitor) with SQLite, web apps and PWAs with IndexedDB, and
6
+ desktop or Node clients read and write while disconnected and reconcile with the server afterwards. It is opt-in in
7
+ two places — the `sync` configuration and a declaration per model — and **without both nothing changes**: no field,
8
+ index, route or code path is added, and a project's test suite stays as it is (protocol §13). It is a MINOR because of
9
+ its size, not because anything existing moves.
10
+
11
+ ## Overview
12
+
13
+ | Category | Details |
14
+ |---|---|
15
+ | **Breaking Changes** | None |
16
+ | **New Features** | Synchronized models: `version`, tombstones, live-only unique indexes, a change order on every database (§2). The sync module: `GET/POST /sync/{model}` with cursor pull, idempotent push, conflict strategies, client ids, resync (§3). Change hints over SSE and GraphQL, tombstone purge, the `syncUpdate` migration helper (§4). `ServiceOptions.expectedVersion` for conditional changes of a synchronized model (§2) |
17
+ | **Bugfixes** | None that change existing behaviour |
18
+ | **Migration Effort** | None for an update. Offering a model to offline clients: about an hour per model, plus the client (§5) |
19
+
20
+ ## Quick Migration
21
+
22
+ ```bash
23
+ pnpm add @lenne.tech/nest-server@12.2.0
24
+ pnpm run build
25
+ pnpm test
26
+ ```
27
+
28
+ No dependency of a project changes. **Vendor mode**: run `/lt-dev:backend:update-nest-server-core` as usual; the new
29
+ module is `src/core/modules/sync/`.
30
+
31
+ ## 1. What changes for an existing project
32
+
33
+ Nothing, unless it switches offline sync on. The framework's own models are not synchronized, the `CrudService` keeps
34
+ its behaviour for every model that does not declare `sync`, and no route is registered without the `sync`
35
+ configuration.
36
+
37
+ ## 2. Synchronized models
38
+
39
+ A model takes part in offline sync when it is declared with `sync` and registered with
40
+ `CorePersistenceModule.forFeature()`:
41
+
42
+ ```typescript
43
+ @PersistedModel({
44
+ sync: {
45
+ roles: { create: [RoleEnum.S_USER], delete: [RoleEnum.S_CREATOR], pull: [RoleEnum.S_USER], update: [RoleEnum.S_CREATOR] },
46
+ strategy: 'merge',
47
+ },
48
+ })
49
+ @Schema({ timestamps: true })
50
+ @ObjectType()
51
+ export class Note extends CorePersistenceModel { … }
52
+ ```
53
+
54
+ The store then keeps three system fields on every write path — `version`, `deletedAt` and the change position
55
+ (`syncSeq` on SQL, `syncAt` on MongoDB) — never settable through an input:
56
+
57
+ - **Deletes leave tombstones**: `CrudService.delete()` and every port delete keep the record as a tombstone without
58
+ content, so clients learn of the deletion. Reads leave tombstones out.
59
+ - **Unique indexes apply to live records only**, so delete-then-recreate with the same value works.
60
+ - **Conditional changes**: `ServiceOptions.expectedVersion` on `update()` / `delete()` is one statement with the
61
+ version as its condition; another version answers `409` (`VersionConflictError`). Only a synchronized model has a
62
+ `version`: on any other model `expectedVersion` throws `UnsupportedCapabilityError`.
63
+
64
+ **Making an EXISTING model synchronized is a migration** on every database — the boot refuses with the statements to
65
+ run (SQL: the new column and live-only indexes; MongoDB: `SyncMigrationRequiredError`, dropping the old unique indexes
66
+ and stamping every document). Details: `.claude/rules/persistence.md` → "Synchronized models".
67
+
68
+ ## 3. The sync module
69
+
70
+ ```typescript
71
+ // config.env.ts
72
+ sync: {
73
+ cursorSecret: process.env.SYNC_CURSOR_SECRET, // else SECRETS_ENCRYPTION_KEY; required in production/staging
74
+ },
75
+ ```
76
+
77
+ ```typescript
78
+ @SyncService({ createInput: NoteCreateInput, model: Note, updateInput: NoteInput })
79
+ @Injectable()
80
+ export class NoteService extends CrudService<Note, NoteCreateInput, NoteInput> { … }
81
+ ```
82
+
83
+ - `GET /sync/{model}`: one page of changes after an opaque, signed cursor; `410` with a reason when the client must
84
+ resync (`invalid`, `horizon`, `access`).
85
+ - `POST /sync/{model}`: items processed in order, each atomic; idempotent through mutation ids; client-generated ids
86
+ (ObjectId-hex with a random tail, or uuidv7 for new models); strategies `reject`, `lww`, `merge` (`@SyncField()` for
87
+ fields), `custom` (`resolveSyncConflict()` on the service).
88
+ - Every pull and push runs through the model's `CrudService` with the roles from `sync.roles` — the roles of the
89
+ project's own routes do not apply here, and an operation without roles is ADMIN only.
90
+ - Without `X-Tenant-Id` a request spans every tenant the caller belongs to.
91
+
92
+ The protocol for client authors: `docs/offline-sync-protocol.md`. The module: `src/core/modules/sync/README.md`.
93
+
94
+ ## 4. Hints, purge, data migrations
95
+
96
+ - **Hints**: `GET /sync/hints` (Server-Sent Events, event `change`) and the GraphQL subscription `syncHint(models)` tell
97
+ a client "pull now" — `{ model, at }` only, to callers with the pull roles and access to the tenant. With `redis`
98
+ every replica's subscribers receive them. `sync.hints: false` switches them off.
99
+ - **Purge**: tombstones older than `sync.retention.tombstonesDays` (90) are deleted every hour on one replica, after
100
+ the retention horizon moved past them; a client behind it resyncs.
101
+ - **Data migrations** of a synchronized SQL table: `runSqlMigration(ownerUrl, [{ syncUpdate: { table, set, where } }])`
102
+ raises `version` and numbers the rows like a store write. A bare `UPDATE` would let a stale client overwrite the
103
+ migrated value without a conflict.
104
+ - **Writes past the CrudService** (data store, native API) call `CoreSyncService.notifyChange()` for hints and
105
+ `bumpAccessEpoch()` when a record changes tenant or owner.
106
+
107
+ ## 5. Offering a model to offline clients — checklist
108
+
109
+ Follow `src/core/modules/sync/INTEGRATION-CHECKLIST.md`. In short:
110
+
111
+ 1. Declare `@PersistedModel({ sync: { roles, strategy } })` and register the model with `CorePersistenceModule.forFeature()`.
112
+ 2. Put `@SyncService({ model, createInput, updateInput })` on its `CrudService`.
113
+ 3. Add `sync: {}` (with a `cursorSecret` for deployed stages) to the configuration.
114
+ 4. Give the client its duties: delete every local record, cursor and unsent change on logout and on an account switch
115
+ (`src/core/modules/sync/SECURITY.md`).
116
+
117
+ The reference model is `src/server/modules/sync-note/`; the framework's own test clients (a PWA on IndexedDB and a Node
118
+ client on SQLite, `tests/clients/` in the framework repository) show a complete client in a few hundred lines.
119
+
120
+ ## Compatibility Notes
121
+
122
+ | Pattern | Status |
123
+ |---|---|
124
+ | Project without `sync` configuration | Unchanged |
125
+ | Model without `sync` in a project with `sync` | Unchanged; not reachable under `/sync` |
126
+ | `CrudService.delete()` on a synchronized model | Leaves a tombstone; a hard delete is the purge's alone |
127
+ | Project controller extending `CoreSyncController` | Fine — the hint route is `CoreSyncHintsController`'s, registered first, so re-declaring `pull()` cannot shadow it |
128
+ | TTL index on a synchronized model | Refused at registration — an expired record would vanish without a tombstone |
129
+
130
+ ## Troubleshooting
131
+
132
+ | Symptom | Cause | Fix |
133
+ |---|---|---|
134
+ | Boot fails: `cursorSecret` missing | Production / staging without `sync.cursorSecret` or `SECRETS_ENCRYPTION_KEY` | Set one of them |
135
+ | Boot fails with SQL statements or `SyncMigrationRequiredError` | An existing model was declared `sync` | Run the printed migration (§2) |
136
+ | Warning "declares no sync roles" | `sync.roles` missing an operation | Declare roles per operation; otherwise ADMIN only |
137
+ | `400` on a push | `Content-Type` is not `application/json` | Send JSON |
138
+ | `403` on a cookie push | `Sec-Fetch-Site: cross-site` or an untrusted `Origin` | Push from a trusted origin (`appUrl`, `baseUrl`, `cors.allowedOrigins`) |
139
+ | Every cursor answers `410 invalid` | `cursorSecret` changed | Expected: clients resync once |
140
+ | Boot fails on MySQL / MariaDB with error 1419 (`You do not have the SUPER privilege and binary logging is enabled`) once a model is declared `sync` | The change-order triggers are created by the owner account (`database.ownerUrl`); with binary logging on (the MySQL 8.4 default) that needs `SUPER` or `log_bin_trust_function_creators=1` | Set `log_bin_trust_function_creators=1` on the server (the parameter group on managed MySQL), or grant the owner `SUPER`. The application account needs neither |
141
+ | A client keeps records after its user left a tenant | The client treats only `410 access` as access loss | A pull answering `403` (pull roles withdrawn, or `X-Tenant-Id` naming a left tenant) is access loss too: drop that model's local records and cursor (`SECURITY.md`) |
142
+ | Hints only on one replica | No `redis` configured | Configure `redis`; hints are never a correctness signal |
143
+
144
+ ## Module Documentation
145
+
146
+ - `src/core/modules/sync/README.md` — module overview, configuration, security model, hints, purge
147
+ - `src/core/modules/sync/INTEGRATION-CHECKLIST.md` — integration steps
148
+ - `src/core/modules/sync/SECURITY.md` — what the server enforces, what a client must do
149
+ - `docs/offline-sync-protocol.md` — the client contract
150
+ - `src/server/modules/sync-note/` — reference model
151
+
152
+ ## References
153
+
154
+ - nest-server-starter: unaffected (it declares no synchronized model)
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "12.1.0",
3
+ "version": "12.2.0",
4
4
  "type": "module",
5
- "description": "Server framework in TypeScript on top of NestJS: GraphQL and REST APIs on MongoDB, PostgreSQL, MySQL or MariaDB, with authentication, field-level permissions, multi-tenancy and an AI assistant.",
5
+ "description": "Server framework in TypeScript on top of NestJS: GraphQL and REST APIs on MongoDB, PostgreSQL, MySQL or MariaDB, with authentication, field-level permissions, multi-tenancy, offline sync and an AI assistant.",
6
6
  "keywords": [
7
7
  "node",
8
8
  "nest",
@@ -15,6 +15,7 @@
15
15
  "graphql",
16
16
  "rest",
17
17
  "multi-tenancy",
18
+ "offline-sync",
18
19
  "typescript"
19
20
  ],
20
21
  "author": "lenne.Tech <info@lenne.tech> (https://lenne.tech)",
@@ -136,7 +137,7 @@
136
137
  "lodash": "4.18.1",
137
138
  "multer": "2.5.0",
138
139
  "node-mailjet": "6.0.11",
139
- "nodemailer": "10.2.1",
140
+ "nodemailer": "10.3.0",
140
141
  "passport": "0.7.0",
141
142
  "passport-jwt": "4.0.1",
142
143
  "reflect-metadata": "0.2.2",
@@ -225,9 +226,9 @@
225
226
  }
226
227
  },
227
228
  "devDependencies": {
228
- "@aws-sdk/client-s3": "3.1158.0",
229
- "@aws-sdk/lib-storage": "3.1158.0",
230
- "@aws-sdk/s3-request-presigner": "3.1158.0",
229
+ "@aws-sdk/client-s3": "3.1159.0",
230
+ "@aws-sdk/lib-storage": "3.1159.0",
231
+ "@aws-sdk/s3-request-presigner": "3.1159.0",
231
232
  "@better-auth/core": "1.7.14",
232
233
  "@better-auth/passkey": "1.7.14",
233
234
  "@compodoc/compodoc": "2.0.0",
@@ -244,12 +245,12 @@
244
245
  "@types/express": "5.0.6",
245
246
  "@types/lodash": "4.17.27",
246
247
  "@types/multer": "2.3.0",
247
- "@types/node": "26.6.5",
248
+ "@types/node": "26.6.6",
248
249
  "@types/passport": "1.0.17",
249
250
  "@types/pg": "8.23.1",
250
251
  "@vitest/coverage-v8": "5.0.5",
251
252
  "better-auth": "1.7.14",
252
- "bullmq": "6.5.0",
253
+ "bullmq": "6.5.1",
253
254
  "cross-env": "10.1.0",
254
255
  "drizzle-orm": "1.0.0-rc.5",
255
256
  "ioredis": "6.0.0",
@@ -263,6 +264,7 @@
263
264
  "oxlint": "1.90.0",
264
265
  "oxlint-tsgolint": "7.0.2005",
265
266
  "pg": "8.23.1",
267
+ "playwright-core": "1.64.1",
266
268
  "rimraf": "6.1.3",
267
269
  "smtp-server": "3.21.1",
268
270
  "tsx": "4.23.15",
@@ -281,15 +281,17 @@ export function multerFileToUpload(file: {
281
281
 
282
282
  /**
283
283
  * Get function to generate a random file name for multer with a certain
284
- * number of digits
284
+ * number of hexadecimal digits
285
+ *
286
+ * The digits come from `randomBytes()`: where the upload directory is served,
287
+ * a name from `Math.random()` could be predicted (DEV-3897).
285
288
  */
286
289
  export function multerRandomFileName(numberOfDigits = 32) {
287
290
  return (req, file, cb) => {
288
291
  // Generating a random string
289
- const randomName = Array(numberOfDigits)
290
- .fill(null)
291
- .map(() => Math.round(Math.random() * 16).toString(16))
292
- .join('');
292
+ const randomName = randomBytes(Math.ceil(numberOfDigits / 2))
293
+ .toString('hex')
294
+ .slice(0, numberOfDigits);
293
295
 
294
296
  // Calling the callback passing the random name generated with the
295
297
  // original extension name
@@ -355,8 +355,9 @@ function markStale(connection: WsConnection): void {
355
355
 
356
356
  /**
357
357
  * Re-check, at once, every open connection of a user — after a membership, role or credential change.
358
- * Answers the number of connections affected. Process-local: the tenant guard's Redis broadcast is
359
- * what carries a membership invalidation to the other replicas.
358
+ * Answers the number of connections affected. Process-local: the Redis broadcasts of the tenant guard
359
+ * (memberships) and of the Better-Auth user mapper (roles, verification) carry an invalidation to the
360
+ * other replicas.
360
361
  */
361
362
  export function revalidateWsConnectionsOf(identity: IWsConnectionIdentity): number {
362
363
  const userId = identity?.userId !== undefined && identity?.userId !== null ? String(identity.userId) : undefined;
@@ -27,7 +27,8 @@ export interface CronJobConfig<OC extends CronOnCompleteCommand | null = null, C
27
27
  * Whether the job is deduplicated across replicas in multi-replica deployments.
28
28
  *
29
29
  * When enabled, only ONE replica executes each tick: via BullMQ job schedulers when
30
- * Redis is configured, otherwise via a MongoDB lease per tick. The lease is BEST EFFORT —
30
+ * Redis is configured, otherwise via a lease per tick (Redis, the SQL default source, or
31
+ * MongoDB), keyed on the tick's SCHEDULED instant. The lease is BEST EFFORT —
31
32
  * an unreachable lease store runs the tick everywhere rather than stopping all scheduled
32
33
  * work, because a silent fleet-wide outage of every cron job is the worse failure.
33
34
  *
@@ -52,8 +53,12 @@ export interface CronJobConfig<OC extends CronOnCompleteCommand | null = null, C
52
53
 
53
54
  /**
54
55
  * Depending on how long the execution of a job takes, it may happen that several executions take place at the
55
- * same time. This can be prevented with `runParallel = false`.This option is set to `true` by default.
56
+ * same time. This can be prevented with `runParallel = false`. This option is set to `true` by default.
56
57
  * If a number is specified, it is used as the number of maximum parallel executions.
58
+ *
59
+ * For a `distributed` job, `false` (or `1`) holds across replicas too: the run holds a lease, renewed while it
60
+ * runs and released at its end, so another replica skips its tick instead of starting the job in parallel
61
+ * (12.1.1+). A larger number limits each process on its own.
57
62
  */
58
63
  runParallel?: boolean | number;
59
64
 
@@ -3509,6 +3509,16 @@ export interface IServerOptions {
3509
3509
  path?: string;
3510
3510
  };
3511
3511
 
3512
+ /**
3513
+ * Offline sync (`docs/offline-sync-protocol.md`): `GET/POST /sync/{model}` for the models whose service carries
3514
+ * `@SyncService()` and whose model is declared with `@PersistedModel({ sync })`. Presence implies enabled — `true`
3515
+ * or `{}` enables it with the defaults, `{ enabled: false }` pre-configures without enabling. Absent: no route, no
3516
+ * store, no code path.
3517
+ *
3518
+ * @since 12.2.0
3519
+ */
3520
+ sync?: boolean | ISync;
3521
+
3512
3522
  /**
3513
3523
  * System setup configuration for initial admin creation.
3514
3524
  *
@@ -3831,6 +3841,125 @@ export interface IS3Config {
3831
3841
  stagingBucket?: string;
3832
3842
  }
3833
3843
 
3844
+ /**
3845
+ * Offline sync (`sync`, presence implies enabled). See `src/core/modules/sync/README.md`.
3846
+ *
3847
+ * @since 12.2.0
3848
+ */
3849
+ export interface ISync {
3850
+ /**
3851
+ * Secret the pull cursors are signed with (HMAC; the key is derived from it). Falls back to the
3852
+ * `SECRETS_ENCRYPTION_KEY` environment variable. REQUIRED in `production` / `staging` — the boot fails without it;
3853
+ * elsewhere an insecure development default is used with a warning. Changing it answers every client's next pull
3854
+ * with a full resync (`410 invalid`).
3855
+ */
3856
+ cursorSecret?: string;
3857
+
3858
+ /**
3859
+ * Pre-configure without enabling.
3860
+ * @default true (when the object is present)
3861
+ */
3862
+ enabled?: boolean;
3863
+
3864
+ /**
3865
+ * Hints that a synchronized model changed (protocol §9): SSE `GET /sync/hints` and the GraphQL subscription
3866
+ * `syncHint`, carrying `{ model, at }` only, to callers with the model's pull roles and — on a tenant-scoped model —
3867
+ * for changes in their own tenants. A hint only means "pull now": a client works without them. `false` switches both
3868
+ * channels off; with `redis` a hint reaches the subscribers of every replica.
3869
+ * @default on (both channels)
3870
+ */
3871
+ hints?:
3872
+ | boolean
3873
+ | {
3874
+ /**
3875
+ * At most one hint per model and tenant within this many milliseconds — a burst of writes is one hint.
3876
+ * @default 250
3877
+ */
3878
+ debounceMs?: number;
3879
+ /** @default true (when the object is present) */
3880
+ enabled?: boolean;
3881
+ /**
3882
+ * The GraphQL subscription `syncHint` (only where GraphQL is enabled).
3883
+ * @default true
3884
+ */
3885
+ graphql?: boolean;
3886
+ /**
3887
+ * The SSE stream `GET /sync/hints`.
3888
+ * @default true
3889
+ */
3890
+ sse?: boolean;
3891
+ };
3892
+
3893
+ /**
3894
+ * Route prefix of the sync endpoints.
3895
+ * @default 'sync'
3896
+ */
3897
+ path?: string;
3898
+
3899
+ pull?: {
3900
+ /**
3901
+ * Records per page when the client sends no `limit`.
3902
+ * @default 200
3903
+ */
3904
+ defaultLimit?: number;
3905
+ /**
3906
+ * Upper bound of the serialized size of one page; a page ends before it would exceed it (it always holds at least
3907
+ * one item when one is pending).
3908
+ * @default 1048576 (1 MiB)
3909
+ */
3910
+ maxBytes?: number;
3911
+ /**
3912
+ * Highest `limit` a client may ask for (at most 1000).
3913
+ * @default 1000
3914
+ */
3915
+ maxLimit?: number;
3916
+ /**
3917
+ * MongoDB only: the window W a pull run re-reads below its previous start (plan §3.4). Changes stamped within W
3918
+ * before a run started may be repeated; a write that takes longer than W from its stamp until it is visible would
3919
+ * be missed, so W must cover the longest such write.
3920
+ * @default replica set / sharded: the server's `transactionLifetimeLimitSeconds` (60 when unreadable) + 30;
3921
+ * standalone: 30
3922
+ */
3923
+ windowSeconds?: number;
3924
+ };
3925
+
3926
+ push?: {
3927
+ /**
3928
+ * Items per push request; more answer 400.
3929
+ * @default 100
3930
+ */
3931
+ maxItems?: number;
3932
+ };
3933
+
3934
+ /**
3935
+ * Per-user rate limits of pull and push (fixed window; exact across replicas when `redis` is configured). `false`
3936
+ * switches both off, `{ pull: false }` one of them.
3937
+ * @default pull 120 / 60 s, push 60 / 60 s
3938
+ */
3939
+ rateLimit?:
3940
+ | false
3941
+ | {
3942
+ pull?: false | { max?: number; windowSeconds?: number };
3943
+ push?: false | { max?: number; windowSeconds?: number };
3944
+ };
3945
+
3946
+ retention?: {
3947
+ /**
3948
+ * How long the outcome of a pushed mutation is kept, so a repeat is answered with it (protocol §6.5).
3949
+ * @default 30
3950
+ */
3951
+ mutationsDays?: number;
3952
+ /**
3953
+ * How long a tombstone is kept. Every hour (on one replica, under a lease) the purge first moves the model's
3954
+ * horizon to this age, then deletes the older tombstones for good; a client whose cursor is older than the horizon
3955
+ * answers its next pull with a full resync (`410 horizon`). `false` keeps tombstones for ever; an invalid value
3956
+ * (zero, negative, not a number) falls back to the default — never to "purge everything".
3957
+ * @default 90
3958
+ */
3959
+ tombstonesDays?: false | number;
3960
+ };
3961
+ }
3962
+
3834
3963
  export interface ISystemSetup {
3835
3964
  /**
3836
3965
  * Whether system setup is enabled.
@@ -4831,6 +4960,26 @@ export interface ICoreModuleOverrides {
4831
4960
  service?: Type<any>;
4832
4961
  };
4833
4962
 
4963
+ /**
4964
+ * Override the offline sync endpoints, the hint subscription and their service (`sync` config).
4965
+ *
4966
+ * - `controller` must extend `CoreSyncController` (e.g. to release a sync scope to API tokens)
4967
+ * - `hintsController` must extend `CoreSyncHintsController` (`GET /sync/hints`, e.g. for API tokens there too)
4968
+ * - `resolver` must extend `CoreSyncResolver` (the `syncHint` subscription)
4969
+ * - `service` must extend `CoreSyncService`
4970
+ *
4971
+ * @example
4972
+ * ```typescript
4973
+ * { sync: { controller: SyncController } }
4974
+ * ```
4975
+ */
4976
+ sync?: {
4977
+ controller?: Type<any>;
4978
+ hintsController?: Type<any>;
4979
+ resolver?: Type<any>;
4980
+ service?: Type<any>;
4981
+ };
4982
+
4834
4983
  /**
4835
4984
  * Override AI module collaborators with project-specific subclasses.
4836
4985
  *
@@ -32,6 +32,11 @@ export interface ServiceOptions {
32
32
  // If truly: create mode is activated
33
33
  create?: boolean;
34
34
 
35
+ // The id `create()` gives the new record: on a synchronized model the id a client chose offline, in the model's
36
+ // format (refused otherwise), on any other model a 24-character hex id. Passed beside the input because an input
37
+ // class keeps only the fields it declares: an `id` inside the input is lost the moment `inputType` maps it
38
+ createId?: string;
39
+
35
40
  // Current user to set ownership, check rights and other things
36
41
  currentUser?: {
37
42
  [key: string]: any;
@@ -42,9 +47,19 @@ export interface ServiceOptions {
42
47
  // Field selection for results (will be overwritten by populate if populate was set)
43
48
  fieldSelection?: FieldSelection;
44
49
 
50
+ // Conditional change of a synchronized model (`@PersistedModel({ sync })`): `update()` and `delete()` apply only
51
+ // when the stored record still has this version, in one atomic statement, and throw a 409 (`VersionConflictError`)
52
+ // otherwise. Refused on a model that is not synchronized
53
+ expectedVersion?: number;
54
+
45
55
  // Determines whether all restrictions are ignored
46
56
  force?: boolean;
47
57
 
58
+ // Read a synchronized model's tombstones too — its deleted records, which every other read leaves out (get, find,
59
+ // findOne, findAndCount, distinct). Reads only: update() and delete() never touch a tombstone and answer 404 for one.
60
+ // Applies to the main model only: a populated reference to a tombstone stays empty
61
+ includeTombstones?: boolean;
62
+
48
63
  // Overwrites type of input (array items)
49
64
  inputType?: new (...params: any[]) => any;
50
65
 
@@ -4,8 +4,15 @@ import { getConnectionToken, getModelToken } from './mongo/mongoose-compat.js';
4
4
  import type { Model } from 'mongoose';
5
5
 
6
6
  import { registerModelSchema } from './model-metadata.registry.js';
7
+ import { isDeclaredSchema } from './mongo/model-declarations.js';
8
+ import { applySyncSchema, assertSyncCollectionReady } from './mongo/sync-schema.plugin.js';
7
9
  import { MongooseDataStore } from './mongoose-data-store.js';
8
- import { getPersistedModelOptions, getPersistedModelOptionsByName } from './persisted-model.decorator.js';
10
+ import {
11
+ getPersistedModelOptions,
12
+ getPersistedModelOptionsByName,
13
+ getPersistedSyncByName,
14
+ } from './persisted-model.decorator.js';
15
+ import { resolveSyncOptions } from './sync-model.js';
9
16
  import {
10
17
  bindStoreToModel,
11
18
  getPersistenceRuntime,
@@ -46,7 +53,15 @@ export interface PersistenceModelDefinition extends ModelDefinition {
46
53
  export class CorePersistenceModule {
47
54
  static forFeature(models: PersistenceModelDefinition[]): DynamicModule {
48
55
  for (const model of models) {
49
- registerModelSchema(model.name, model.schema);
56
+ const sync = resolveSyncOptions(
57
+ model.model ? getPersistedModelOptions(model.model)?.sync : getPersistedSyncByName(model.name),
58
+ model.name,
59
+ );
60
+ // Described as declared first, then prepared: the descriptor must not see the Mongoose sync paths
61
+ registerModelSchema(model.name, model.schema, sync);
62
+ if (sync && !isDeclaredSchema(model.schema)) {
63
+ applySyncSchema(model.schema, sync);
64
+ }
50
65
  registerPersistedModel({
51
66
  declaredSource:
52
67
  model.dataSource ??
@@ -88,6 +103,10 @@ export class CorePersistenceModule {
88
103
  ? // No SQL source and no MongoDB connection: the project runs without a database (`database: false`)
89
104
  unavailableStore(model.name, 'the project runs without a database (`database: false`)')
90
105
  : new MongooseDataStore(mongooseModel);
106
+ // A synchronized model on MongoDB: the collection must hold nothing from before it was synchronized
107
+ if (store.capabilities.mongoosePopulate && store.capabilities.changeOrder) {
108
+ await assertSyncCollectionReady(mongooseModel);
109
+ }
91
110
  registerDataStore(model.name, store);
92
111
  bindStoreToModel(mongooseModel, store);
93
112
  // A stand-in already refuses, naming the cause; a real Mongoose model of a model routed elsewhere is made to
@@ -13,6 +13,7 @@ import {
13
13
  } from './data-source.config.js';
14
14
  import type { ModelDescriptor } from './model-descriptor.interface.js';
15
15
  import { getModelDescriptor } from './model-metadata.registry.js';
16
+ import { withSequenceField } from './sync-model.js';
16
17
  import { MongooseDataStore } from './mongoose-data-store.js';
17
18
  import {
18
19
  getPersistedModels,
@@ -20,8 +21,6 @@ import {
20
21
  type PersistenceOverview,
21
22
  type PersistenceRuntime,
22
23
  } from './persistence-runtime.registry.js';
23
- import { grantStatement } from './sql/postgres/postgres-schema.compiler.js';
24
- import { assertSafeApplicationRole, SCHEMA_LOCK_SQL } from './sql/postgres/postgres-schema.migrator.js';
25
24
  import { type SqlDialect, sqlDialect } from './sql/sql-dialect.js';
26
25
  import { createSqlExecutor } from './sql/sql-executor.factory.js';
27
26
  import type { SqlExecutor } from './sql/sql-executor.interface.js';
@@ -120,19 +119,16 @@ export class DataSourceRuntime implements PersistenceRuntime {
120
119
  if (!registration || !source || source.driver === 'mongodb') {
121
120
  return undefined;
122
121
  }
123
- if (source.driver !== 'postgres') {
124
- throw new Error(
125
- `Better-Auth cannot run on ${this.dialect(source).label} yet: the user model (table ${USERS_TABLE}) is routed to ` +
126
- `the source "${source.name}". Keep the user model on PostgreSQL or MongoDB (database.models), or switch Better-Auth off.`,
127
- );
128
- }
129
122
  // Opening the store brings `users` up to date first: Better-Auth only adds its columns to the table the user model
130
- // defines, never the other way round
123
+ // defines, never the other way round — and on MySQL / MariaDB that run also grants the application account its
124
+ // rights on the database, which then cover Better-Auth's tables
131
125
  const users = await this.resolveStore(registration.name, undefined);
132
126
  const app = await this.executor(source);
133
127
  return {
134
128
  applySchema: (migrate, tables) => this.applyForeignSchema(source, app, migrate, tables),
135
- pool: app.driverPool,
129
+ driver: source.driver as IamSqlStorage['driver'],
130
+ pool: this.dialect(source).foreignPool(app),
131
+ query: queryOn(app),
136
132
  source: source.name,
137
133
  users,
138
134
  };
@@ -195,10 +191,12 @@ export class DataSourceRuntime implements PersistenceRuntime {
195
191
  }
196
192
 
197
193
  private async openStore(source: ResolvedDataSource, name: string): Promise<DataStore<any>> {
198
- const model = getModelDescriptor(name);
199
- if (!model) {
194
+ const declared = getModelDescriptor(name);
195
+ if (!declared) {
200
196
  throw new Error(`No descriptor for model "${name}" — register it with CorePersistenceModule.forFeature()`);
201
197
  }
198
+ // A synchronized model's table also carries its commit-ordered change position (offline-sync plan §3.4)
199
+ const model = withSequenceField(declared);
202
200
  const app = await this.executor(source);
203
201
  const dialect = this.dialect(source);
204
202
  let changes: SchemaChange[];
@@ -270,11 +268,12 @@ export class DataSourceRuntime implements PersistenceRuntime {
270
268
  private async applyForeignSchema(
271
269
  source: ResolvedDataSource,
272
270
  app: SqlExecutor,
273
- migrate: (pool: unknown, asOwner: boolean) => Promise<void>,
271
+ migrate: Parameters<IamSqlStorage['applySchema']>[0],
274
272
  tables: string[],
275
273
  ): Promise<void> {
274
+ const dialect = this.dialect(source);
276
275
  if (!source.ownerUrl) {
277
- await migrate(app.driverPool, false);
276
+ await migrate(dialect.foreignPool(app), false, queryOn(app));
278
277
  } else {
279
278
  const connect = (maxConnections: number) =>
280
279
  createSqlExecutor({
@@ -284,20 +283,15 @@ export class DataSourceRuntime implements PersistenceRuntime {
284
283
  url: source.ownerUrl,
285
284
  });
286
285
  const [lock, migrator] = await Promise.all([connect(1), connect(2)]);
287
- const appRole = decodeURIComponent(new URL(source.url).username);
288
286
  try {
289
- await lock.transaction(async (tx) => {
290
- await tx.run({ params: [], text: SCHEMA_LOCK_SQL }, SYSTEM);
291
- await migrate(migrator.driverPool, true);
292
- for (const table of tables) {
293
- await tx.run({ params: [], text: grantStatement(table, appRole) }, SYSTEM);
294
- }
295
- });
287
+ await dialect.withForeignSchemaLock(lock, tables, () =>
288
+ migrate(dialect.foreignPool(migrator), true, queryOn(migrator)),
289
+ );
296
290
  } finally {
297
291
  await Promise.all([lock.close(), migrator.close()]);
298
292
  }
299
293
  }
300
- await assertSafeApplicationRole(app, tables);
294
+ await dialect.assertSafeApplication(app, tables);
301
295
  }
302
296
 
303
297
  private dialect(source: ResolvedDataSource): SqlDialect {
@@ -346,6 +340,11 @@ const USERS_TABLE = 'users';
346
340
 
347
341
  const SYSTEM = { kind: 'system' } as const;
348
342
 
343
+ /** One statement on an executor as system work, `$1`-numbered parameters, its rows back */
344
+ function queryOn(executor: SqlExecutor): IamSqlStorage['query'] {
345
+ return async (text, params = []) => ({ rows: (await executor.run({ params, text }, SYSTEM)).rows });
346
+ }
347
+
349
348
  async function closeExecutor(pending: Promise<SqlExecutor>): Promise<void> {
350
349
  await (await pending.catch(() => undefined))?.close();
351
350
  }