@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,270 @@
1
+ import type {
2
+ FieldDescriptor,
3
+ IndexDescriptor,
4
+ ModelDescriptor,
5
+ SyncDescriptor,
6
+ SyncIdFormat,
7
+ } from './model-descriptor.interface.js';
8
+
9
+ export type { SyncDescriptor, SyncIdFormat } from './model-descriptor.interface.js';
10
+
11
+ /**
12
+ * Synchronized models (offline sync, `docs/offline-sync-protocol.md`, offline-sync plan §3.2).
13
+ *
14
+ * A model declared with `@PersistedModel({ sync })` carries three system fields every store maintains and no caller
15
+ * writes — `version`, `deletedAt` and its position in the change order — and its delete leaves a tombstone instead of
16
+ * removing the record. This leaf holds the declaration, the descriptor it produces and the field names, so the stores,
17
+ * the schema compilers and the Mongoose plugin read one definition. Types and plain functions only: every persistence
18
+ * file may import it without an import cycle.
19
+ */
20
+
21
+ /**
22
+ * How a push meets a record that changed since the client's base version (protocol §6.4): `reject` answers `conflict`,
23
+ * `lww` applies the change on top (last to ARRIVE at the server wins), `merge` applies the fields the client changed
24
+ * unless one of them is declared `@SyncField('reject')`, `custom` asks the model service's `resolveSyncConflict()`.
25
+ */
26
+ export type SyncConflictStrategy = 'custom' | 'lww' | 'merge' | 'reject';
27
+
28
+ /**
29
+ * Who may do what through the sync routes (protocol §1.3), passed as `serviceOptions.roles` to every `CrudService`
30
+ * call of the module — so `S_CREATOR` and `S_SELF` are decided against the stored record, as everywhere. The roles
31
+ * a project puts on its own routes do not reach `/sync`; an operation without roles here is open to `ADMIN` only.
32
+ */
33
+ export interface SyncOperationRoles {
34
+ create?: string[];
35
+ delete?: string[];
36
+ /** Also who may receive hints for the model; `S_CREATOR` / `S_SELF` decide nothing here (a pull has no record) */
37
+ pull?: string[];
38
+ update?: string[];
39
+ }
40
+
41
+ const SYNC_STRATEGIES: readonly SyncConflictStrategy[] = ['custom', 'lww', 'merge', 'reject'];
42
+ const SYNC_OPERATIONS: readonly (keyof SyncOperationRoles)[] = ['create', 'delete', 'pull', 'update'];
43
+
44
+ export interface SyncModelOptions {
45
+ /**
46
+ * Format of the model's ids. `uuidv7` is meant for NEW models: ids are stored as text on SQL and as a BSON UUID
47
+ * (binary subtype 4) on MongoDB, and every reference to the model must hold that format too.
48
+ * @default 'objectId'
49
+ */
50
+ idFormat?: SyncIdFormat;
51
+ /**
52
+ * Operation roles of the sync routes (`SyncOperationRoles`). Read by the sync module only.
53
+ * @default every operation open to ADMIN only (the boot warns until roles are declared)
54
+ */
55
+ roles?: SyncOperationRoles;
56
+ /**
57
+ * The user-id fields a tombstone keeps besides `tenantId` (e.g. `['ownerId']`), so a deletion reaches exactly the
58
+ * users who could see the record. Every other content field is cleared when the record is deleted.
59
+ * @default []
60
+ */
61
+ scope?: string[];
62
+ /**
63
+ * What a push does when the record changed since the client's base version. Read by the sync module only.
64
+ * @default 'reject'
65
+ */
66
+ strategy?: SyncConflictStrategy;
67
+ }
68
+
69
+ /** Incremented by every write; `1` after the insert */
70
+ export const SYNC_VERSION_FIELD = 'version';
71
+ /** `null` for a live record, the deletion time for a tombstone */
72
+ export const SYNC_DELETED_FIELD = 'deletedAt';
73
+ /** MongoDB's change position: the server clock of the last write (plan §3.4, C2) */
74
+ export const SYNC_CLOCK_FIELD = 'syncAt';
75
+ /** The SQL change position: a commit-ordered sequence number (plan §3.4, C1d) */
76
+ export const SYNC_SEQUENCE_FIELD = 'syncSeq';
77
+ /** The value a multi-row statement writes before its final statement numbers the rows (plan §3.4) */
78
+ export const SYNC_SEQUENCE_PENDING = -1;
79
+
80
+ /** Fields only the store writes on a synchronized model */
81
+ export const SYNC_SYSTEM_FIELDS: readonly string[] = Object.freeze([
82
+ SYNC_VERSION_FIELD,
83
+ SYNC_DELETED_FIELD,
84
+ SYNC_CLOCK_FIELD,
85
+ SYNC_SEQUENCE_FIELD,
86
+ ]);
87
+
88
+ /**
89
+ * The live-record condition: the same form in every read filter and in every partial unique index, because MongoDB
90
+ * uses a partial index only for a query that implies its filter, and the equality also covers a document without the
91
+ * field (P2-4, measured in phase 2). SQL compiles it to `deleted_at IS NULL`.
92
+ */
93
+ export function liveFilter(): { deletedAt: null } {
94
+ return { [SYNC_DELETED_FIELD]: null } as { deletedAt: null };
95
+ }
96
+
97
+ /** The declaration of `@PersistedModel({ sync })`, resolved; `undefined` for a model that is not synchronized */
98
+ export function resolveSyncOptions(
99
+ value: boolean | SyncModelOptions | undefined,
100
+ model: string,
101
+ ): SyncDescriptor | undefined {
102
+ if (value === undefined || value === false) {
103
+ return undefined;
104
+ }
105
+ const options: SyncModelOptions = value === true ? {} : value;
106
+ if (!options || typeof options !== 'object') {
107
+ throw new Error(`@PersistedModel(${model}): sync must be true or an options object`);
108
+ }
109
+ const idFormat = options.idFormat ?? 'objectId';
110
+ if (idFormat !== 'objectId' && idFormat !== 'uuidv7') {
111
+ throw new Error(
112
+ `@PersistedModel(${model}): sync.idFormat must be 'objectId' or 'uuidv7', got ${JSON.stringify(idFormat)}`,
113
+ );
114
+ }
115
+ const scope = options.scope ?? [];
116
+ if (!Array.isArray(scope) || scope.some((field) => typeof field !== 'string' || !field)) {
117
+ throw new Error(`@PersistedModel(${model}): sync.scope must be a list of field names`);
118
+ }
119
+ if (options.strategy !== undefined && !SYNC_STRATEGIES.includes(options.strategy)) {
120
+ throw new Error(
121
+ `@PersistedModel(${model}): sync.strategy must be one of ${SYNC_STRATEGIES.join(', ')}, got ${JSON.stringify(options.strategy)}`,
122
+ );
123
+ }
124
+ if (options.roles !== undefined) {
125
+ const roles = options.roles;
126
+ const invalid =
127
+ !roles ||
128
+ typeof roles !== 'object' ||
129
+ Object.entries(roles).some(
130
+ ([operation, list]) =>
131
+ !SYNC_OPERATIONS.includes(operation as keyof SyncOperationRoles) ||
132
+ !Array.isArray(list) ||
133
+ list.some((role) => typeof role !== 'string' || !role),
134
+ );
135
+ if (invalid) {
136
+ throw new Error(
137
+ `@PersistedModel(${model}): sync.roles maps ${SYNC_OPERATIONS.join(', ')} to lists of role names`,
138
+ );
139
+ }
140
+ }
141
+ return { idFormat, scope: Object.freeze([...scope]) };
142
+ }
143
+
144
+ /**
145
+ * The descriptor of a synchronized model: the system fields added, the id typed by the declared format, and every
146
+ * unique index restricted to live records — so deleting a record and creating one with the same unique value works,
147
+ * on every database (plan §3.6). Database-neutral: the change position (`syncSeq` / `syncAt`) is the store's own
148
+ * column, added by the dialect that keeps it.
149
+ *
150
+ * Refuses what would make the model lose deletions or system fields: a TTL index (records would vanish without a
151
+ * tombstone), a declared field of a system name, a scope field the model does not have.
152
+ */
153
+ export function withSyncDescriptor(descriptor: ModelDescriptor, sync: SyncDescriptor): ModelDescriptor {
154
+ const name = descriptor.name;
155
+ for (const field of SYNC_SYSTEM_FIELDS) {
156
+ if (descriptor.fields[field]) {
157
+ throw new Error(`${name}: "${field}" is reserved on a synchronized model — rename the field`);
158
+ }
159
+ }
160
+ for (const field of sync.scope) {
161
+ if (!descriptor.fields[field]) {
162
+ throw new Error(`${name}: sync.scope names "${field}", which the model does not declare`);
163
+ }
164
+ }
165
+ const nestedUnique = descriptor.indexes.find(
166
+ (index) => index.options.unique === true && Object.keys(index.fields).some((path) => path.includes('.')),
167
+ );
168
+ if (nestedUnique) {
169
+ // MongoDB keeps such an index on the embedded schema, out of reach of the live-only rewrite: a tombstone (which
170
+ // keeps no values) would collide on it with the next deletion
171
+ throw new Error(
172
+ `${name}: a unique index inside an embedded object (${JSON.stringify(nestedUnique.fields)}) is not supported on a ` +
173
+ 'synchronized model — declare the unique field on the model itself',
174
+ );
175
+ }
176
+ if (
177
+ descriptor.indexes.some((index) => 'expireAfterSeconds' in index.options) ||
178
+ Object.values(descriptor.fields).some((field) => field.expiresAfterSeconds !== undefined)
179
+ ) {
180
+ throw new Error(
181
+ `${name}: a synchronized model cannot expire records (TTL) — an expired record disappears without a tombstone, ` +
182
+ 'and every client keeps it',
183
+ );
184
+ }
185
+
186
+ const fields: Record<string, FieldDescriptor> = { ...descriptor.fields };
187
+ if (sync.idFormat === 'uuidv7' && fields._id) {
188
+ fields._id = { ...fields._id, kind: 'uuid' };
189
+ }
190
+ fields[SYNC_VERSION_FIELD] = systemField(SYNC_VERSION_FIELD, 'number', 1);
191
+ fields[SYNC_DELETED_FIELD] = systemField(SYNC_DELETED_FIELD, 'date', null);
192
+
193
+ return {
194
+ ...descriptor,
195
+ fields,
196
+ indexes: descriptor.indexes.map((index) => liveUniqueIndex(index)),
197
+ sync,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * A unique index restricted to live records. A sparse one keeps its meaning through `$exists` — MongoDB refuses
203
+ * `sparse` together with a `partialFilterExpression`. Every other index is returned unchanged.
204
+ */
205
+ export function liveUniqueIndex(index: IndexDescriptor): IndexDescriptor {
206
+ if (index.options.unique !== true) {
207
+ return index;
208
+ }
209
+ const { sparse, ...options } = index.options;
210
+ const existing = (options.partialFilterExpression as Record<string, unknown> | undefined) ?? {};
211
+ const exists =
212
+ sparse === true ? Object.fromEntries(Object.keys(index.fields).map((path) => [path, { $exists: true }])) : {};
213
+ return {
214
+ fields: { ...index.fields },
215
+ options: { ...options, partialFilterExpression: { ...existing, ...exists, ...liveFilter() } },
216
+ };
217
+ }
218
+
219
+ /**
220
+ * The descriptor a SQL store works with: a synchronized model's descriptor plus its commit-ordered change position
221
+ * `syncSeq` (a `bigint` column the database's trigger numbers, plan §3.4) and the pull's index over it, led by the
222
+ * tenant every pull on a tenant-scoped model is filtered by. Any other descriptor is returned as it is.
223
+ */
224
+ export function withSequenceField(descriptor: ModelDescriptor): ModelDescriptor {
225
+ if (!descriptor.sync || descriptor.fields[SYNC_SEQUENCE_FIELD]) {
226
+ return descriptor;
227
+ }
228
+ const field = systemField(SYNC_SEQUENCE_FIELD, 'bigint', undefined);
229
+ return {
230
+ ...descriptor,
231
+ fields: { ...descriptor.fields, [SYNC_SEQUENCE_FIELD]: field },
232
+ indexes: [
233
+ ...descriptor.indexes,
234
+ {
235
+ fields: descriptor.tenantScoped ? { tenantId: 1, [SYNC_SEQUENCE_FIELD]: 1 } : { [SYNC_SEQUENCE_FIELD]: 1 },
236
+ options: {},
237
+ },
238
+ ],
239
+ };
240
+ }
241
+
242
+ /** Whether a partial filter is exactly the live-record condition (PostgreSQL then keeps `NULLS NOT DISTINCT`) */
243
+ export function isLiveFilter(filter: unknown): boolean {
244
+ if (!filter || typeof filter !== 'object') {
245
+ return false;
246
+ }
247
+ const keys = Object.keys(filter);
248
+ return (
249
+ keys.length === 1 &&
250
+ keys[0] === SYNC_DELETED_FIELD &&
251
+ (filter as Record<string, unknown>)[SYNC_DELETED_FIELD] === null
252
+ );
253
+ }
254
+
255
+ function systemField(name: string, kind: FieldDescriptor['kind'], value: unknown): FieldDescriptor {
256
+ return {
257
+ ...(value === undefined ? {} : { default: value }),
258
+ hasDefault: value !== undefined,
259
+ index: false,
260
+ isArray: false,
261
+ kind,
262
+ lowercase: false,
263
+ name,
264
+ required: false,
265
+ select: true,
266
+ sparse: false,
267
+ trim: false,
268
+ unique: false,
269
+ };
270
+ }
@@ -9,7 +9,19 @@ import { convertFilterArgsToQuery } from '../helpers/filter.helper.js';
9
9
  import { mergePlain, prepareServiceOptionsForCreate } from '../helpers/input.helper.js';
10
10
  import { ServiceOptions } from '../interfaces/service-options.interface.js';
11
11
  import type { FindOptions, Where } from '../persistence/data-store.interface.js';
12
+ import { getModelDescriptor } from '../persistence/model-metadata.registry.js';
13
+ import { getSyncAccessListener } from '../persistence/sync-access.registry.js';
12
14
  import { UnsupportedCapabilityError } from '../persistence/unsupported-capability.error.js';
15
+ import { isDuplicateIdError } from '../persistence/mongoose-data-store.js';
16
+ import { includeTombstones, syncOfSchema } from '../persistence/mongo/sync-schema.plugin.js';
17
+ import type { SyncDescriptor } from '../persistence/model-descriptor.interface.js';
18
+ import {
19
+ assertClientId,
20
+ assertExpectedVersion,
21
+ insertWithServerId,
22
+ suppliedId,
23
+ VersionConflictError,
24
+ } from '../persistence/policies/sync.policy.js';
13
25
  import { CoreModel } from '../models/core-model.model.js';
14
26
  import { PaginationInfo } from '../models/pagination-info.model.js';
15
27
  import { PlainObject } from '../types/plain-object.type.js';
@@ -74,14 +86,36 @@ export abstract class CrudService<
74
86
  return this.process(
75
87
  async (data) => {
76
88
  const currentUserId = serviceOptions?.currentUser?.id;
89
+ const createId = serviceOptions?.createId;
77
90
  if (this.usesNeutralStore) {
78
- return this.dataStore.insert({ ...data.input, createdBy: currentUserId, updatedBy: currentUserId } as any);
91
+ return this.syncChanged(
92
+ await this.dataStore.insert({
93
+ ...data.input,
94
+ ...(createId === undefined || createId === null ? {} : { id: createId }),
95
+ createdBy: currentUserId,
96
+ updatedBy: currentUserId,
97
+ } as any),
98
+ );
79
99
  }
80
- return new (this.mainDbModel as MongooseModel<Document & Model>)({
81
- ...data.input,
82
- createdBy: currentUserId,
83
- updatedBy: currentUserId,
84
- }).save();
100
+ const build = (id?: string) =>
101
+ new (this.mainDbModel as MongooseModel<Document & Model>)({
102
+ ...data.input,
103
+ ...(id === undefined ? {} : { _id: id }),
104
+ createdBy: currentUserId,
105
+ updatedBy: currentUserId,
106
+ });
107
+ const sync = this.mongoSync;
108
+ if (!sync) {
109
+ return build(createId ?? undefined).save();
110
+ }
111
+ // A synchronized model keeps a client's id in its format and replaces a taken server id (plan §3.3, E6)
112
+ const supplied = createId ?? suppliedId(data.input ?? {});
113
+ if (supplied !== undefined && supplied !== null) {
114
+ return this.syncChanged(
115
+ await build(assertClientId(supplied, sync.idFormat, this.mainDbModel.modelName)).save(),
116
+ );
117
+ }
118
+ return this.syncChanged(await insertWithServerId(sync.idFormat, (id) => build(id).save(), isDuplicateIdError));
85
119
  },
86
120
  { input, serviceOptions },
87
121
  );
@@ -110,8 +144,8 @@ export abstract class CrudService<
110
144
  /**
111
145
  * Get distinct values of a property
112
146
  */
113
- distinct(property: string): Promise<string[]> {
114
- return this.dataStore.distinct(property) as Promise<string[]>;
147
+ distinct(property: string, serviceOptions?: ServiceOptions): Promise<string[]> {
148
+ return this.dataStore.distinct(property, undefined, tombstoneOption(serviceOptions)) as Promise<string[]>;
115
149
  }
116
150
 
117
151
  /**
@@ -119,13 +153,16 @@ export abstract class CrudService<
119
153
  */
120
154
  async get(id: string, serviceOptions?: ServiceOptions): Promise<Model> {
121
155
  if (this.usesNeutralStore) {
122
- const record = await this.dataStore.findById(id, neutralSelect(serviceOptions?.select));
156
+ const record = await this.dataStore.findById(id, {
157
+ ...neutralSelect(serviceOptions?.select),
158
+ ...tombstoneOption(serviceOptions),
159
+ });
123
160
  if (!record) {
124
161
  throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
125
162
  }
126
163
  return this.process(async () => record, { dbObject: record, serviceOptions });
127
164
  }
128
- const query = this.mainDbModel.findById(id);
165
+ const query = this.withTombstones(this.mainDbModel.findById(id), serviceOptions);
129
166
  if (serviceOptions?.select) {
130
167
  query.select(serviceOptions.select);
131
168
  }
@@ -190,7 +227,10 @@ export abstract class CrudService<
190
227
  }
191
228
 
192
229
  // Find in DB
193
- let find = this.mainDbModel.find(filterQuery.filterQuery, null, filterQuery.queryOptions);
230
+ let find = this.withTombstones(
231
+ this.mainDbModel.find(filterQuery.filterQuery, null, filterQuery.queryOptions),
232
+ serviceOptions,
233
+ );
194
234
  const collation = serviceOptions?.collation || ConfigService.get('mongoose.collation');
195
235
  if (collation) {
196
236
  find = find.collation(collation);
@@ -261,7 +301,7 @@ export abstract class CrudService<
261
301
  const { options, where } = neutralQuery(filterQuery, serviceOptions);
262
302
  const [items, totalCount] = await Promise.all([
263
303
  this.dataStore.find(where, filter?.samples ? { ...options, sample: filter.samples } : options),
264
- this.dataStore.count(where),
304
+ this.dataStore.count(where, tombstoneOption(serviceOptions)),
265
305
  ]);
266
306
  const queryOptions = filterQuery.queryOptions || {};
267
307
  return {
@@ -323,7 +363,12 @@ export abstract class CrudService<
323
363
  // Find and process db items
324
364
  const collation = serviceOptions?.collation || ConfigService.get('mongoose.collation');
325
365
  const dbResult =
326
- (await this.mainDbModel.aggregate(aggregation, collation ? { collation } : {}).exec())[0] || {};
366
+ (
367
+ await this.withTombstones(
368
+ this.mainDbModel.aggregate(aggregation, collation ? { collation } : {}),
369
+ serviceOptions,
370
+ ).exec()
371
+ )[0] || {};
327
372
  dbResult.totalCount = dbResult.totalCount?.[0]?.total || 0;
328
373
  dbResult.items = dbResult.items?.map((item) => this.mainDbModel.hydrate(item)) || [];
329
374
 
@@ -443,13 +488,17 @@ export abstract class CrudService<
443
488
  return this.dataStore.findOne(where, {
444
489
  collation: options.collation,
445
490
  exclude: options.exclude,
491
+ includeTombstones: options.includeTombstones,
446
492
  select: options.select,
447
493
  sort: options.sort,
448
494
  });
449
495
  }
450
496
 
451
497
  // Find in DB
452
- let find = this.mainDbModel.findOne(filterQuery.filterQuery, null, filterQuery.queryOptions);
498
+ let find = this.withTombstones(
499
+ this.mainDbModel.findOne(filterQuery.filterQuery, null, filterQuery.queryOptions),
500
+ serviceOptions,
501
+ );
453
502
  const collation = serviceOptions?.collation || ConfigService.get('mongoose.collation');
454
503
  if (collation) {
455
504
  find = find.collation(collation);
@@ -657,18 +706,31 @@ export abstract class CrudService<
657
706
  * Update item via ID
658
707
  */
659
708
  async update(id: string, input: PlainObject<UpdateInput>, serviceOptions?: ServiceOptions): Promise<Model> {
709
+ const expectedVersion = this.expectedVersionOf(serviceOptions);
660
710
  if (this.usesNeutralStore) {
661
711
  const stored = await this.dataStore.findById(id);
662
712
  if (!stored) {
663
713
  throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
664
714
  }
715
+ this.assertVersion(id, stored, expectedVersion);
665
716
  return this.process(
666
717
  async (data) => {
667
718
  // The same write as on MongoDB: the stored record merged with the input, written back whole — minus
668
719
  // the id, which never changes, and the update time, which the store stamps
720
+ const before = this.syncAccessSnapshot(stored);
669
721
  const merged = mergePlain(stored, data.input, { updatedBy: serviceOptions?.currentUser?.id });
670
722
  const { _id: _mongoId, id: _id, updatedAt: _updatedAt, ...patch } = merged as Record<string, any>;
671
- return this.dataStore.updateById(id, patch as any);
723
+ const updated =
724
+ expectedVersion === undefined
725
+ ? await this.dataStore.updateById(id, patch as any)
726
+ : // One statement with the version as its condition: of two changes made on the same version, one applies
727
+ ((await this.dataStore.updateOne({ id, version: expectedVersion } as Where, patch as any)) ??
728
+ this.versionConflict(id));
729
+ if (updated) {
730
+ await this.reportSyncAccessMove(before, merged);
731
+ this.reportSyncChange(before, merged);
732
+ }
733
+ return updated;
672
734
  },
673
735
  { dbObject: stored, input, serviceOptions },
674
736
  );
@@ -677,15 +739,28 @@ export abstract class CrudService<
677
739
  if (!dbObject) {
678
740
  throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
679
741
  }
742
+ this.assertVersion(id, dbObject, expectedVersion);
680
743
  return this.process(
681
744
  async (data) => {
682
745
  const currentUserId = serviceOptions?.currentUser?.id;
746
+ const before = this.syncAccessSnapshot(dbObject);
683
747
  const merged = mergePlain(dbObject, data.input, { updatedBy: currentUserId });
684
- const query = this.mainDbModel.findByIdAndUpdate(id, merged, { returnDocument: 'after' });
748
+ const query =
749
+ expectedVersion === undefined
750
+ ? this.mainDbModel.findByIdAndUpdate(id, merged, { returnDocument: 'after' })
751
+ : this.mainDbModel.findOneAndUpdate({ _id: id, version: expectedVersion } as any, merged, {
752
+ returnDocument: 'after',
753
+ });
685
754
  if (serviceOptions?.select) {
686
755
  query.select(serviceOptions.select);
687
756
  }
688
- return await query.exec();
757
+ const updated = await query.exec();
758
+ if (!updated) {
759
+ return expectedVersion === undefined ? updated : this.versionConflict(id);
760
+ }
761
+ await this.reportSyncAccessMove(before, merged);
762
+ this.reportSyncChange(before, merged);
763
+ return updated;
689
764
  },
690
765
  { dbObject, input, serviceOptions },
691
766
  );
@@ -715,14 +790,17 @@ export abstract class CrudService<
715
790
  * Delete item via ID
716
791
  */
717
792
  async delete(id: string, serviceOptions?: ServiceOptions): Promise<Model> {
793
+ const expectedVersion = this.expectedVersionOf(serviceOptions);
718
794
  if (this.usesNeutralStore) {
719
795
  const stored = await this.dataStore.findById(id, neutralSelect(serviceOptions?.select));
720
796
  if (!stored) {
721
797
  throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
722
798
  }
799
+ this.assertVersion(id, stored, expectedVersion);
723
800
  return this.process(
724
801
  async () => {
725
- await this.dataStore.deleteById(id);
802
+ await this.removeOrTombstone(id, expectedVersion);
803
+ this.reportSyncChange(stored);
726
804
  return stored;
727
805
  },
728
806
  { dbObject: stored, serviceOptions },
@@ -736,15 +814,169 @@ export abstract class CrudService<
736
814
  if (!dbObject) {
737
815
  throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
738
816
  }
817
+ this.assertVersion(id, dbObject, expectedVersion);
739
818
  return this.process(
740
819
  async () => {
741
- await this.mainDbModel.findByIdAndDelete(id).exec();
820
+ if (this.mongoSync) {
821
+ // A synchronized model's delete leaves a tombstone, which is how clients learn of it
822
+ await this.removeOrTombstone(id, expectedVersion);
823
+ this.reportSyncChange(dbObject);
824
+ } else {
825
+ await this.mainDbModel.findByIdAndDelete(id).exec();
826
+ }
742
827
  return dbObject;
743
828
  },
744
829
  { dbObject, serviceOptions },
745
830
  );
746
831
  }
747
832
 
833
+ // -- synchronized models (`@PersistedModel({ sync })`) ------------------------------------------------------
834
+
835
+ /** The sync declaration of the main model on its MongoDB path, read from the Mongoose schema */
836
+ protected get mongoSync(): SyncDescriptor | undefined {
837
+ return this.usesNeutralStore ? undefined : syncOfSchema(this.mainDbModel?.schema);
838
+ }
839
+
840
+ /** Whether the main model is synchronized, on whichever database it lives */
841
+ protected get isSynchronized(): boolean {
842
+ return !!this.dataStore?.capabilities.changeOrder;
843
+ }
844
+
845
+ /** A Mongoose query or aggregation that also sees tombstones when the caller asked (`includeTombstones`) */
846
+ protected withTombstones<Q extends object>(query: Q, serviceOptions?: ServiceOptions): Q {
847
+ return serviceOptions?.includeTombstones && this.mongoSync ? includeTombstones(query) : query;
848
+ }
849
+
850
+ /** `serviceOptions.expectedVersion`, checked; only a synchronized model has a version */
851
+ protected expectedVersionOf(serviceOptions?: ServiceOptions): number | undefined {
852
+ if (serviceOptions?.expectedVersion === undefined || serviceOptions.expectedVersion === null) {
853
+ return undefined;
854
+ }
855
+ if (!this.isSynchronized) {
856
+ throw new UnsupportedCapabilityError(
857
+ `expectedVersion: ${this.mainModelConstructor?.name ?? this.dataStore?.modelName} is not a synchronized model ` +
858
+ '(@PersistedModel({ sync }))',
859
+ );
860
+ }
861
+ return assertExpectedVersion(serviceOptions.expectedVersion);
862
+ }
863
+
864
+ /** The early answer to a conditional change on a record that already has another version */
865
+ protected assertVersion(id: string, stored: any, expectedVersion: number | undefined): void {
866
+ if (expectedVersion !== undefined && stored?.version !== expectedVersion) {
867
+ this.versionConflict(id);
868
+ }
869
+ }
870
+
871
+ protected versionConflict(id: string): never {
872
+ throw new VersionConflictError(this.mainModelConstructor?.name ?? this.dataStore.modelName, id);
873
+ }
874
+
875
+ /**
876
+ * The tenant and scope fields of a stored record as they are BEFORE a write — taken before `mergePlain()`, which
877
+ * merges the input INTO the stored object. `undefined` when nobody listens or the model is not synchronized.
878
+ */
879
+ protected syncAccessSnapshot(stored: Record<string, any> | undefined): Record<string, any> | undefined {
880
+ if (!stored || !getSyncAccessListener() || !this.isSynchronized) {
881
+ return undefined;
882
+ }
883
+ const scope = getModelDescriptor(this.dataStore.modelName)?.sync?.scope ?? [];
884
+ return Object.fromEntries(
885
+ ['tenantId', ...scope].map((field) => [field, Array.isArray(stored[field]) ? [...stored[field]] : stored[field]]),
886
+ );
887
+ }
888
+
889
+ /**
890
+ * Tell the sync module that this write took a synchronized record away from users who could see it: moved to
891
+ * another tenant, or a scope field (`sync.scope`, e.g. `ownerId`) changed away from a user (offline-sync plan §3.8).
892
+ * Their clients then answer the next pull with a full resync and lose the local copy. A no-op without the module.
893
+ * A failure is logged, never thrown: the write it follows has happened.
894
+ */
895
+ protected async reportSyncAccessMove(
896
+ stored: Record<string, any> | undefined,
897
+ written: Record<string, any>,
898
+ ): Promise<void> {
899
+ const listener = getSyncAccessListener();
900
+ if (!listener || !stored) {
901
+ return;
902
+ }
903
+ const model = this.dataStore.modelName;
904
+ const ids = (value: unknown): string[] =>
905
+ value === undefined || value === null
906
+ ? []
907
+ : (Array.isArray(value) ? value : [value]).flatMap((item) =>
908
+ item === undefined || item === null ? [] : [String(getStringIds(item))],
909
+ );
910
+ const after = (field: string) => (field in written ? written[field] : stored[field]);
911
+ const [tenantBefore] = ids(stored.tenantId);
912
+ const tenantChanged = !!tenantBefore && ids(after('tenantId'))[0] !== tenantBefore;
913
+ const usersLost = (getModelDescriptor(model)?.sync?.scope ?? []).flatMap((field) => {
914
+ const now = new Set(ids(after(field)));
915
+ return ids(stored[field]).filter((user) => !now.has(user));
916
+ });
917
+ if (!tenantChanged && !usersLost.length) {
918
+ return;
919
+ }
920
+ try {
921
+ await listener.recordMoved({ model, tenantChanged, tenantId: tenantBefore, usersLost: [...new Set(usersLost)] });
922
+ } catch (error) {
923
+ Logger.error(
924
+ `Sync access epoch for a moved ${model} record not bumped — affected clients keep their copy until their next ` +
925
+ `full resync: ${(error as Error)?.message ?? error}`,
926
+ this.constructor.name,
927
+ );
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Tell the sync module that a write of a synchronized record succeeded, so the subscribers of its hints learn of it
933
+ * (offline-sync plan §3.9) — with the tenants the record was in before and after the write. A no-op without the
934
+ * module and on any other model; a failure is logged, never thrown: the write has happened.
935
+ */
936
+ protected reportSyncChange(...records: (Record<string, any> | null | undefined)[]): void {
937
+ const listener = getSyncAccessListener();
938
+ if (!listener?.recordChanged || !this.isSynchronized) {
939
+ return;
940
+ }
941
+ const tenantIds = new Set<string>();
942
+ for (const record of records) {
943
+ const tenantId = record?.tenantId;
944
+ if (tenantId !== undefined && tenantId !== null && tenantId !== '') {
945
+ tenantIds.add(String(getStringIds(tenantId)));
946
+ }
947
+ }
948
+ try {
949
+ listener.recordChanged({ model: this.dataStore.modelName, tenantIds: [...tenantIds] });
950
+ } catch (error) {
951
+ Logger.warn(
952
+ `Sync hint for a ${this.dataStore.modelName} write not sent — clients learn of it at their next pull: ` +
953
+ `${(error as Error)?.message ?? error}`,
954
+ this.constructor.name,
955
+ );
956
+ }
957
+ }
958
+
959
+ /** `reportSyncChange()` for a created record, which it answers */
960
+ protected syncChanged<R>(created: R): R {
961
+ this.reportSyncChange(created as any);
962
+ return created;
963
+ }
964
+
965
+ /**
966
+ * Delete through the store: a synchronized model's record becomes a tombstone (conditional on `expectedVersion`),
967
+ * any other model's is removed
968
+ */
969
+ protected async removeOrTombstone(id: string, expectedVersion: number | undefined): Promise<void> {
970
+ if (expectedVersion === undefined) {
971
+ await this.dataStore.deleteById(id);
972
+ return;
973
+ }
974
+ const removed = await this.dataStore.deleteOne({ id, version: expectedVersion } as Where);
975
+ if (!removed) {
976
+ this.versionConflict(id);
977
+ }
978
+ }
979
+
748
980
  /**
749
981
  * Delete item via ID without checks or restrictions
750
982
  * Warning: Disables the handling of rights and restrictions!
@@ -897,6 +1129,11 @@ function neutralSelect(select: unknown): Pick<FindOptions, 'exclude' | 'select'>
897
1129
  };
898
1130
  }
899
1131
 
1132
+ /** `includeTombstones` of the service options as a store option */
1133
+ function tombstoneOption(serviceOptions?: ServiceOptions): { includeTombstones?: boolean } {
1134
+ return serviceOptions?.includeTombstones ? { includeTombstones: true } : {};
1135
+ }
1136
+
900
1137
  /** The filter and query options of a CrudService call as the neutral filter and find options */
901
1138
  function neutralQuery(
902
1139
  query: { filterQuery?: unknown; queryOptions?: QueryOptions },
@@ -906,6 +1143,7 @@ function neutralQuery(
906
1143
  const options: FindOptions = {
907
1144
  collation: (serviceOptions?.collation || ConfigService.get('mongoose.collation')) as Record<string, unknown>,
908
1145
  ...neutralSelect(serviceOptions?.select),
1146
+ ...tombstoneOption(serviceOptions),
909
1147
  };
910
1148
  const skip = queryOptions.skip ?? queryOptions.offset;
911
1149
  const limit = queryOptions.limit ?? queryOptions.take;