@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,410 @@
1
+ # Offline sync protocol
2
+
3
+ > **Status: implemented** by the sync module (`src/core/modules/sync/`, 12.2.0): pull, push, conflicts, client ids and
4
+ > resync (DEV-3901), hints (§9) and the tombstone purge (DEV-3902), proven end to end by a PWA and a Node test client
5
+ > and by two server instances with skewed clocks (DEV-3903). Specified in
6
+ > phase 1 of the offline-sync epic (DEV-3819, DEV-3898), the open points decided by the cursor prototype of phase 2
7
+ > (DEV-3899). The implementation plan, the port extensions, the reasoning and the measurements live in the framework
8
+ > repository under `.claude/plans/offline-sync/PLAN.md`; §15 lists every decision.
9
+
10
+ Clients read and write while disconnected and reconcile with the server when they are back: they pull what changed
11
+ since their cursor, push what they changed locally, and receive tombstones for deletions. The protocol is HTTP with
12
+ JSON; a client needs `fetch` and nothing from the server's code. It serves mobile apps (React Native, Capacitor) with
13
+ SQLite, web apps and PWAs with IndexedDB or SQLite-WASM, and desktop or Node clients — on every database the server
14
+ supports (MongoDB, PostgreSQL with any engine, MySQL / MariaDB) with the same observable behaviour.
15
+
16
+ ## 1. Principles
17
+
18
+ 1. **Stateless server.** The server keeps nothing per device. The cursor is opaque, owned by the client, and every
19
+ device and every browser tab may hold its own.
20
+ 2. **The same security pipeline as every other request.** A pull reads through `CrudService` (rights, `@Restricted`,
21
+ `securityCheck`, tenant policy before every store call, tenant row check after every read, row-level security on
22
+ PostgreSQL, interceptors); a push writes through it. There is no lean path around it.
23
+ 3. **Operation rights are declared for sync.** The roles a project puts on its own routes (`@Roles` on a controller,
24
+ `roles` passed to `update()` in a controller) do not reach `/sync`. Each sync model declares who may pull, create,
25
+ update and delete; an operation without a declaration is open to administrators only.
26
+ 4. **Every pushed change stands alone.** Each item is applied atomically and independently; one failing item never
27
+ rolls back another.
28
+ 5. **No silent loss.** When the server cannot deliver a consistent delta — a cursor behind the retention horizon, a
29
+ changed access scope — it says so (§8) instead of returning partial data.
30
+ 6. **Opt-in per model.** A project without a sync configuration gets no new field, index, route or code path.
31
+
32
+ ## 2. Concepts
33
+
34
+ | Term | Meaning |
35
+ |---|---|
36
+ | Sync model | A model the project declared syncable. Only those are reachable under `/sync`. |
37
+ | Record | One row/document of a sync model as the client sees it, read through the normal output pipeline. |
38
+ | `id` | The record's id, chosen by the client for records it creates (§3). |
39
+ | `version` | An integer the server increments on every change of the record, starting at 1. Never set by a client. |
40
+ | `updatedAt` | The server's time of the last change. Informational; ordering never relies on it. |
41
+ | `deletedAt` | `null` for a live record, the deletion time for a tombstone. |
42
+ | Tombstone | What remains of a deleted record: `id`, `version`, `deletedAt`, `updatedAt`, on a tenant-scoped model `tenantId`, and the user-id fields the model declares as its scope (e.g. `ownerId`). The server also keeps its audit fields (`createdAt`, `createdBy`, `updatedBy` — who deleted it); a pull sends none of them. No content field. |
43
+ | Cursor | Opaque string the server returns with every pull page; the client sends it back to continue. |
44
+ | Retention horizon | How long tombstones are kept. A cursor older than the horizon cannot be continued (§8). |
45
+ | Mutation id | A unique id the client gives every pushed change, so a repeated push is recognised (§6.5). |
46
+
47
+ ## 3. Identifiers
48
+
49
+ **Clients create the ids of records they create offline**, in the one format the model declares:
50
+
51
+ | Format | Shape | Used by | Generated by |
52
+ |---|---|---|---|
53
+ | ObjectId-hex | 24 lowercase hex characters (`^[0-9a-f]{24}$`) | every model unless declared otherwise — existing models and projects change nothing | the client, as in MongoDB's own model where the driver creates the id — but with a random tail: 4 timestamp bytes plus 8 bytes from a secure random source, fresh for every id (see below); not a driver's default `new ObjectId()` |
54
+ | uuidv7 | 36 characters, `xxxxxxxx-xxxx-7xxx-[89ab]xxx-xxxxxxxxxxxx`, lowercase | a model declared with it — a choice for **new** models only | a uuidv7 generator (`crypto.randomUUID()` is v4) |
55
+
56
+ * **One format per model, never mixed** in a collection or table (decided 2026-10-29). An existing model does not
57
+ switch: that would mix both formats in its data.
58
+ * The server validates every id it receives against the model's format and refuses anything else (`INVALID_ID`, §7).
59
+ It never rewrites a client id.
60
+ * Records the server creates itself (a create through the normal API) get the model's format. Server-generated
61
+ ObjectIds are predictable from an observed one, so a client could occupy the next ones; on a sync model the server
62
+ therefore generates a fresh id and retries when its own id is taken, and a normal create never fails on that.
63
+ * Ids of models that are not syncable keep today's rule: ObjectId-hex only.
64
+ * **A client's next ids must not be predictable.** A uuidv7 carries 74 random bits by construction. A driver's default
65
+ ObjectId does not: its per-process value and counter make every next id of a client predictable from one it has
66
+ created, so a member of the same tenant who sees one could create those ids first, and the client's own create
67
+ would then meet a record that is not its own. That is why a client fills the 8 bytes after an ObjectId's timestamp
68
+ from a secure random source (`crypto.getRandomValues`) for every id — still a valid ObjectId, as unguessable as a
69
+ uuidv7. The server cannot tell how an id was generated, so this rule is the client's. Beyond it, every access is
70
+ checked against the caller's rights, an id taken by a record the caller may not see answers `ID_UNAVAILABLE`, and
71
+ the push rate limit bounds probing. ObjectId stays the default; uuidv7 is a model's choice for new models.
72
+ * Records created offline may reference each other by these ids before any of them reached the server. Push them in
73
+ dependency order (§6.3) or accept that a reference may point to a record the server has not received yet — the server
74
+ checks a reference against the caller's rights only where the model declares that check.
75
+
76
+ How each database stores the formats: the id column is already a string column wide enough for both on PostgreSQL
77
+ (`text`) and MySQL / MariaDB (`varchar(255)`). On MongoDB an ObjectId-hex id is stored as an ObjectId and a uuidv7 id
78
+ as a BSON UUID (binary subtype 4, MongoDB's recommended UUID representation); the core keeps ids as strings, the store
79
+ converts at its boundary. References to a model use that model's representation. Because a collection holds one
80
+ format, casting, populate and sort never meet two representations side by side.
81
+
82
+ **A create whose id is already taken** by a record or tombstone the caller may see — inside their tenant scope, with the
83
+ model's pull roles, not hidden by `securityCheck` — is answered `conflict` with that record as the caller may see it (§6.5) — without comparing the stored values with `data`: an answer that depended on the comparison
84
+ would let the caller test guesses for fields hidden from them. Taken by a record or tombstone the caller may **not**
85
+ see, the item is refused with `ID_UNAVAILABLE` — the same answer,
86
+ same timing, for every reason an id cannot be used; it carries no tenant, owner, content or deletion state. That an id
87
+ is occupied stays observable — decided (O2, §15): ids stay unique across tenants, the push rate limit bounds probing,
88
+ and an id generated as above (uuidv7, or ObjectId-hex with its random tail) cannot be guessed in the first place.
89
+
90
+ ## 4. Requests and authentication
91
+
92
+ * **Base path** `/sync` (configurable). Model names in paths are the names the project registered for sync.
93
+ * **Tenant:** with `X-Tenant-Id` a request covers that one tenant (and requires an active membership in it); without the
94
+ header it spans every tenant the caller is an active member of.
95
+ * **Authentication:** a Better-Auth session cookie or a bearer token (JWT or session token), exactly as for every other
96
+ route. API tokens are refused on sync routes unless the project releases a sync scope for them.
97
+ * **Writes with cookie authentication** follow the rules of every writing endpoint — the session cookie is
98
+ `SameSite=lax`, credentialed CORS is answered for the trusted origins (for any origin under `cors.allowAll`, a
99
+ development setting) — and push additionally requires
100
+ `Content-Type: application/json` (which forces a CORS preflight; required of every push, `400` otherwise) and,
101
+ while the server uses cookies, refuses a request whose `Origin` is not a trusted origin or whose `Sec-Fetch-Site` is
102
+ `cross-site` (`403`). The trusted origins are those credentialed CORS answers plus the app's and the API's own. A
103
+ client without fetch metadata (a native app, a server) sends neither header and is not affected; a browser app
104
+ using a bearer token runs on a trusted origin anyway, or CORS would refuse it first.
105
+ * **Session expired while offline:** push answers `401` for the whole request before it looks at any item. Nothing is
106
+ applied and nothing is discarded; after signing in again the client sends the same items with the same mutation ids
107
+ and gets no duplicates (§6.5).
108
+ * **Size limits:** push accepts at most `maxPushItems` items (default 100) and the server's JSON body limit; pull pages
109
+ are bounded by count and by size (§5.2).
110
+ * **Rate limits:** pull and push have their own per-user limits; a refused request answers `429` with `Retry-After`.
111
+
112
+ ## 5. Pull
113
+
114
+ ### 5.1 Request
115
+
116
+ ```
117
+ GET /sync/{model}?cursor={cursor}&limit={n}
118
+ ```
119
+
120
+ | Parameter | Meaning |
121
+ |---|---|
122
+ | `cursor` | Absent: start from nothing (a full state, in pages). Present: continue after this cursor. |
123
+ | `limit` | Records per page, 1..`maxPullLimit` (default 200, maximum 1000). |
124
+
125
+ ### 5.2 Response
126
+
127
+ ```json
128
+ {
129
+ "items": [
130
+ { "id": "6720c0…", "version": 3, "updatedAt": "2026-11-02T09:14:03.120Z", "deletedAt": null, "data": { "title": "…" } },
131
+ { "id": "6720c1…", "version": 5, "updatedAt": "2026-11-02T09:15:44.002Z", "deletedAt": "2026-11-02T09:15:44.002Z" }
132
+ ],
133
+ "cursor": "c3luYzpub3RlOjE…",
134
+ "hasMore": true
135
+ }
136
+ ```
137
+
138
+ * `items` holds live records (with `data`, the record as the normal output pipeline returns it, minus `id`, `version`,
139
+ `updatedAt` and `deletedAt`) and tombstones (no `data`).
140
+ * A page ends at `limit` records **or** when its serialized size reaches `maxPullBytes` (default 1 MiB), whichever
141
+ comes first — a page always holds at least one item when one is pending. `hasMore` says whether to ask again
142
+ immediately.
143
+ * `cursor` is returned on **every** page and is a valid resume point: a client that crashes after storing page n and its
144
+ cursor continues with page n+1. Apply a page's items and store its cursor in one local transaction.
145
+ * **Ordering and duplicates.** Pages are ordered by the server's change order, not by `updatedAt`. A record may appear
146
+ more than once across pages (it changed again while the client was paging) — keep the item with the higher
147
+ `version`. Depending on the database the server runs on, a pull also repeats **unchanged** records (decided in
148
+ phase 2, P2-2): on MongoDB a run re-reads what changed within a window W before its cursor (on a replica set the
149
+ server's transaction lifetime — 60 s by default — plus 30 s, on a standalone 30 s; decided in DEV-3901), on SQL
150
+ databases it never does. Clients compare `version` and need not know which.
151
+ * Without a cursor the client receives every record and every unexpired tombstone it may see, then continues with the
152
+ last cursor like any delta. A client whose local storage was cleared (Safari removes the storage of a site not
153
+ installed after seven days without interaction) simply starts without a cursor.
154
+
155
+ ### 5.3 What a pull guarantees
156
+
157
+ * Every change committed before the pull started and visible to the caller is delivered in some page of the run that
158
+ starts at the given cursor — regardless of how many server instances wrote it, whether their clocks differ, whether
159
+ two changes carry the same timestamp, or whether a transaction committed after a younger one was already pulled.
160
+ The mechanism (decided in phase 2, P §3.4, measured on every target database): on SQL databases a change sequence
161
+ the server assigns in commit order, on MongoDB the server's clock with a window that a commit cannot outlast. The
162
+ cursor is opaque either way; a client never reads or compares it.
163
+ * No record and no tombstone of another tenant, and no record the caller's rights or `securityCheck` hide.
164
+
165
+ ## 6. Push
166
+
167
+ ### 6.1 Request
168
+
169
+ ```
170
+ POST /sync/{model}
171
+ Content-Type: application/json
172
+
173
+ { "items": [ … ] }
174
+ ```
175
+
176
+ ### 6.2 Item
177
+
178
+ ```json
179
+ { "mutationId": "0190f8b3-…", "op": "upsert", "id": "6720c0…", "baseVersion": 2, "data": { "title": "…" } }
180
+ { "mutationId": "0190f8b4-…", "op": "delete", "id": "6720c1…", "baseVersion": 5 }
181
+ ```
182
+
183
+ | Field | Meaning |
184
+ |---|---|
185
+ | `mutationId` | Unique per change, generated by the client (uuidv7). The same change sent again carries the same `mutationId`. |
186
+ | `op` | `upsert` (create or update) or `delete` (creates a tombstone; a hard delete is not reachable through sync). |
187
+ | `id` | The record's id (§3). |
188
+ | `baseVersion` | The `version` the client's change is based on. Absent **only** on the item that creates the record. Every later item carries the version the item before it produces — a create gives 1, each applied change adds 1 (§6.3) — so a record created and changed twice offline is pushed as the create, then `baseVersion: 1`, then `baseVersion: 2`. A client may instead fold all unsent changes of one record into a single item. |
189
+ | `data` | The fields to set, validated against the model's input type like any create/update. Absent for `delete`. |
190
+ | `fields` | Optional, for `merge`: the field names the client changed. Without it, every key in `data` counts as changed. |
191
+
192
+ ### 6.3 Processing
193
+
194
+ * Items are processed **in the order sent**, each in its own atomic step. A batch is not a transaction: on a database
195
+ without transactions (MongoDB standalone) it could not be one, so it is not one anywhere.
196
+ * An item referencing a record created earlier in the same batch sees that record.
197
+ * `upsert` without `baseVersion` on an id that does not exist creates the record with `version: 1`.
198
+ * `upsert` with `baseVersion` is an **atomic conditional change**: it applies only when the stored version still equals
199
+ `baseVersion` (`WHERE id = … AND version = …` or the MongoDB equivalent; never read-then-write), and it increments
200
+ `version`.
201
+ * `delete` with a matching `baseVersion` turns the record into a tombstone through the normal delete pipeline (the
202
+ model's delete rights, `securityCheck`, audit apply). Deleting a tombstone again is answered `applied`. A delete through
203
+ any other route of the server leaves a tombstone too, so clients learn of it the same way.
204
+ * An `upsert` on a tombstone is a conflict, never a silent resurrection.
205
+
206
+ ### 6.4 Conflicts
207
+
208
+ A conflict is a `baseVersion` that no longer matches. What happens is the model's (or field's) strategy:
209
+
210
+ | Strategy | Result |
211
+ |---|---|
212
+ | `reject` (default) | Nothing changes; status `conflict` with the server's record. The client decides. |
213
+ | `lww` | The change is applied on top of the current state. "Last" means **last to arrive at the server**, never the device's clock. Status `applied`. |
214
+ | `merge` | Field by field: fields the client changed overwrite, fields only the server changed stay. Fields declared `reject` still conflict. Status `merged` with the resulting record. |
215
+ | `custom` | A project hook receives the server record, the client change and the base version and returns the result or a refusal; it runs inside the same pipeline. |
216
+
217
+ ### 6.5 Repeats and parallel tabs
218
+
219
+ * A repeated create is recognised by its id; a repeated change by its `mutationId`. The server records the outcome of
220
+ every mutation id for `mutationRetention` (default 30 days) and answers a repeat with the recorded status and the
221
+ current record — whether the first attempt's response was lost or the client retried after a login. When two tabs
222
+ send the same change at once, one is applied and the other is answered `retry` while the first is still in progress;
223
+ sent again, it receives the recorded outcome. Neither a duplicate nor a false conflict results.
224
+ * After `mutationRetention` the server no longer recognises the mutation id. A repeated create meets its own record
225
+ and is answered `conflict` with it (§3); the client compares it with its local copy and resolves it like any
226
+ conflict. A repeated change meets its own effect as a version mismatch, and the strategy decides: `reject` answers
227
+ `conflict` with the server's record, which already contains the change; `lww` writes the same values again; `merge`
228
+ answers `merged`. Never a duplicate record.
229
+ * Tabs sharing one IndexedDB should still coordinate (Web Locks API or a `BroadcastChannel` leader) to avoid needless
230
+ traffic; correctness does not depend on it.
231
+
232
+ ### 6.6 Response
233
+
234
+ `200` with one result per item, in the order sent:
235
+
236
+ ```json
237
+ {
238
+ "results": [
239
+ { "mutationId": "0190f8b3-…", "id": "6720c0…", "status": "applied", "version": 3 },
240
+ { "mutationId": "0190f8b4-…", "id": "6720c1…", "status": "conflict", "version": 7, "record": { … } },
241
+ { "mutationId": "0190f8b5-…", "id": "6720c9…", "status": "rejected", "code": "VALIDATION_FAILED", "details": [ … ] }
242
+ ]
243
+ }
244
+ ```
245
+
246
+ ## 7. Item statuses and codes
247
+
248
+ | Status | Meaning | Client action |
249
+ |---|---|---|
250
+ | `applied` | The change is stored (also for a recognised repeat). `version` is the new version. | Store `version`; mark the change sent. |
251
+ | `merged` | Stored after a field merge; `record` is the result. | Replace the local record. |
252
+ | `conflict` | Not stored; `record` is the server's current record (a tombstone when it was deleted). | Resolve, then push again with `baseVersion` = `record.version`, or drop the change. |
253
+ | `rejected` | Not stored, for the reason in `code`. Retrying the same change will not help. | Show or log; drop or correct the change. |
254
+ | `not_found` | An update or delete for a record that does not exist **or that the caller may not see** — the same answer for both. | Drop the local record. |
255
+ | `retry` | Not stored for a transient reason (a lock timeout, a store outage, the same `mutationId` still in progress). | Send the same item again later. |
256
+
257
+ | `code` (with `rejected`) | Meaning |
258
+ |---|---|
259
+ | `VALIDATION_FAILED` | `data` failed input validation; `details` lists the fields. |
260
+ | `FORBIDDEN` | The caller may not change this record or these fields. |
261
+ | `INVALID_ID` | The id does not match the model's id format (§3). |
262
+ | `ID_UNAVAILABLE` | The id cannot be used (§3); no further detail by design. |
263
+ | `CUSTOM_REFUSED` | The `custom` conflict hook refused the change. |
264
+
265
+ ## 8. Resync
266
+
267
+ When the server cannot continue a cursor consistently, pull answers:
268
+
269
+ ```
270
+ 410 Gone
271
+ { "code": "SYNC_RESYNC_REQUIRED", "reason": "horizon" }
272
+ ```
273
+
274
+ | `reason` | Cause |
275
+ |---|---|
276
+ | `horizon` | The cursor is older than the tombstone retention horizon; deletions in between may be gone. |
277
+ | `access` | The caller's access changed since the cursor was issued while the caller may still pull the model: a tenant membership removed (on a pull without `X-Tenant-Id`), a role changed, a record moved to another tenant or visibility scope. Once the caller may not pull the model at all, the pull answers `403` instead (§10). |
278
+ | `invalid` | The cursor is malformed, was issued for another model or user, or for an incompatible sync configuration. |
279
+
280
+ **Client procedure:** keep the unsent local changes, delete every local record of that model, pull without a cursor
281
+ until `hasMore` is `false`, then push the kept changes (they meet the new state through the normal conflict rules).
282
+ After an `access` resync records the client may no longer see are gone, which is the point. The server forces this
283
+ resync rather than sending a removal per record; a per-record signal may follow later.
284
+
285
+ ## 9. Hints
286
+
287
+ Hints tell a connected client that pulling is worthwhile. They are optional: sync works with pull alone, triggered at
288
+ start, on the `online` event and on focus. The server never assumes a client syncs in the background (the Background
289
+ Sync API exists only in Chromium).
290
+
291
+ * **SSE** for every web client: `GET /sync/hints?models=note,task` with `Accept: text/event-stream`, authenticated like
292
+ any request (`models` optional: all models without it; a name that is not offered never matches). Events:
293
+ `event: change` with `data: {"model":"note","at":"2026-11-02T09:15:44.002Z"}`; a comment line every 25 seconds keeps
294
+ proxies from closing the stream, and `retry: 3000` tells the EventSource how soon to reconnect. A signed-in caller
295
+ always gets the stream (`200`); models the caller may not pull simply never send a hint, so the stream reveals
296
+ nothing about which models exist. `404` when the server switched the SSE channel off.
297
+ * **GraphQL subscription** for clients that use GraphQL: `subscription { syncHint(models: ["note"]) { model at } }`.
298
+ * A hint carries the model name and a time — no record, no id, no tenant. It is sent only to subscribers who hold the
299
+ model's pull roles and may read the changed tenant's records of that model; bursts are collapsed (default 250 ms).
300
+ * Both channels are long-lived, so the server checks them again during their lifetime like its GraphQL WebSockets: a
301
+ channel whose user lost access, signed out or changed roles is closed; the client reconnects and is checked anew. An
302
+ SSE stream also ends before its first hint after the re-check interval (30 s by default) and is reconnected — a hint
303
+ is never delivered on rights older than that.
304
+ * On reconnect a client pulls once, because hints sent while it was disconnected are not replayed.
305
+
306
+ ## 10. HTTP errors
307
+
308
+ | Status | When |
309
+ |---|---|
310
+ | `400` | Malformed body or query, unknown `op`, more than `maxPushItems` items. |
311
+ | `401` | Not authenticated or session expired. Nothing was applied. |
312
+ | `403` | On a pull: the caller may not pull the model (pull roles withdrawn), or `X-Tenant-Id` names a tenant they left or that was deactivated — access loss like `410 access`: delete that model's (and tenant's) local records and cursor; the unsent changes cannot be pushed under this account (§11). On a push: a cookie-authenticated push failing the origin rules. A push item lacking the operation's roles is `rejected` / `FORBIDDEN` (§7). |
313
+ | `404` | The model is not registered for sync. |
314
+ | `410` | Resync required (§8). |
315
+ | `413` | The body exceeds the server's limit. |
316
+ | `429` | Rate limit; `Retry-After` says when to try again. |
317
+ | `503` | A store needed for the request is unavailable; retry later. |
318
+
319
+ Error bodies use the framework's error-code format, sync codes in `#LTNS_0700`–`#LTNS_0799` (`SYNC_RESYNC_REQUIRED`,
320
+ `SYNC_MODEL_NOT_FOUND`, `SYNC_INVALID_REQUEST`, `SYNC_ORIGIN_REFUSED`, `SYNC_STORE_UNAVAILABLE`; 429 is
321
+ `RATE_LIMIT_EXCEEDED`). The `410` body is always the one §8 shows, whatever exception filter the project registered.
322
+
323
+ ## 11. Client duties
324
+
325
+ * Sync at start, on `online`, on focus, and after a hint; back off exponentially on `429`, `503` and network errors.
326
+ * Generate ids in the model's format with the random part §3 requires, and mutation ids, locally; persist unsent changes with their mutation ids until a
327
+ terminal status (`applied`, `merged`, `conflict`, `rejected`, `not_found`) arrives.
328
+ * Give every queued change of one record the `baseVersion` the change before it produces (§6.2), or fold them into one
329
+ item; push them in that order.
330
+ * Store each pulled page and its cursor atomically; compare `version` before overwriting a local record.
331
+ * **On logout delete every local record, the cursors and the unsent changes**, and do the same before another user's
332
+ first pull. The device holds personal data outside the server; the sync module's `SECURITY.md` lists what to delete
333
+ and how to keep the credential out of reach.
334
+ * **On `410 access` and on `403` to a pull, delete the local records of that model** (and tenant) and the cursor — the
335
+ caller lost access, and the device must not keep what the server would no longer send (§8, §10, the module's
336
+ `SECURITY.md`).
337
+ * Treat a cleared storage as a fresh start (no cursor).
338
+
339
+ ## 12. What the server guarantees
340
+
341
+ * Pull and push run through the same `@Restricted`, `securityCheck`, tenant and output pipeline as every other
342
+ request, with the operation roles the model declares for sync (§1); hints reach only callers with the pull roles.
343
+ * A pull never delivers a record or tombstone of another tenant; a push never changes one. An update or delete of it
344
+ answers `not_found` like an unused id, a create on its id `ID_UNAVAILABLE` — the same answer for every reason, with
345
+ nothing about the record; that an id is taken stays observable by design (§3, decided O2, §15).
346
+ * Tombstones carry no content field. On a model whose records only certain users may see, the tombstone keeps the
347
+ user-id fields the model declares as its scope (e.g. `ownerId`, decided O1, §15), so it reaches exactly the users who
348
+ could see the record — never the rest of the tenant.
349
+ * Hard deletes are not reachable through sync.
350
+ * Changes are never lost by the cursor (§5.3) and never duplicated by retries (§6.5).
351
+ * Idempotency records and rate-limit counters live in infrastructure stores with an explicit expiry condition that
352
+ every reader checks itself — a TTL index (MongoDB) or the expiry sweep (SQL) only removes what has expired. The
353
+ retention horizon never expires. Tombstones older than `sync.retention.tombstonesDays` (90 days) are removed every
354
+ hour under a lease, after the horizon moved past them.
355
+
356
+ ## 13. Compatibility
357
+
358
+ Without a sync configuration nothing changes: no field, index, route or code path is added, and the test suite of a
359
+ project stays as it is.
360
+
361
+ ## 14. Requirement trace
362
+
363
+ Every requirement of DEV-3819 and where this document meets it (P = `.claude/plans/offline-sync/PLAN.md`).
364
+
365
+ | Requirement (DEV-3819) | Where |
366
+ |---|---|
367
+ | HTTP with JSON, fully documented, client needs only `fetch` | §1, §4–§10 |
368
+ | No per-device server state; opaque client-owned cursor, one per device/tab | §1, §5.2 |
369
+ | Clients create ids in the server format, repeated create idempotent after days, format checked | §3, §6.5 |
370
+ | Id collision with a foreign record does not reveal it | §3, §7 (`ID_UNAVAILABLE`), decided O2 (§15) |
371
+ | Pull pages bounded by count and size; full state without cursor; resume after abort | §5.1, §5.2 |
372
+ | Cursor behind the tombstone horizon → clear "resync" (410) | §8 |
373
+ | Optional, exchangeable hint channel: GraphQL subscription and SSE; pull-only works | §9 |
374
+ | Conflict strategies `reject` / `lww` / `merge` / `custom`; LWW by server arrival order | §6.4 |
375
+ | Cookie and bearer auth; cookie push under the CSRF/origin rules of all writes | §4 |
376
+ | Expired session while offline → 401, nothing discarded, resend without duplicates | §4, §6.5 |
377
+ | Parallel pushes from several tabs: no duplicates, no false conflicts | §6.5 |
378
+ | Cleared browser storage starts cleanly | §5.2, §11 |
379
+ | No background push assumed; sync on start, `online`, focus | §9, §11 |
380
+ | Only the `DataStore` port and the neutral query language; differences via `store.capabilities` | P §3.1, P §3.3 |
381
+ | `version`, `deletedAt`, cursor field and index from model metadata; SQL schema evolution adds them | P §3.2, P §3.6 |
382
+ | Optimistic lock as an atomic conditional change, port operation with contract test | §6.3, P §3.3 (E1) |
383
+ | Tombstones filtered from all normal reads incl. `count`, `distinct`, populate | P §3.3 (E2) |
384
+ | Unique indexes do not fail on tombstones (partial; MySQL via generated column) | P §3.3 (E3), P §3.6 |
385
+ | Cursor without loss across instances, clocks, equal timestamps, late commits; prototype decides | §5.3, P §3.4, P phase 2 |
386
+ | Push item-atomic; batch transaction only with `capabilities.transactions`, not the default | §6.3 |
387
+ | Hints emitted by the store after every successful write, via in-process / Redis pub/sub; no change feed (P2-5, §15) | §9, P §3.9 |
388
+ | Idempotency keys, rate-limit counters, retention horizon in infrastructure stores with explicit expiry; cron sweep under `LockStore` | §12, P §3.7 |
389
+ | Every pull/push through the full pipeline; no `pullLean` | §1, §12 |
390
+ | Tombstones carry `tenantId` and only `id`, `version`, `deletedAt`, `updatedAt` | §2, §12; plus declared user-id scope fields, decided O1 (§15); the server keeps the audit fields too (DEV-3900), a pull sends none |
391
+ | Hard delete not reachable through sync | §6.2, §12 |
392
+ | Access loss → client deletes local copies; at least a forced full resync | §8 (`410 access`), §10 / §11 (`403` on a pull) |
393
+ | Integration doc says what a client deletes on logout | §11, the module's `SECURITY.md` and its checklist |
394
+ | Acceptance: the same contract suite green on MongoDB standalone and replica set, PostgreSQL × three engines (and MySQL, MariaDB) | `tests/sync-contract.e2e-spec.ts` (framework repository) |
395
+ | Acceptance: no lost change with parallel writers on two instances with skewed clocks | `tests/sync-clock-skew.e2e-spec.ts` |
396
+ | Acceptance: PWA test client in a browser, and a client without one, pass the same scenarios | `tests/sync-pwa-client.e2e-spec.ts`, `tests/sync-node-client.e2e-spec.ts` |
397
+ | Without sync configuration nothing changes | §1, §13 |
398
+ | Decisions 2026-10-29: both id formats, full resync on access loss, SSE + GraphQL, test clients, prototype decides the cursor | §3, §8, §9, P §11 |
399
+
400
+ ## 15. Open questions
401
+
402
+ | # | Question | Decided by | Notes |
403
+ |---|---|---|---|
404
+ | P2-1 | Cursor mechanism: change sequence with a commit horizon, or overlap window with `version` comparison | **decided 2026-10-31 (DEV-3899, measured on all seven targets):** SQL — a commit-ordered sequence, the counter taken last (no horizon needed); MongoDB — the server clock with a window W derived from the transaction lifetime | P §3.4, P §5 |
405
+ | P2-2 | Whether a page may repeat an unchanged record | **decided 2026-10-31 (DEV-3899):** on MongoDB yes, within W before the cursor; on SQL no | Clients compare `version` either way (§5.2) |
406
+ | P2-3 | MongoDB collections holding ObjectId and string `_id` side by side: casting, populate, sort, references | **dropped 2026-10-29 (owner, DEV-3899):** one id format per model, never mixed — the question cannot arise | §3 |
407
+ | P2-4 | Partial unique index on MongoDB with `{ deletedAt: { $type: 'null' } }`, on MySQL / MariaDB through virtual live-key columns (both refuse a plain partial unique index) | **decided 2026-10-31 (DEV-3899, measured):** MongoDB filters live records with `{ deletedAt: null }` (accepted on 5.0, 7.0, 8.2, covers a missing field, used by the matching query); PostgreSQL keeps `NULLS NOT DISTINCT` with `WHERE deleted_at IS NULL`; MySQL / MariaDB use the virtual live-key column. Every target refuses a second live value and a second live `NULL` and lets tombstones repeat a value | P §3.6, P §5 |
408
+ | P2-5 | Whether `capabilities.changeFeed` (change streams on a replica set, `LISTEN/NOTIFY` on PostgreSQL) is worth it for hints, measured against CrudService-emitted events | **decided 2026-10-31 (DEV-3899):** no. A feed would add hints only for writes that bypass the store. Those reach a client with its next pull at the earliest (on SQL the sequence trigger numbers them too, on MongoDB they carry no cursor field and are not pulled at all — sync models are written through the store), and hints are optional by design. The store path is needed on MySQL, MariaDB and standalone MongoDB regardless. One source, the store's write events | §9, P §3.9 |
409
+ | O1 | Tombstones on per-user-visibility models | **decided 2026-10-29 (owner):** a tombstone keeps the user-id fields the model declares as its scope (`syncScope`, e.g. `ownerId`) — only user ids, never content | §2, §12; otherwise a tombstone either reaches the whole tenant (ids and deletion times of other users' records) or nobody (a deletion never reaches the owner's other devices) |
410
+ | O2 | Id collision with a record the caller may not see | **decided 2026-10-29 (owner):** the uniform `ID_UNAVAILABLE` stays, ids stay unique across tenants | §3; ids unique per tenant would change every lookup, reference and route by id in the framework |
@@ -89,6 +89,20 @@ overrides:
89
89
  '@graphql-tools/utils@>=12.0.0 <12.1.0': '12.1.0'
