@lenne.tech/nest-server 12.1.1 → 12.3.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 (317) hide show
  1. package/.claude/rules/architecture.md +1 -0
  2. package/.claude/rules/better-auth.md +31 -4
  3. package/.claude/rules/configurable-features.md +8 -6
  4. package/.claude/rules/package-management.md +29 -2
  5. package/.claude/rules/persistence.md +27 -1
  6. package/.claude/rules/testing.md +80 -15
  7. package/.claude/rules/versioning.md +13 -2
  8. package/CLAUDE.md +7 -1
  9. package/FRAMEWORK-API.md +20 -4
  10. package/README.md +20 -2
  11. package/dist/config.env.js +2 -2
  12. package/dist/config.env.js.map +1 -1
  13. package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +4 -0
  14. package/dist/core/common/helpers/graphql-ws-connection.helper.js +17 -3
  15. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
  16. package/dist/core/common/interfaces/server-options.interface.d.ts +49 -0
  17. package/dist/core/common/interfaces/service-options.interface.d.ts +3 -0
  18. package/dist/core/common/persistence/core-persistence.module.js +12 -2
  19. package/dist/core/common/persistence/core-persistence.module.js.map +1 -1
  20. package/dist/core/common/persistence/data-source.runtime.js +4 -2
  21. package/dist/core/common/persistence/data-source.runtime.js.map +1 -1
  22. package/dist/core/common/persistence/data-store.interface.d.ts +24 -9
  23. package/dist/core/common/persistence/id-format.d.ts +2 -0
  24. package/dist/core/common/persistence/id-format.js +14 -0
  25. package/dist/core/common/persistence/id-format.js.map +1 -1
  26. package/dist/core/common/persistence/model-descriptor.interface.d.ts +6 -0
  27. package/dist/core/common/persistence/model-metadata.registry.d.ts +2 -1
  28. package/dist/core/common/persistence/model-metadata.registry.js +20 -2
  29. package/dist/core/common/persistence/model-metadata.registry.js.map +1 -1
  30. package/dist/core/common/persistence/mongo/sync-schema.plugin.d.ts +18 -0
  31. package/dist/core/common/persistence/mongo/sync-schema.plugin.js +368 -0
  32. package/dist/core/common/persistence/mongo/sync-schema.plugin.js.map +1 -0
  33. package/dist/core/common/persistence/mongoose-data-store.d.ts +19 -9
  34. package/dist/core/common/persistence/mongoose-data-store.js +107 -31
  35. package/dist/core/common/persistence/mongoose-data-store.js.map +1 -1
  36. package/dist/core/common/persistence/persisted-model.decorator.d.ts +3 -0
  37. package/dist/core/common/persistence/persisted-model.decorator.js +9 -0
  38. package/dist/core/common/persistence/persisted-model.decorator.js.map +1 -1
  39. package/dist/core/common/persistence/policies/sync.policy.d.ts +17 -0
  40. package/dist/core/common/persistence/policies/sync.policy.js +77 -0
  41. package/dist/core/common/persistence/policies/sync.policy.js.map +1 -0
  42. package/dist/core/common/persistence/sql/mysql/mysql-data-store.d.ts +12 -8
  43. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js +82 -29
  44. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js.map +1 -1
  45. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.d.ts +10 -0
  46. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js +61 -4
  47. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js.map +1 -1
  48. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +101 -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-errors.d.ts +2 -1
  59. package/dist/core/common/persistence/sql/sql-errors.js +3 -1
  60. package/dist/core/common/persistence/sql/sql-errors.js.map +1 -1
  61. package/dist/core/common/persistence/sync-access.registry.d.ts +17 -0
  62. package/dist/core/common/persistence/sync-access.registry.js +8 -0
  63. package/dist/core/common/persistence/sync-access.registry.js.map +1 -0
  64. package/dist/core/common/persistence/sync-model.d.ts +29 -0
  65. package/dist/core/common/persistence/sync-model.js +138 -0
  66. package/dist/core/common/persistence/sync-model.js.map +1 -0
  67. package/dist/core/common/services/crud.service.d.ts +13 -1
  68. package/dist/core/common/services/crud.service.js +174 -16
  69. package/dist/core/common/services/crud.service.js.map +1 -1
  70. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +3 -0
  71. package/dist/core/modules/ai/core-ai-mcp.controller.js +29 -0
  72. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  73. package/dist/core/modules/ai/core-ai.constants.d.ts +2 -0
  74. package/dist/core/modules/ai/core-ai.constants.js +2 -0
  75. package/dist/core/modules/ai/core-ai.constants.js.map +1 -1
  76. package/dist/core/modules/ai/core-ai.controller.d.ts +6 -0
  77. package/dist/core/modules/ai/core-ai.controller.js +31 -1
  78. package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
  79. package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
  80. package/dist/core/modules/ai/core-ai.module.js +8 -1
  81. package/dist/core/modules/ai/core-ai.module.js.map +1 -1
  82. package/dist/core/modules/ai/core-ai.resolver.d.ts +6 -0
  83. package/dist/core/modules/ai/core-ai.resolver.js +32 -0
  84. package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
  85. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +65 -1
  86. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +6 -3
  87. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -1
  88. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +48 -1
  89. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  90. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.d.ts +9 -0
  91. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js +54 -0
  92. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js.map +1 -0
  93. package/dist/core/modules/ai/index.d.ts +7 -0
  94. package/dist/core/modules/ai/index.js +6 -0
  95. package/dist/core/modules/ai/index.js.map +1 -1
  96. package/dist/core/modules/ai/inputs/core-ai-settings.input.d.ts +3 -0
  97. package/dist/core/modules/ai/inputs/core-ai-settings.input.js +31 -0
  98. package/dist/core/modules/ai/inputs/core-ai-settings.input.js.map +1 -0
  99. package/dist/core/modules/ai/interfaces/ai-tool.interface.d.ts +2 -0
  100. package/dist/core/modules/ai/models/core-ai-settings.model.d.ts +4 -0
  101. package/dist/core/modules/ai/models/core-ai-settings.model.js +30 -0
  102. package/dist/core/modules/ai/models/core-ai-settings.model.js.map +1 -0
  103. package/dist/core/modules/ai/models/core-ai-user-setting.model.d.ts +8 -0
  104. package/dist/core/modules/ai/models/core-ai-user-setting.model.js +45 -0
  105. package/dist/core/modules/ai/models/core-ai-user-setting.model.js.map +1 -0
  106. package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +4 -1
  107. package/dist/core/modules/ai/services/core-ai-conversation.service.js +16 -4
  108. package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
  109. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +6 -6
  110. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
  111. package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +27 -2
  112. package/dist/core/modules/ai/services/core-ai-mcp.service.js +118 -11
  113. package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
  114. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js +9 -6
  115. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js.map +1 -1
  116. package/dist/core/modules/ai/services/core-ai-user-setting.service.d.ts +17 -0
  117. package/dist/core/modules/ai/services/core-ai-user-setting.service.js +72 -0
  118. package/dist/core/modules/ai/services/core-ai-user-setting.service.js.map +1 -0
  119. package/dist/core/modules/ai/services/core-ai.service.d.ts +8 -3
  120. package/dist/core/modules/ai/services/core-ai.service.js +68 -29
  121. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  122. package/dist/core/modules/better-auth/better-auth.config.js +3 -1
  123. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  124. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +2 -2
  125. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  126. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.d.ts +8 -0
  127. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js +64 -0
  128. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js.map +1 -0
  129. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -1
  130. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  131. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
  132. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +5 -3
  133. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  134. package/dist/core/modules/better-auth/core-better-auth.module.js +2 -0
  135. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  136. package/dist/core/modules/better-auth/index.d.ts +1 -0
  137. package/dist/core/modules/better-auth/index.js +1 -0
  138. package/dist/core/modules/better-auth/index.js.map +1 -1
  139. package/dist/core/modules/error-code/error-codes.d.ts +72 -0
  140. package/dist/core/modules/error-code/error-codes.js +64 -0
  141. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  142. package/dist/core/modules/migrate/sql-migration.helper.d.ts +15 -1
  143. package/dist/core/modules/migrate/sql-migration.helper.js +90 -5
  144. package/dist/core/modules/migrate/sql-migration.helper.js.map +1 -1
  145. package/dist/core/modules/sync/core-sync-base.controller.d.ts +7 -0
  146. package/dist/core/modules/sync/core-sync-base.controller.js +44 -0
  147. package/dist/core/modules/sync/core-sync-base.controller.js.map +1 -0
  148. package/dist/core/modules/sync/core-sync-cursor.d.ts +19 -0
  149. package/dist/core/modules/sync/core-sync-cursor.js +77 -0
  150. package/dist/core/modules/sync/core-sync-cursor.js.map +1 -0
  151. package/dist/core/modules/sync/core-sync-hints.controller.d.ts +4 -0
  152. package/dist/core/modules/sync/core-sync-hints.controller.js +55 -0
  153. package/dist/core/modules/sync/core-sync-hints.controller.js.map +1 -0
  154. package/dist/core/modules/sync/core-sync-hints.service.d.ts +28 -0
  155. package/dist/core/modules/sync/core-sync-hints.service.js +155 -0
  156. package/dist/core/modules/sync/core-sync-hints.service.js.map +1 -0
  157. package/dist/core/modules/sync/core-sync-infrastructure.models.d.ts +2 -0
  158. package/dist/core/modules/sync/core-sync-infrastructure.models.js +17 -0
  159. package/dist/core/modules/sync/core-sync-infrastructure.models.js.map +1 -0
  160. package/dist/core/modules/sync/core-sync-mutation.store.d.ts +43 -0
  161. package/dist/core/modules/sync/core-sync-mutation.store.js +88 -0
  162. package/dist/core/modules/sync/core-sync-mutation.store.js.map +1 -0
  163. package/dist/core/modules/sync/core-sync-state.store.d.ts +18 -0
  164. package/dist/core/modules/sync/core-sync-state.store.js +64 -0
  165. package/dist/core/modules/sync/core-sync-state.store.js.map +1 -0
  166. package/dist/core/modules/sync/core-sync.constants.d.ts +28 -0
  167. package/dist/core/modules/sync/core-sync.constants.js +23 -0
  168. package/dist/core/modules/sync/core-sync.constants.js.map +1 -0
  169. package/dist/core/modules/sync/core-sync.controller.d.ts +8 -0
  170. package/dist/core/modules/sync/core-sync.controller.js +79 -0
  171. package/dist/core/modules/sync/core-sync.controller.js.map +1 -0
  172. package/dist/core/modules/sync/core-sync.decorators.d.ts +12 -0
  173. package/dist/core/modules/sync/core-sync.decorators.js +28 -0
  174. package/dist/core/modules/sync/core-sync.decorators.js.map +1 -0
  175. package/dist/core/modules/sync/core-sync.module.d.ts +16 -0
  176. package/dist/core/modules/sync/core-sync.module.js +50 -0
  177. package/dist/core/modules/sync/core-sync.module.js.map +1 -0
  178. package/dist/core/modules/sync/core-sync.resolver.d.ts +9 -0
  179. package/dist/core/modules/sync/core-sync.resolver.js +46 -0
  180. package/dist/core/modules/sync/core-sync.resolver.js.map +1 -0
  181. package/dist/core/modules/sync/core-sync.service.d.ts +143 -0
  182. package/dist/core/modules/sync/core-sync.service.js +1237 -0
  183. package/dist/core/modules/sync/core-sync.service.js.map +1 -0
  184. package/dist/core/modules/sync/interfaces/core-sync.interfaces.d.ts +56 -0
  185. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js +2 -0
  186. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js.map +1 -0
  187. package/dist/core/modules/sync/models/core-sync-hint.model.d.ts +4 -0
  188. package/dist/core/modules/sync/models/core-sync-hint.model.js +30 -0
  189. package/dist/core/modules/sync/models/core-sync-hint.model.js.map +1 -0
  190. package/dist/core/modules/tenant/core-tenant.guard.js +14 -0
  191. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  192. package/dist/core/modules/tenant/core-tenant.service.d.ts +21 -1
  193. package/dist/core/modules/tenant/core-tenant.service.js +153 -1
  194. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  195. package/dist/core.module.js +14 -0
  196. package/dist/core.module.js.map +1 -1
  197. package/dist/index.d.ts +16 -0
  198. package/dist/index.js +16 -0
  199. package/dist/index.js.map +1 -1
  200. package/dist/server/modules/error-code/error-codes.d.ts +8 -0
  201. package/dist/server/modules/sync-note/inputs/sync-note-create.input.d.ts +4 -0
  202. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js +27 -0
  203. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js.map +1 -0
  204. package/dist/server/modules/sync-note/inputs/sync-note.input.d.ts +6 -0
  205. package/dist/server/modules/sync-note/inputs/sync-note.input.js +37 -0
  206. package/dist/server/modules/sync-note/inputs/sync-note.input.js.map +1 -0
  207. package/dist/server/modules/sync-note/sync-note.model.d.ts +10 -0
  208. package/dist/server/modules/sync-note/sync-note.model.js +73 -0
  209. package/dist/server/modules/sync-note/sync-note.model.js.map +1 -0
  210. package/dist/server/modules/sync-note/sync-note.module.d.ts +2 -0
  211. package/dist/server/modules/sync-note/sync-note.module.js +21 -0
  212. package/dist/server/modules/sync-note/sync-note.module.js.map +1 -0
  213. package/dist/server/modules/sync-note/sync-note.service.d.ts +10 -0
  214. package/dist/server/modules/sync-note/sync-note.service.js +35 -0
  215. package/dist/server/modules/sync-note/sync-note.service.js.map +1 -0
  216. package/dist/test/test.helper.d.ts +1 -0
  217. package/dist/test/test.helper.js +9 -2
  218. package/dist/test/test.helper.js.map +1 -1
  219. package/docs/REQUEST-LIFECYCLE.md +4 -2
  220. package/docs/error-codes.md +14 -0
  221. package/docs/offline-sync-protocol.md +111 -68
  222. package/docs/security-overrides.md +14 -0
  223. package/migration-guides/12.0.x-to-12.1.x.md +0 -1
  224. package/migration-guides/12.1.x-to-12.2.x.md +154 -0
  225. package/migration-guides/12.2.x-to-12.3.x.md +440 -0
  226. package/package.json +23 -21
  227. package/src/config.env.ts +6 -2
  228. package/src/core/common/helpers/graphql-ws-connection.helper.ts +32 -4
  229. package/src/core/common/interfaces/server-options.interface.ts +229 -8
  230. package/src/core/common/interfaces/service-options.interface.ts +15 -0
  231. package/src/core/common/persistence/core-persistence.module.ts +21 -2
  232. package/src/core/common/persistence/data-source.runtime.ts +5 -2
  233. package/src/core/common/persistence/data-store.interface.ts +65 -12
  234. package/src/core/common/persistence/id-format.ts +26 -0
  235. package/src/core/common/persistence/model-descriptor.interface.ts +12 -0
  236. package/src/core/common/persistence/model-metadata.registry.ts +31 -2
  237. package/src/core/common/persistence/mongo/sync-schema.plugin.ts +508 -0
  238. package/src/core/common/persistence/mongoose-data-store.ts +171 -35
  239. package/src/core/common/persistence/persisted-model.decorator.ts +26 -0
  240. package/src/core/common/persistence/policies/sync.policy.ts +121 -0
  241. package/src/core/common/persistence/sql/mysql/mysql-data-store.ts +108 -30
  242. package/src/core/common/persistence/sql/mysql/mysql-schema.compiler.ts +107 -4
  243. package/src/core/common/persistence/sql/mysql/mysql-schema.migrator.ts +129 -3
  244. package/src/core/common/persistence/sql/postgres/postgres-schema.compiler.ts +74 -2
  245. package/src/core/common/persistence/sql/postgres/postgres-schema.migrator.ts +139 -7
  246. package/src/core/common/persistence/sql/sql-data-store.ts +325 -44
  247. package/src/core/common/persistence/sql/sql-errors.ts +5 -1
  248. package/src/core/common/persistence/sync-access.registry.ts +50 -0
  249. package/src/core/common/persistence/sync-model.ts +270 -0
  250. package/src/core/common/services/crud.service.ts +257 -19
  251. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +36 -15
  252. package/src/core/modules/ai/README.md +163 -25
  253. package/src/core/modules/ai/core-ai-mcp.controller.ts +48 -1
  254. package/src/core/modules/ai/core-ai.constants.ts +6 -0
  255. package/src/core/modules/ai/core-ai.controller.ts +36 -1
  256. package/src/core/modules/ai/core-ai.module.ts +12 -0
  257. package/src/core/modules/ai/core-ai.resolver.ts +36 -0
  258. package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +35 -4
  259. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +68 -1
  260. package/src/core/modules/ai/helpers/ai-tool-authorization.helper.ts +99 -0
  261. package/src/core/modules/ai/index.ts +18 -0
  262. package/src/core/modules/ai/inputs/core-ai-settings.input.ts +24 -0
  263. package/src/core/modules/ai/interfaces/ai-hook.interface.ts +1 -1
  264. package/src/core/modules/ai/interfaces/ai-tool.interface.ts +38 -26
  265. package/src/core/modules/ai/models/core-ai-mode.model.ts +1 -1
  266. package/src/core/modules/ai/models/core-ai-settings.model.ts +23 -0
  267. package/src/core/modules/ai/models/core-ai-tool-policy.model.ts +1 -1
  268. package/src/core/modules/ai/models/core-ai-user-setting.model.ts +46 -0
  269. package/src/core/modules/ai/services/core-ai-conversation.service.ts +31 -6
  270. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +12 -9
  271. package/src/core/modules/ai/services/core-ai-mcp.service.ts +206 -13
  272. package/src/core/modules/ai/services/core-ai-prompt-builder.service.ts +13 -6
  273. package/src/core/modules/ai/services/core-ai-user-setting.service.ts +85 -0
  274. package/src/core/modules/ai/services/core-ai.service.ts +134 -35
  275. package/src/core/modules/api-token/README.md +4 -2
  276. package/src/core/modules/better-auth/CUSTOMIZATION.md +1 -1
  277. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +7 -0
  278. package/src/core/modules/better-auth/README.md +32 -1
  279. package/src/core/modules/better-auth/better-auth.config.ts +12 -1
  280. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +5 -2
  281. package/src/core/modules/better-auth/core-better-auth-path-guard.middleware.ts +85 -0
  282. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +5 -2
  283. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +14 -7
  284. package/src/core/modules/better-auth/core-better-auth.module.ts +12 -2
  285. package/src/core/modules/better-auth/index.ts +1 -0
  286. package/src/core/modules/error-code/error-codes.ts +77 -0
  287. package/src/core/modules/migrate/sql-migration.helper.ts +141 -7
  288. package/src/core/modules/sync/INTEGRATION-CHECKLIST.md +95 -0
  289. package/src/core/modules/sync/README.md +198 -0
  290. package/src/core/modules/sync/SECURITY.md +65 -0
  291. package/src/core/modules/sync/core-sync-base.controller.ts +43 -0
  292. package/src/core/modules/sync/core-sync-cursor.ts +117 -0
  293. package/src/core/modules/sync/core-sync-hints.controller.ts +52 -0
  294. package/src/core/modules/sync/core-sync-hints.service.ts +178 -0
  295. package/src/core/modules/sync/core-sync-infrastructure.models.ts +40 -0
  296. package/src/core/modules/sync/core-sync-mutation.store.ts +146 -0
  297. package/src/core/modules/sync/core-sync-state.store.ts +89 -0
  298. package/src/core/modules/sync/core-sync.constants.ts +51 -0
  299. package/src/core/modules/sync/core-sync.controller.ts +68 -0
  300. package/src/core/modules/sync/core-sync.decorators.ts +84 -0
  301. package/src/core/modules/sync/core-sync.module.ts +75 -0
  302. package/src/core/modules/sync/core-sync.resolver.ts +35 -0
  303. package/src/core/modules/sync/core-sync.service.ts +1773 -0
  304. package/src/core/modules/sync/interfaces/core-sync.interfaces.ts +85 -0
  305. package/src/core/modules/sync/models/core-sync-hint.model.ts +18 -0
  306. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +12 -1
  307. package/src/core/modules/tenant/README.md +49 -3
  308. package/src/core/modules/tenant/core-tenant.guard.ts +26 -2
  309. package/src/core/modules/tenant/core-tenant.service.ts +287 -2
  310. package/src/core.module.ts +25 -0
  311. package/src/index.ts +21 -0
  312. package/src/server/modules/sync-note/inputs/sync-note-create.input.ts +14 -0
  313. package/src/server/modules/sync-note/inputs/sync-note.input.ts +23 -0
  314. package/src/server/modules/sync-note/sync-note.model.ts +77 -0
  315. package/src/server/modules/sync-note/sync-note.module.ts +16 -0
  316. package/src/server/modules/sync-note/sync-note.service.ts +25 -0
  317. package/src/test/test.helper.ts +17 -3