90
90
  ```
91
91
 
92
+ ### Advisories no override can fix yet: `minimatch` and `glob` (2026-10-31)
93
+
94
+ [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249) (minimatch `< 11.0.1`, moderate, DoS
95
+ through globparts amplification) and [GHSA-3g3m-2fjr-6pw4](https://github.com/advisories/GHSA-3g3m-2fjr-6pw4)
96
+ (glob `<= 14.0.1`, moderate) are fixed only in new majors that no consumer can take yet: `@nestjs/cli` 12.0.11
97
+ pins minimatch 10.2.6 exactly, ts-morph 28 (`@ts-morph/common` 0.29), nodemon, `@swc/cli` and glob 13 need the 9.x /
98
+ 10.x lines, rimraf 6.1.3 needs glob 13 — all on their newest release. An override across the major is no way out
99
+ (minimatch 11 removed `makeRe()` and `Minimatch#regexp`). nest-server suppresses both in its own repository with
100
+ `auditConfig.unusableFixConsumers`, which `check:overrides` re-tests on every run. What it found for its own tree:
101
+ the only production path is `@lenne.tech/nest-server > ts-morph > @ts-morph/common > minimatch`, used by the
102
+ Permissions module's scanner with globs the developer writes; every other path is dev tooling. Your project's tree
103
+ may have other paths, so check `pnpm why minimatch --prod` and `pnpm why glob --prod` before you suppress the same
104
+ way, and remove the entries once your consumers move to minimatch 11 / glob 14.
105
+
92
106
  ### Only on nest-server 11.x: `ws` and `js-yaml`
93
107
 
94
108
  `@nestjs/graphql` 13 and `@nestjs/swagger` 11 exact-pin vulnerable versions of these two; their 12.x
@@ -10,7 +10,7 @@ working once it declares them itself — which a starter-based project already d
10
10
  **PostgreSQL**, in **MySQL 8.4 / MariaDB 11.8** (§7), or in several of them, model by model. The framework's own
11
11
  modules — users, Better-Auth, tenants, API tokens, the AI module, file metadata, locks, migrations, health check, Hub —
12
12
  run on MongoDB and PostgreSQL behind one `CrudService` and one security pipeline, and the whole e2e suite runs on both;
13
- on MySQL and MariaDB everything but Better-Auth runs, proven by the parity matrices. A project that stays on MongoDB changes nothing but
13
+ on MySQL and MariaDB everything runs too, proven by the parity matrices — Better-Auth since 12.1.1. A project that stays on MongoDB changes nothing but
14
14
  its dependency list.
15
15
 
16
16
  ## Overview
@@ -165,8 +165,13 @@ documentation:
165
165
  - **No row-level security.** Tenant isolation is the store's (the before-layer and the row check after every read).
166
166
  A query through `store.native()`, `store.raw()` or a connection of the project's own reaches every tenant's rows
167
167
  unless it filters by `tenantId` itself; the boot warns once per source.