@@ -0,0 +1,65 @@
1
+ # Offline Sync — Security
2
+
3
+ What the server guarantees, and what a client has to do itself. Offline sync puts a copy of the user's data on the
4
+ device, outside the server's control: the server can decide what reaches a device, but only the client can remove it
5
+ again. The protocol is in [`docs/offline-sync-protocol.md`](../../../../docs/offline-sync-protocol.md) (§11 client
6
+ duties, §12 server guarantees), the module in [README.md](./README.md).
7
+
8
+ ## What the server enforces
9
+
10
+ | Guarantee | How |
11
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
12
+ | No path around the rights | Every pull and push runs through the model's `CrudService` — `process()`, `@Restricted`, `securityCheck()`, the tenant policy, row-level security on PostgreSQL — with the operation roles the model declares (`@PersistedModel({ sync: { roles } })`). There is no lean read path |
13
+ | No foreign data | A pull never delivers a record or tombstone of another tenant or one the caller's rights hide. A push never changes one: an update or delete answers `not_found`, as for an unused id; a create on its id answers `ID_UNAVAILABLE`, the same answer for every reason, with no tenant, owner, content or deletion state. That an id is taken stays observable (protocol §3, decided O2) — ids generated as §3 requires cannot be guessed, and the push rate limit bounds probing |
14
+ | Tombstones without content | A tombstone keeps its id, `version`, `deletedAt`, `updatedAt`, `tenantId` and the user-id scope fields the model declares — nothing a user typed |
15
+ | No hard delete | `op: 'delete'` leaves a tombstone through the normal delete pipeline; removing data for good is the purge's alone, after the retention horizon moved past it |
16
+ | Access loss is enforced | A changed role, a removed membership or a record moved away answers the next pull with `410 access` while the caller may still pull the model. Once they may not pull it at all (pull roles withdrawn), or `X-Tenant-Id` names a tenant they left or that was deactivated, the pull answers `403`. Either way the client must drop its copies |
17
+ | Cookie pushes are CSRF-safe | `Content-Type: application/json` is required, and while cookies are in use a cross-site `Sec-Fetch-Site` or an untrusted `Origin` is refused (`403`) |
18
+ | Hints say nothing | `{ model, at }` only, delivered to callers with the pull roles and access to the tenant of the change; an open stream ends when its user's rights change |
19
+ | Probing is bounded | Per-user rate limits on pull and push; client ids carry 64 random bits (protocol §3), so the next id of a client cannot be guessed |
20
+
21
+ ## What a client must do
22
+
23
+ ### On logout
24
+
25
+ The device holds personal data the server no longer controls. When the user signs out, delete:
26
+
27
+ - **every local record** of every synchronized model,
28
+ - **the cursors** (they are bound to the user; another user's pull with them answers `410 invalid`),
29
+ - **the outbox** — the unsent changes. Before signing out, tell the user that changes are still unsent and offer to
30
+ sync first; after signing out they cannot be pushed under this account anyway,
31
+ - **the credential**: the bearer token from wherever it is stored. A cookie session ends through the server's sign-out
32
+ route, which also clears the cookie.
33
+
34
+ In a browser that is the IndexedDB database (or the SQLite-WASM file in the OPFS) of the app; in a native or desktop
35
+ app the SQLite file. Deleting the whole database is simpler and safer than deleting rows.
36
+
37
+ ### On an account switch
38
+
39
+ Exactly as on logout, before the next user's first pull. A client that kept the previous user's records would show
40
+ them to the next user until a resync — the server cannot reach into the device to prevent it.
41
+
42
+ ### On `410 access`
43
+
44
+ Delete every local record of that model, keep the unsent changes, pull without a cursor, then push the kept changes
45
+ (protocol §8). The records the user may no longer see are gone afterwards — which is the point.
46
+
47
+ ### On `403` on a pull
48
+
49
+ The caller may no longer pull the model at all, or not in that tenant. Treat it like `410 access` for that model (and
50
+ tenant): delete its local records and its cursor. The unsent changes cannot be pushed under this account any more —
51
+ tell the user rather than retrying.
52
+
53
+ ### At rest
54
+
55
+ - A browser app authenticates with the session cookie (`httpOnly`), not with a token in `localStorage`, where any
56
+ script injected into the page can read it.
57
+ - A native app keeps its token in the OS keychain, never in the SQLite file next to the data.
58
+ - A service worker caches the app shell only, never responses of `/sync` or `/iam` — the reference test client
59
+ (`tests/clients/pwa/sw.js` in the framework repository) shows the split.
60
+ - Device encryption is the operating system's job; recommend it where the data is sensitive.
61
+
62
+ ### Ids
63
+
64
+ Generate the ids of offline records with a secure random source (`crypto.getRandomValues`), as protocol §3 describes
65
+ — never a driver's default `new ObjectId()`, whose next value is predictable from the last one.
@@ -0,0 +1,43 @@
1
+ import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
2
+
3
+ import { getTenantContextResolver } from '../../common/services/core-tenant-context.registry.js';
4
+ import { ErrorCode } from '../error-code/error-codes.js';
5
+ import { CoreSyncService } from './core-sync.service.js';
6
+
7
+ /**
8
+ * What the sync routes share: the tenant scope of a header-less request and the per-user rate limit. Declares no route,
9
+ * so `CoreSyncController` (pull, push) and `CoreSyncHintsController` (hints) each register only their own.
10
+ *
11
+ * `@Injectable()` only so the constructor's parameter types are emitted: the two controllers inherit it.
12
+ */
13
+ @Injectable()
14
+ export abstract class CoreSyncBaseController {
15
+ constructor(protected readonly syncService: CoreSyncService) {}
16
+
17
+ /**
18
+ * Without `X-Tenant-Id` a sync request spans every tenant the caller is an active member of (plan §3.4) — or every
19
+ * tenant for an administrator under `adminBypass`. The tenant guard admits an `S_USER` route without resolving
20
+ * those memberships, so this asks the guard's own membership logic (`core-tenant-context.registry.ts`). A header
21
+ * the guard validated is kept; without multi-tenancy nothing happens.
22
+ */
23
+ protected async scopeTenants(req: any, currentUser: any): Promise<void> {
24
+ if (!req || !currentUser?.id || req.tenantId || req.tenantIds || req.isAdminBypass) {
25
+ return;
26
+ }
27
+ const resolved = await getTenantContextResolver()?.resolve(currentUser, undefined);
28
+ if (resolved?.isAdminBypass) {
29
+ req.isAdminBypass = true;
30
+ } else if (resolved?.tenantIds) {
31
+ req.tenantIds = resolved.tenantIds;
32
+ }
33
+ }
34
+
35
+ /** 429 with `Retry-After` once the caller exceeded the limit of this kind */
36
+ protected async limit(kind: 'pull' | 'push', currentUser: any, res: any): Promise<void> {
37
+ const retryAfter = await this.syncService.rateLimit(kind, currentUser);
38
+ if (retryAfter !== undefined) {
39
+ res?.setHeader?.('Retry-After', String(retryAfter));
40
+ throw new HttpException(ErrorCode.RATE_LIMIT_EXCEEDED, HttpStatus.TOO_MANY_REQUESTS);
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,117 @@
1
+ import { createHash, createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
2
+
3
+ /**
4
+ * The pull cursor (protocol §5, plan §3.4): opaque to the client, signed by the server, bound to the model and the user
5
+ * it was issued for. Format: `base64url(JSON).base64url(HMAC-SHA256)`. Nothing in it is a secret — the signature only
6
+ * keeps a client from editing it into another user's, another model's or an earlier position.
7
+ */
8
+
9
+ /** Where a pull continues */
10
+ export type SyncCursorPosition =
11
+ /** SQL: the last change number delivered (`syncSeq`) */
12
+ | { seq: number }
13
+ /** MongoDB: the keyset `(syncAt, id)` of the last record delivered; `id: ''` starts at `at` itself */
14
+ | { at: number; id: string };
15
+
16
+ export interface SyncCursorState {
17
+ /** Hash of the access epochs the cursor was issued under */
18
+ epochs: string;
19
+ /** Hash of the caller's access when the cursor was issued (user, tenant scope, roles, verification) */
20
+ fingerprint: string;
21
+ model: string;
22
+ position: SyncCursorPosition;
23
+ /** Start (epoch ms) of the run in progress; absent once a page ended its run */
24
+ runStart?: number;
25
+ /**
26
+ * Start (epoch ms) of the run since which the client's state is complete — compared with the tombstone horizon: a
27
+ * tombstone purged after this point may never have reached the client
28
+ */
29
+ since: number;
30
+ user: string;
31
+ }
32
+
33
+ /** Cursors longer than this are refused unread: nothing the server issues comes close */
34
+ const MAX_CURSOR_LENGTH = 2048;
35
+
36
+ /** The key the cursors are signed with, derived from the configured secret (never the secret itself) */
37
+ export function deriveCursorKey(secret: string): Buffer {
38
+ return Buffer.from(hkdfSync('sha256', secret, 'lenne.tech', 'nest-server:sync-cursor:v1', 32));
39
+ }
40
+
41
+ export function encodeSyncCursor(state: SyncCursorState, key: Buffer): string {
42
+ const payload = Buffer.from(
43
+ JSON.stringify({
44
+ e: state.epochs,
45
+ f: state.fingerprint,
46
+ k: state.since,
47
+ m: state.model,
48
+ p: 'seq' in state.position ? { s: state.position.seq } : { a: state.position.at, i: state.position.id },
49
+ ...(state.runStart === undefined ? {} : { r: state.runStart }),
50
+ u: state.user,
51
+ v: 1,
52
+ }),
53
+ ).toString('base64url');
54
+ return `${payload}.${sign(payload, key)}`;
55
+ }
56
+
57
+ /** The cursor's state, or `undefined` when it is malformed, tampered with or of another format version */
58
+ export function decodeSyncCursor(cursor: unknown, key: Buffer): SyncCursorState | undefined {
59
+ if (typeof cursor !== 'string' || !cursor || cursor.length > MAX_CURSOR_LENGTH) {
60
+ return undefined;
61
+ }
62
+ const [payload, signature, rest] = cursor.split('.');
63
+ if (!payload || !signature || rest !== undefined) {
64
+ return undefined;
65
+ }
66
+ const expected = Buffer.from(sign(payload, key));
67
+ const given = Buffer.from(signature);
68
+ if (expected.length !== given.length || !timingSafeEqual(expected, given)) {
69
+ return undefined;
70
+ }
71
+ let raw: any;
72
+ try {
73
+ raw = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ if (
78
+ !raw ||
79
+ raw.v !== 1 ||
80
+ typeof raw.m !== 'string' ||
81
+ typeof raw.u !== 'string' ||
82
+ typeof raw.f !== 'string' ||
83
+ typeof raw.e !== 'string' ||
84
+ !Number.isFinite(raw.k) ||
85
+ (raw.r !== undefined && !Number.isFinite(raw.r)) ||
86
+ !raw.p ||
87
+ typeof raw.p !== 'object'
88
+ ) {
89
+ return undefined;
90
+ }
91
+ let position: SyncCursorPosition;
92
+ if (Number.isFinite(raw.p.s)) {
93
+ position = { seq: raw.p.s };
94
+ } else if (Number.isFinite(raw.p.a) && typeof raw.p.i === 'string') {
95
+ position = { at: raw.p.a, id: raw.p.i };
96
+ } else {
97
+ return undefined;
98
+ }
99
+ return {
100
+ epochs: raw.e,
101
+ fingerprint: raw.f,
102
+ model: raw.m,
103
+ position,
104
+ ...(raw.r === undefined ? {} : { runStart: raw.r }),
105
+ since: raw.k,
106
+ user: raw.u,
107
+ };
108
+ }
109
+
110
+ /** A short, stable hash of a JSON-serializable value (fingerprints, epoch sets) */
111
+ export function stableHash(value: unknown): string {
112
+ return createHash('sha256').update(JSON.stringify(value)).digest('base64url').slice(0, 32);
113
+ }
114
+
115
+ function sign(payload: string, key: Buffer): string {
116
+ return createHmac('sha256', key).update(payload).digest('base64url');
117
+ }
@@ -0,0 +1,52 @@
1
+ import { Controller, Get, NotFoundException, Query, Req, Res } from '@nestjs/common';
2
+ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
3
+
4
+ import { CurrentUser } from '../../common/decorators/current-user.decorator.js';
5
+ import { Roles } from '../../common/decorators/roles.decorator.js';
6
+ import { RoleEnum } from '../../common/enums/role.enum.js';
7
+ import { ErrorCode } from '../error-code/error-codes.js';
8
+ import { CoreSyncBaseController } from './core-sync-base.controller.js';
9
+ import { SYNC_DEFAULTS } from './core-sync.constants.js';
10
+
11
+ /**
12
+ * `GET /sync/hints` (protocol §9): server-sent change hints. A controller of its own, registered before
13
+ * `CoreSyncController`: Nest registers a class's own routes before the ones it inherits, so a project controller that
14
+ * re-declares `pull()` (`@Get(':model')`) would otherwise read `hints` as a model name and answer 404 — and an
15
+ * EventSource does not reconnect after an error status, so the hints would stop without a sign.
16
+ *
17
+ * A signed-in caller always gets the stream (200); models the caller may not pull never send a hint, so the stream
18
+ * reveals nothing about which models exist. API tokens are refused here like on every route without
19
+ * `@ApiTokenScopes()`; a project that wants them extends this controller, re-declares `hints()` with
20
+ * `@ApiTokenScopes('sync')` and passes it as `sync.hintsController` override.
21
+ */
22
+ @ApiTags('sync')
23
+ @Controller(SYNC_DEFAULTS.path)
24
+ @Roles(RoleEnum.S_USER)
25
+ export class CoreSyncHintsController extends CoreSyncBaseController {
26
+ /** The stream is `CoreSyncService.openHintStream()`; 404 while the SSE channel is switched off */
27
+ @ApiBearerAuth()
28
+ @ApiOperation({
29
+ description:
30
+ 'Server-sent events: `event: change` with `{ model, at }` whenever a model the caller may pull changes; ' +
31
+ '`models` (comma-separated) narrows the stream',
32
+ summary: 'Change hints (SSE)',
33
+ })
34
+ @Get('hints')
35
+ @Roles(RoleEnum.S_USER)
36
+ async hints(
37
+ @Query('models') models: string,
38
+ @CurrentUser() currentUser: any,
39
+ @Req() req: any,
40
+ @Res() res: any,
41
+ ): Promise<void> {
42
+ if (!this.syncService.hintsEnabled('sse')) {
43
+ throw new NotFoundException(ErrorCode.SYNC_MODEL_NOT_FOUND);
44
+ }
45
+ // Counted like a pull: an EventSource reconnects on its own, and every reconnect is a request
46
+ await this.limit('pull', currentUser, res);
47
+ // Without X-Tenant-Id the stream spans every membership of the caller, as a pull does
48
+ await this.scopeTenants(req, currentUser);
49
+ const named = typeof models === 'string' ? models.split(',').map((model) => model.trim()) : [];
50
+ await this.syncService.openHintStream(req, res, currentUser, named.filter(Boolean));
51
+ }
52
+ }
@@ -0,0 +1,178 @@
1
+ import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy, Optional } from '@nestjs/common';
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ import type { ISync } from '../../common/interfaces/server-options.interface.js';
5
+ import { ConfigService } from '../../common/services/config.service.js';
6
+ import { CoreRedisService } from '../../common/services/core-redis.service.js';
7
+ import { SYNC_DEFAULTS } from './core-sync.constants.js';
8
+
9
+ /**
10
+ * A hint as the bus carries it: a synchronized model changed, in a tenant (`undefined` on a model without tenants).
11
+ * Subscribers receive `{ model, at }` only (plan §3.9); the tenant decides who receives it.
12
+ */
13
+ export interface SyncHintEvent {
14
+ at: number;
15
+ model: string;
16
+ tenantId?: string;
17
+ }
18
+
19
+ /**
20
+ * The hint bus of offline sync (plan §3.9, protocol §9): a change of a synchronized model becomes one hint per model and
21
+ * tenant per `sync.hints.debounceMs`, delivered to this replica's subscribers at once and, with `redis`, to every other
22
+ * replica's through one Redis channel. Without Redis, or while it does not answer, hints stay on this replica — a hint
23
+ * only means "pull now", so a client that misses one learns of the change at its next pull.
24
+ *
25
+ * Who may receive a hint is not decided here: each subscriber filters by its own access (`CoreSyncService`).
26
+ */
27
+ @Injectable()
28
+ export class CoreSyncHintService implements OnApplicationBootstrap, OnModuleDestroy {
29
+ protected readonly logger = new Logger(CoreSyncHintService.name);
30
+ /** This replica, so a hint it published is not delivered a second time when Redis brings it back */
31
+ protected readonly origin = randomUUID();
32
+ protected readonly subscribers = new Set<(hint: SyncHintEvent) => void>();
33
+ /** Pending hints by model and tenant: one per key and debounce window, removed when sent */
34
+ protected readonly pending = new Map<string, ReturnType<typeof setTimeout>>();
35
+ protected channel?: string;
36
+ protected redisListener?: (channel: string, message: string) => void;
37
+
38
+ constructor(@Optional() protected readonly redisService?: CoreRedisService) {}
39
+
40
+ /** The `sync.hints` configuration (an object; `true` / absent read as `{}`, `false` as `{ enabled: false }`) */
41
+ protected get config(): Exclude<ISync['hints'], boolean | undefined> {
42
+ const sync = ConfigService.configFastButReadOnly?.sync;
43
+ const hints = sync && typeof sync === 'object' ? sync.hints : undefined;
44
+ if (hints === false) {
45
+ return { enabled: false };
46
+ }
47
+ return hints && typeof hints === 'object' ? hints : {};
48
+ }
49
+
50
+ /** Whether hints are sent at all (`sync.hints`, on by default) */
51
+ get enabled(): boolean {
52
+ return this.config.enabled !== false;
53
+ }
54
+
55
+ /** `sync.hints.debounceMs`: at most one hint per model and tenant per window; an invalid value → the default */
56
+ protected get debounceMs(): number {
57
+ const value = this.config.debounceMs;
58
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : SYNC_DEFAULTS.hintDebounceMs;
59
+ }
60
+
61
+ async onApplicationBootstrap(): Promise<void> {
62
+ if (!this.enabled || !this.redisService?.enabled) {
63
+ return;
64
+ }
65
+ const channel = this.redisService.key('sync', 'hints');
66
+ try {
67
+ // Held in a field: the subscriber connection is shared, a listener left behind would deliver into a destroyed bus
68
+ this.channel = channel;
69
+ this.redisListener = (incoming: string, message: string) => {
70
+ if (incoming === channel) {
71
+ this.receive(message);
72
+ }
73
+ };
74
+ this.redisService.getSubscriber().on('message', this.redisListener);
75
+ // Registered with the Redis service, so the subscription comes back after every reconnect
76
+ await this.redisService.subscribeChannel(channel);
77
+ } catch (error) {
78
+ this.logger.warn(
79
+ `Sync hints: Redis subscribe failed (${(error as Error)?.message ?? error}) — retried when the connection is ` +
80
+ "up; until then this replica's subscribers receive only its own hints",
81
+ );
82
+ }
83
+ }
84
+
85
+ onModuleDestroy(): void {
86
+ for (const timer of this.pending.values()) {
87
+ clearTimeout(timer);
88
+ }
89
+ this.pending.clear();
90
+ this.subscribers.clear();
91
+ if (this.redisListener) {
92
+ try {
93
+ this.redisService?.getSubscriber().off('message', this.redisListener);
94
+ if (this.channel) {
95
+ void this.redisService?.releaseChannel(this.channel).catch(() => undefined);
96
+ }
97
+ } catch {
98
+ // Redis already gone — nothing to detach from
99
+ }
100
+ this.redisListener = undefined;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * A synchronized model changed in these tenants (none: a model without tenants). Sent as one hint per model and
106
+ * tenant once the debounce window ends — a burst of writes is one hint.
107
+ */
108
+ notify(model: string, tenantIds: readonly (string | undefined)[] = [undefined]): void {
109
+ if (!this.enabled) {
110
+ return;
111
+ }
112
+ for (const tenantId of tenantIds.length ? new Set(tenantIds) : [undefined]) {
113
+ const key = `${model}\u0000${tenantId ?? ''}`;
114
+ if (this.pending.has(key)) {
115
+ continue;
116
+ }
117
+ const timer = setTimeout(() => {
118
+ this.pending.delete(key);
119
+ this.send({ at: Date.now(), model, ...(tenantId ? { tenantId } : {}) });
120
+ }, this.debounceMs);
121
+ timer.unref?.();
122
+ this.pending.set(key, timer);
123
+ }
124
+ }
125
+
126
+ /** Receive every hint (this replica's and, with Redis, every other's); answers the unsubscribe */
127
+ subscribe(handler: (hint: SyncHintEvent) => void): () => void {
128
+ this.subscribers.add(handler);
129
+ return () => {
130
+ this.subscribers.delete(handler);
131
+ };
132
+ }
133
+
134
+ /** Deliver a hint here and hand it to the other replicas */
135
+ protected send(hint: SyncHintEvent): void {
136
+ this.deliver(hint);
137
+ if (!this.channel || !this.redisService?.enabled) {
138
+ return;
139
+ }
140
+ try {
141
+ void this.redisService
142
+ .getClient()
143
+ .publish(this.channel, JSON.stringify({ ...hint, origin: this.origin }))
144
+ .catch((error: Error) => this.logger.debug(`Sync hint publish failed: ${error.message}`));
145
+ } catch (error) {
146
+ this.logger.debug(`Sync hint publish failed: ${(error as Error)?.message ?? error}`);
147
+ }
148
+ }
149
+
150
+ /** A hint from the Redis channel — another replica's; this replica's own were delivered when sent */
151
+ protected receive(message: string): void {
152
+ let parsed: any;
153
+ try {
154
+ parsed = JSON.parse(message);
155
+ } catch {
156
+ return;
157
+ }
158
+ if (!parsed || parsed.origin === this.origin || typeof parsed.model !== 'string' || !Number.isFinite(parsed.at)) {
159
+ return;
160
+ }
161
+ this.deliver({
162
+ at: Number(parsed.at),
163
+ model: parsed.model,
164
+ ...(typeof parsed.tenantId === 'string' && parsed.tenantId ? { tenantId: parsed.tenantId } : {}),
165
+ });
166
+ }
167
+
168
+ protected deliver(hint: SyncHintEvent): void {
169
+ // A Set tolerates a subscriber removing itself while it is called
170
+ for (const handler of this.subscribers) {
171
+ try {
172
+ handler(hint);
173
+ } catch (error) {
174
+ this.logger.warn(`Sync hint subscriber failed: ${(error as Error)?.message ?? error}`);
175
+ }
176
+ }
177
+ }
178
+ }
@@ -0,0 +1,40 @@
1
+ import { createSchema } from '../../common/persistence/mongo/mongoose-compat.js';
2
+
3
+ /**
4
+ * The sync module's infrastructure stores (offline-sync plan §3.3 E7, §3.7) as framework models: registered with
5
+ * `CorePersistenceModule.forFeature()`, they follow `database` like every other model and run through one code path
6
+ * on every database — a collection on MongoDB, a table on SQL. Nothing here relies on the TTL for correctness: every
7
+ * reader checks `expiresAt` itself; MongoDB's TTL index and the SQL expiry sweep only reclaim the space.
8
+ */
9
+
10
+ /**
11
+ * One pushed mutation per `key` (model, user, record, mutation id — hashed): claimed before the record is written
12
+ * (`state: 'pending'`), then its outcome (`state: 'done'` with `status`, `version`, `code`). `attempt` makes every
13
+ * takeover a conditional write that exactly one request wins.
14
+ */
15
+ export const SyncMutationSchema = createSchema(
16
+ {
17
+ attempt: { type: Number },
18
+ claimedAt: { type: Date },
19
+ code: { type: String },
20
+ expiresAt: { index: { expireAfterSeconds: 0 }, type: Date },
21
+ key: { index: { unique: true }, type: String },
22
+ state: { type: String },
23
+ status: { type: String },
24
+ version: { type: Number },
25
+ },
26
+ { collection: 'syncMutations', versionKey: false },
27
+ );
28
+
29
+ /**
30
+ * Access epochs (`epoch:…` keys, an integer bumped when access to synchronized records changes, plan §3.8) and tombstone
31
+ * horizons (`horizon:<model>`, the oldest point a cursor may continue from, advanced by the purge before it deletes).
32
+ */
33
+ export const SyncStateSchema = createSchema(
34
+ {
35
+ epoch: { type: Number },
36
+ horizon: { type: Date },
37
+ key: { index: { unique: true }, type: String },
38
+ },
39
+ { collection: 'syncStates', versionKey: false },
40
+ );
@@ -0,0 +1,146 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import type { DataStore, StoredRecord } from '../../common/persistence/data-store.interface.js';
4
+
5
+ /** The recorded outcome of a mutation: what a repeat of it is answered with (protocol §6.5) */
6
+ export interface SyncMutationOutcome {
7
+ code?: string;
8
+ status: string;
9
+ version?: number;
10
+ }
11
+
12
+ /**
13
+ * What a claim found:
14
+ * - `claimed`: this request writes the mutation and then records its outcome (`complete()`) or gives it up (`release()`)
15
+ * - `busy`: another request is writing it right now — answer `retry`
16
+ * - `done`: it was applied before — answer with its outcome
17
+ */
18
+ export type SyncMutationClaim = { kind: 'busy' } | { kind: 'claimed' } | { kind: 'done'; outcome: SyncMutationOutcome };
19
+
20
+ interface MutationRecord {
21
+ attempt?: number;
22
+ claimedAt?: Date;
23
+ code?: null | string;
24
+ expiresAt?: Date;
25
+ key: string;
26
+ state?: string;
27
+ status?: null | string;
28
+ version?: null | number;
29
+ }
30
+
31
+ /**
32
+ * Idempotency of pushed changes (offline-sync plan §3.7): the mutation is claimed BEFORE the record is written, under a
33
+ * unique key, so two tabs sending the same change at once end in one write — the second finds the claim and is told to
34
+ * retry, then finds the outcome. A record written after the write could not give that: both requests would miss it.
35
+ *
36
+ * A claim older than the claim timeout belongs to a request that died between claiming and recording; it is taken over
37
+ * with a conditional write on `attempt`, which exactly one contender wins. An entry past `expiresAt` counts as absent
38
+ * whether or not the TTL has removed it yet.
39
+ */
40
+ export class CoreSyncMutationStore {
41
+ constructor(
42
+ protected readonly store: DataStore<MutationRecord>,
43
+ protected readonly options: { claimTimeoutMs: number; retentionMs: number },
44
+ ) {}
45
+
46
+ /**
47
+ * The key of a mutation: model, user, record and mutation id, hashed to a fixed length. The user is part of it, so a
48
+ * mutation id of somebody else can neither block a change nor reveal its outcome.
49
+ */
50
+ static key(model: string, userId: string, recordId: string, mutationId: string): string {
51
+ return createHash('sha256').update([model, userId, recordId, mutationId].join('\u0000')).digest('hex');
52
+ }
53
+
54
+ async claim(key: string, now = new Date()): Promise<SyncMutationClaim> {
55
+ // A concurrent claimant can make each step fail once; three rounds settle every interleaving of two requests
56
+ for (let round = 0; round < 3; round++) {
57
+ const existing = await this.store.findOne({ key });
58
+ if (!existing) {
59
+ try {
60
+ await this.store.insert({ key, ...this.pending(now, 1) });
61
+ return { kind: 'claimed' };
62
+ } catch (error) {
63
+ if (!isDuplicateKey(error)) {
64
+ throw error;
65
+ }
66
+ continue;
67
+ }
68
+ }
69
+ if (isExpired(existing, now)) {
70
+ if (await this.takeOver(existing, now)) {
71
+ return { kind: 'claimed' };
72
+ }
73
+ continue;
74
+ }
75
+ if (existing.state === 'done' && existing.status) {
76
+ return { kind: 'done', outcome: outcomeOf(existing) };
77
+ }
78
+ const claimedAt = existing.claimedAt ? new Date(existing.claimedAt).getTime() : 0;
79
+ if (now.getTime() - claimedAt < this.options.claimTimeoutMs) {
80
+ return { kind: 'busy' };
81
+ }
82
+ return (await this.takeOver(existing, now)) ? { kind: 'claimed' } : { kind: 'busy' };
83
+ }
84
+ return { kind: 'busy' };
85
+ }
86
+
87
+ /** Record the outcome of a claimed mutation; kept for the retention period from now */
88
+ async complete(key: string, outcome: SyncMutationOutcome, now = new Date()): Promise<void> {
89
+ await this.store.updateOne(
90
+ { key },
91
+ {
92
+ $set: {
93
+ code: outcome.code ?? null,
94
+ expiresAt: new Date(now.getTime() + this.options.retentionMs),
95
+ state: 'done',
96
+ status: outcome.status,
97
+ version: outcome.version ?? null,
98
+ },
99
+ },
100
+ );
101
+ }
102
+
103
+ /** Give up a claim whose write did not happen (a transient failure), so the client's retry is written anew */
104
+ async release(key: string): Promise<void> {
105
+ await this.store.deleteOne({ key, state: 'pending' });
106
+ }
107
+
108
+ protected pending(now: Date, attempt: number): Omit<MutationRecord, 'key'> {
109
+ return {
110
+ attempt,
111
+ claimedAt: now,
112
+ code: null,
113
+ expiresAt: new Date(now.getTime() + this.options.retentionMs),
114
+ state: 'pending',
115
+ status: null,
116
+ version: null,
117
+ };
118
+ }
119
+
120
+ /** Take an abandoned or expired entry over: conditional on the attempt the reader saw, so one contender wins */
121
+ protected async takeOver(existing: StoredRecord<MutationRecord>, now: Date): Promise<boolean> {
122
+ const attempt = existing.attempt ?? 0;
123
+ const taken = await this.store.updateOne(
124
+ { attempt: existing.attempt ?? null, id: existing.id },
125
+ { $set: this.pending(now, attempt + 1) },
126
+ );
127
+ return !!taken;
128
+ }
129
+ }
130
+
131
+ function isExpired(record: MutationRecord, now: Date): boolean {
132
+ return !!record.expiresAt && new Date(record.expiresAt).getTime() <= now.getTime();
133
+ }
134
+
135
+ function outcomeOf(record: MutationRecord): SyncMutationOutcome {
136
+ return {
137
+ ...(record.code ? { code: record.code } : {}),
138
+ status: record.status,
139
+ ...(typeof record.version === 'number' ? { version: record.version } : {}),
140
+ };
141
+ }
142
+
143
+ /** A unique index refused the write — MongoDB's code, which the SQL stores carry too (`DuplicateKeyError`) */
144
+ export function isDuplicateKey(error: unknown): boolean {
145
+ return (error as { code?: unknown })?.code === 11000;
146
+ }