168
- - **Better-Auth is refused at boot** while the user model (`users`) lives on MySQL / MariaDB. Keep it on PostgreSQL or
169
- MongoDB (`database.models: { User: '<source>' }`), or sign in through the legacy endpoints only.
168
+ - **Better-Auth runs there since 12.1.1** (12.1.0 refused it at boot while the user model lived on MySQL / MariaDB).
169
+ Its Kysely adapter runs on the source's `mysql2` pool, its tables are created by its own migrator as the owner, under
170
+ the schema lock — in the framework's collation `utf8mb4_bin`: the database's default is switched while Better-Auth
171
+ creates a table and restored afterwards, because MariaDB refuses a foreign key from Better-Auth's tables to `users.id`
172
+ across collations. The switch locks every table, so it runs only on a boot that creates one and waits at most five
173
+ seconds behind an open transaction (the boot then fails and the next one retries).
174
+ Better-Auth's own column types apply (`timestamp(3)`, `varchar(36)` ids). Nothing to change in the project.
170
175
  - **An indexed text field holds at most 255 characters** (a longer value answers 400). Indexing an existing text field
171
176
  later is a type change — a reviewed SQL migration. A lookup index over four or more text fields indexes a prefix of
172
177
  each; such a unique index is refused.
@@ -234,7 +239,7 @@ graphql-upload 17 accepts `@types/express` 4 and 5.
234
239
  | Boot refuses the application role | Superuser, `BYPASSRLS`, owner of a framework table, or `TRUNCATE` right | Use a role that owns nothing; the owner goes into `ownerUrl` |
235
240
  | `X.find() reaches MongoDB, but X is stored on postgres` | Code calls the Mongoose model of a model routed to SQL | Use `this.dataStore` / `@InjectStore()` |
236
241
  | 403 on a tenant read on PostgreSQL | No tenant scope for a tenant-scoped table (RLS) | Run inside a request with a tenant, or as system work (`RequestContext.runWithBypassTenantGuard()`) |
237
- | `Better-Auth cannot run on MySQL yet` at boot | The user model is routed to a MySQL / MariaDB source | Route `User` to PostgreSQL or MongoDB, or switch Better-Auth off |
242
+ | `Better-Auth cannot run on MySQL yet` at boot | 12.1.0 with the user model routed to a MySQL / MariaDB source | Update to 12.1.1, or route `User` to PostgreSQL or MongoDB |
238
243
  | `The application account … holds ALTER, …` at boot | `database.url` connects as an account with schema rights | Connect as a DML-only account; the owner goes into `ownerUrl` |
239
244
  | `Updating the path "…" would create a conflict` on MySQL | Two update operators on one path | One operator per path, as on MongoDB |
240
245
  | 400 `a value is longer than its column allows` on MySQL | A text field in an index got more than 255 characters | Shorten the value, or leave the field out of the index |
@@ -0,0 +1,125 @@
1
+ # Migration Guide: 12.1.0 → 12.1.1
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
+ | **Bugfixes** | Multi-replica fixes: a BullMQ cron schedule lost in Redis is registered again (§1), a cron tick no longer runs twice when replicas fire on either side of a second boundary and `runParallel: false` holds across replicas (§2), Redis channels are subscribed again after an outage (§3), a withdrawn Better-Auth role stops working on every replica at once (§4). `synchronizedUp()` works (it threw on every call) (§5) |
8
+ | **New Features** | Better-Auth on MySQL 8.4 and MariaDB 11.8 (§6). The offline-sync protocol is specified (`docs/offline-sync-protocol.md`, not implemented yet) |
9
+ | **Framework API** | `CoreRedisService.isReady()`, `subscribeChannel()` / `releaseChannel()`; `SynchronizedUpOptions`; protected hooks on `CoreCronJobs` (`bullJobOptions`, `restoreBullSchedulers()`, `bullSchedulerCheckIntervalMs`, …); `CoreBetterAuthUserMapper` implements `OnApplicationBootstrap` / `OnModuleDestroy` |
10
+ | **Dependencies** | `nodemailer` 10.2.1 → 10.3.0. Tested optional peers raised: `bullmq` 6.5.0 → 6.5.1, `@aws-sdk/client-s3`, `@aws-sdk/lib-storage`, `@aws-sdk/s3-request-presigner` 3.1158.0 → 3.1159.0 — `pnpm run update` raises them where a project pins them |
11
+ | **Breaking Changes** | None |
12
+ | **Migration Effort** | None for most projects. Check §2 for cron jobs with `runParallel: false`, §3 if your code subscribes to Redis channels itself, §4 if you subclass `CoreBetterAuthUserMapper` |
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm add @lenne.tech/nest-server@12.1.1
18
+ pnpm run update # nest-server-starter based projects: aligns the pins the framework raised
19
+ pnpm run build && pnpm test
20
+ ```
21
+
22
+ Vendor-mode projects pick it up with the core update (`/lt-dev:backend:update-nest-server-core`).
23
+
24
+ ## 1. A BullMQ cron schedule lost in Redis comes back
25
+
26
+ With `redis` configured and `bullmq` installed, cron jobs run as BullMQ job schedulers. The schedule was written once
27
+ at bootstrap, so a Redis restart without persistence, a `FLUSHDB` or an eviction removed it, and no replica ran the
28
+ job again until the next deploy. `CoreCronJobs` now registers the schedules the queue no longer lists — after every
29
+ reconnect of the queue connection and every `bullSchedulerCheckIntervalMs` (protected, 60000 ms; `0` switches only the
30
+ timer off) — and logs a warning naming each one. A schedule removed on purpose stays removed, also while replicas of
31
+ the previous version are still running. A tick that fell due while the schedule was missing is not run.
32
+
33
+ ## 2. Cron jobs across replicas
34
+
35
+ **The tick lease is keyed on the scheduled instant.** A job on a local timer took its lease under the time its timer
36
+ fired. Two replicas firing a few milliseconds apart on either side of a second boundary computed different keys and
37
+ both ran the tick. The key is now the tick's scheduled instant; a manual run (the Hub's "Run now") gets a key of its
38
+ own.
39
+
40
+ **`runParallel: false` holds across replicas.** It used to hold within one process only, so a long run on replica A
41
+ did not stop replica B from starting the next tick. A distributed job with `runParallel: false` (or `1`) now holds a
42
+ `<job>:running` lease for the duration of the run (60 s, renewed every 20 s, released when the run ends) — on Redis,
43
+ else the SQL lease table, else `cron-locks` — and a replica that finds it held skips its tick. Like the tick lease it
44
+ fails open: an unreachable lease store runs the tick.
45
+
46
+ **What to check:** a run that never ends now holds the job on **every** replica while its process lives, not only on
47
+ its own. If a job of yours can hang, give it a timeout.
48
+
49
+ **BullMQ jobs no longer pile up in Redis.** Scheduler jobs carry `attempts: 1`, `removeOnComplete: { count: 100 }` and
50
+ `removeOnFail: { count: 500 }` (protected `bullJobOptions`); before, every completed and failed tick stayed in Redis.
51
+ The first bootstrap after the update removes the backlog an earlier version kept, oldest first, in batches — a tool
52
+ that reads old job history from Redis (Bull Board and the like) shows the last 100 / 500 from then on.
53
+
54
+ Without `redis`, startup logs one info line naming the jobs that run on every replica because they do not set
55
+ `distributed` (silent with `log: false`). A failed TTL index on `cron-locks` is retried on the next tick.
56
+
57
+ ## 3. Redis outages
58
+
59
+ `CoreRedisService.isReady()` says whether the shared connection is up (`enabled` only says Redis is configured). At
60
+ boot the service pings once (3 s) and warns when Redis does not answer, then boots anyway.
61
+
62
+ Channels on the shared subscriber now go through `subscribeChannel(channel)` / `releaseChannel(channel)`, counted per
63
+ holder, and are subscribed again on every reconnect. ioredis re-subscribes only what once succeeded, so a subscribe
64
+ that failed at boot left that replica deaf for its whole life — the framework's own tenant-cache invalidation was one
65
+ such channel.
66
+
67
+ **What to check:** code of your own that calls `getSubscriber().subscribe(…)` keeps working, but is not re-subscribed
68
+ after a failed first attempt. Switch to `subscribeChannel()` / `releaseChannel()` for that.
69
+
70
+ GraphQL subscriptions and TUS upload locks still have no fallback while Redis is down — see the "Central Redis" row
71
+ of `.claude/rules/configurable-features.md`.
72
+
73
+ ## 4. Better-Auth roles across replicas
74
+
75
+ `CoreBetterAuthUserMapper` caches a user's roles and verification state for 15 s. `invalidateUserCache()` cleared
76
+ that cache on the replica that called it only, so a withdrawn role kept working on the others until the entry
77
+ expired. With `redis` configured the invalidation is now broadcast to every replica, which also re-checks the user's
78
+ open WebSockets there.
79
+
80
+ **What to check:** a subclass of `CoreBetterAuthUserMapper` that implements `onApplicationBootstrap()` or
81
+ `onModuleDestroy()` must call `super`, or the broadcast is neither subscribed nor released.
82
+
83
+ ## 5. `synchronizedUp()`
84
+
85
+ `synchronizedUp()` threw on every call until 12.1.0: it called `up` on the loaded migration state, which has none. It
86
+ now runs the `MigrationRunner` — the same runner as `migrate up` — under the state store's lock, takes
87
+ `migrationsDirectory` (default `migrations` in the working directory, as for the CLI) and `pattern`, and refuses a
88
+ `MongoStateStore` without `lockCollectionName` rather than running unsynchronized. A relative `migrationsDirectory`
89
+ given to `MigrationRunner` is resolved against the working directory (it failed with `MODULE_NOT_FOUND` before).
90
+
91
+ ## 6. Better-Auth on MySQL and MariaDB
92
+
93
+ 12.1.0 refused to boot with the user model on a MySQL or MariaDB source while Better-Auth was enabled. It now runs
94
+ there: Better-Auth's Kysely adapter on the source's `mysql2` pool, its migrations applied as the owner under the
95
+ database's `GET_LOCK`. Better-Auth names no collation, and MariaDB refuses a foreign key to `users.id` across
96
+ collations, so while it creates a table the framework switches the database's default collation to `utf8mb4_bin` and
97
+ restores it afterwards — only on such a run, with a 5 s lock wait, because `ALTER DATABASE` locks every table.
98
+
99
+ Nothing to do for a project on MongoDB or PostgreSQL.
100
+
101
+ ## Smaller fixes
102
+
103
+ - Upload file names from `multerRandomFileName()` come from `crypto.randomBytes()` instead of `Math.random()`.
104
+ - The Hub's log and trace panels answer an empty page with the cursor unchanged when the shared Redis buffer cannot
105
+ be read, instead of the replica's local buffer under a cursor that belongs to the shared one.
106
+
107
+ ## Compatibility Notes
108
+
109
+ | Pattern | Status |
110
+ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
111
+ | Single replica, no Redis | Unchanged apart from the info line naming non-distributed cron jobs (§2) |
112
+ | `CronJobs extends CoreCronJobs` without overrides | Unchanged |
113
+ | A cron job with `runParallel: false` on several replicas | Now exclusive fleet-wide (§2) |
114
+ | Own `getSubscriber().subscribe()` calls | Work; switch to `subscribeChannel()` for re-subscription (§3) |
115
+ | `CoreBetterAuthUserMapper` subclass with own lifecycle hooks | Call `super` (§4) |
116
+ | Better-Auth with users on MySQL / MariaDB | New — boots now (§6) |
117
+
118
+ ## Module Documentation
119
+
120
+ - [Migrate module](../src/core/modules/migrate/README.md)
121
+ - [Hub module](../src/core/modules/hub/README.md)
122
+ - [Better-Auth module](../src/core/modules/better-auth/README.md)
123
+ - Cron, Redis and data sources: `.claude/rules/configurable-features.md` (rows "Cron Job Deduplication", "Central
124
+ Redis", "Data Sources")
125
+ - [Offline-sync protocol (specification)](../docs/offline-sync-protocol.md)