@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
@@ -4,14 +4,32 @@ import { ConfigService } from '../../services/config.service.js';
4
4
  import type {
5
5
  DataStore,
6
6
  DataStoreUpdate,
7
+ DeleteOptions,
7
8
  FindOptions,
8
9
  PushOptions,
10
+ ReadOptions,
9
11
  StoreCapabilities,
10
12
  StoredRecord,
11
13
  UpdateOptions,
12
14
  Where,
13
15
  } from '../data-store.interface.js';
14
16
  import { generateId, isWellFormedId } from '../id-format.js';
17
+ import {
18
+ assertClientId,
19
+ generateSyncId,
20
+ insertWithServerId,
21
+ suppliedId,
22
+ syncInsertRecord,
23
+ tombstoneClearedFields,
24
+ } from '../policies/sync.policy.js';
25
+ import {
26
+ liveFilter,
27
+ SYNC_DELETED_FIELD,
28
+ SYNC_SEQUENCE_FIELD,
29
+ SYNC_SEQUENCE_PENDING,
30
+ SYNC_SYSTEM_FIELDS,
31
+ SYNC_VERSION_FIELD,
32
+ } from '../sync-model.js';
15
33
  import type { FieldDescriptor, ModelDescriptor } from '../model-descriptor.interface.js';
16
34
  import { resolveAuditActorId, stampAuditOnUpdate, stampAuditOnWrite } from '../policies/audit.policy.js';
17
35
  import { hashPassword, hashUpdatePassword } from '../policies/password.policy.js';
@@ -36,6 +54,7 @@ import {
36
54
  import { computedDefault } from '../mongo/declared-schema.view.js';
37
55
  import type { DeclaredSchema } from '../mongo/model-declarations.js';
38
56
  import { tableName } from './sql-schema.shared.js';
57
+ import { syncSequenceName } from './postgres/postgres-schema.compiler.js';
39
58
  import { compilePostgresUpdate, pullExpression } from './postgres/postgres-update.compiler.js';
40
59
  import { compilePostgresPath, compilePostgresWhere } from './postgres/postgres-where.compiler.js';
41
60
  import { castFilterValue } from './sql-cast.js';
@@ -64,6 +83,25 @@ export interface SqlDataStoreOptions {
64
83
  schema?: DeclaredSchema | { path(name: string): any };
65
84
  }
66
85
 
86
+ /** One statement inside a store transaction */
87
+ export type SqlRun = <R = Record<string, any>>(statement: SqlStatement) => Promise<{ rowCount: number; rows: R[] }>;
88
+
89
+ /** Internal: an update the store itself writes (a tombstone), which may set `deletedAt` */
90
+ interface WriteOptions extends UpdateOptions {
91
+ tombstone?: boolean;
92
+ }
93
+
94
+ /** How many rows one statement of a multi-row write of a synchronized model numbers */
95
+ const SYNC_CHUNK = 500;
96
+
97
+ /** The capabilities a store of this model reports: a synchronized model adds its change order and id format */
98
+ export function storeCapabilities(
99
+ base: Readonly<StoreCapabilities>,
100
+ model: ModelDescriptor,
101
+ ): Readonly<StoreCapabilities> {
102
+ return model.sync ? Object.freeze({ ...base, changeOrder: 'seq', idType: model.sync.idFormat }) : base;
103
+ }
104
+
67
105
  const SCALAR_KINDS = new Set([
68
106
  'bigint',
69
107
  'boolean',
@@ -98,7 +136,7 @@ const UPDATE_ONE_ATTEMPTS = 3;
98
136
  * table is absent from the record — SQL cannot tell a stored null from an absent value.
99
137
  */
100
138
  export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
101
- readonly capabilities: Readonly<StoreCapabilities> = POSTGRES_STORE_CAPABILITIES;
139
+ readonly capabilities: Readonly<StoreCapabilities>;
102
140
  readonly driver: string = 'postgres';
103
141
  protected readonly logger = new Logger(SqlDataStore.name);
104
142
  protected readonly table: string;
@@ -111,23 +149,29 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
111
149
  protected readonly model: ModelDescriptor,
112
150
  protected readonly options: SqlDataStoreOptions = {},
113
151
  ) {
152
+ this.capabilities = storeCapabilities(this.baseCapabilities(), model);
114
153
  this.table = this.quote(tableName(model));
115
154
  for (const field of Object.values(model.fields)) {
116
155
  this.fields.set(columnName(field.name), field);
117
156
  }
118
157
  }
119
158
 
159
+ /** What the database offers through this store, before the model's own additions */
160
+ protected baseCapabilities(): Readonly<StoreCapabilities> {
161
+ return POSTGRES_STORE_CAPABILITIES;
162
+ }
163
+
120
164
  get modelName(): string {
121
165
  return this.model.name;
122
166
  }
123
167
 
124
168
  // -- reads ----------------------------------------------------------------------------------------------
125
169
 
126
- async count(where?: Where<T>): Promise<number> {
170
+ async count(where?: Where<T>, options: ReadOptions = {}): Promise<number> {
127
171
  const params = new SqlParams();
128
172
  const { rows } = await this.run<{ n: number }>({
129
173
  params: params.values,
130
- text: `SELECT count(*)::int AS n FROM ${this.table} WHERE ${this.where(where, params)}`,
174
+ text: `SELECT count(*)::int AS n FROM ${this.table} WHERE ${this.where(where, params, options)}`,
131
175
  });
132
176
  return rows[0].n;
133
177
  }
@@ -151,10 +195,10 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
151
195
  return { count: rows[0].n, sum: Number(rows[0].total) };
152
196
  }
153
197
 
154
- async distinct(field: string, where?: Where<T>): Promise<unknown[]> {
198
+ async distinct(field: string, where?: Where<T>, options: ReadOptions = {}): Promise<unknown[]> {
155
199
  const params = new SqlParams();
156
200
  const path = compilePostgresPath(this.model, field, params);
157
- const condition = this.where(where, params);
201
+ const condition = this.where(where, params, options);
158
202
  const text =
159
203
  path.kind === 'array'
160
204
  ? `SELECT DISTINCT d.v AS v FROM ${this.table}, jsonb_array_elements(CASE WHEN jsonb_typeof(${path.expr}) = 'array' THEN ${path.expr} ELSE '[]'::jsonb END) AS d(v) WHERE ${condition}`
@@ -166,7 +210,7 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
166
210
  async find(where?: Where<T>, options: FindOptions = {}): Promise<StoredRecord<T>[]> {
167
211
  const sample = options.sample === undefined ? undefined : assertSampleSize(options.sample);
168
212
  const params = new SqlParams();
169
- let text = `SELECT ${this.selectList(options.select, options.exclude)} FROM ${this.table} WHERE ${this.where(where, params)}`;
213
+ let text = `SELECT ${this.selectList(options.select, options.exclude)} FROM ${this.table} WHERE ${this.where(where, params, options)}`;
170
214
  text += this.orderBy(options, params);
171
215
  if (options.limit !== undefined) {
172
216
  text += ` LIMIT ${params.add(assertNonNegativeInteger(options.limit, 'limit'))}`;
@@ -182,7 +226,10 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
182
226
  return this.toRecords(rows, options.select);
183
227
  }
184
228
 
185
- async findById(id: string, options: Pick<FindOptions, 'exclude' | 'select'> = {}): Promise<null | StoredRecord<T>> {
229
+ async findById(
230
+ id: string,
231
+ options: Pick<FindOptions, 'exclude' | 'includeTombstones' | 'select'> = {},
232
+ ): Promise<null | StoredRecord<T>> {
186
233
  return this.findOne({ id } as Where<T>, options);
187
234
  }
188
235
 
@@ -199,8 +246,14 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
199
246
  params: SqlParams,
200
247
  ): { columns: string[]; id: string; values: string[] } {
201
248
  const supplied = doc.id ?? doc._id;
202
- const id =
203
- supplied !== undefined && isWellFormedId(String(supplied)) ? String(supplied).toLowerCase() : generateId();
249
+ const id = this.model.sync
250
+ ? // A synchronized model keeps a client's id in its format and refuses any other (plan §3.3, E6)
251
+ supplied === undefined || supplied === null
252
+ ? generateSyncId(this.model.sync.idFormat)
253
+ : assertClientId(supplied, this.model.sync.idFormat, this.modelName)
254
+ : supplied !== undefined && isWellFormedId(String(supplied))
255
+ ? String(supplied).toLowerCase()
256
+ : generateId();
204
257
  const columns = [this.quote('id')];
205
258
  const values = [params.add(id)];
206
259
  for (const [key, value] of Object.entries(doc)) {
@@ -217,13 +270,15 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
217
270
 
218
271
  async insert(record: Partial<T>): Promise<StoredRecord<T>> {
219
272
  const doc = await this.beforeInsert({ ...(record as Record<string, any>) });
220
- const params = new SqlParams();
221
- const { columns, values } = this.insertValues(doc, params);
222
- const { rows } = await this.run({
223
- params: params.values,
224
- text: `INSERT INTO ${this.table} (${columns.join(', ')}) VALUES (${values.join(', ')}) RETURNING ${this.selectList()}`,
273
+ return this.withServerId(doc, async (prepared) => {
274
+ const params = new SqlParams();
275
+ const { columns, values } = this.insertValues(prepared, params);
276
+ const { rows } = await this.run({
277
+ params: params.values,
278
+ text: `INSERT INTO ${this.table} (${columns.join(', ')}) VALUES (${values.join(', ')}) RETURNING ${this.selectList()}`,
279
+ });
280
+ return this.toRecords(rows)[0];
225
281
  });
226
- return this.toRecords(rows)[0];
227
282
  }
228
283
 
229
284
  async updateById(
@@ -235,28 +290,32 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
235
290
  const params = new SqlParams();
236
291
  const assignments = this.compileUpdate(patch, params);
237
292
  if (!assignments.length) {
238
- return this.findById(id);
293
+ return this.findById(id, options);
239
294
  }
240
295
  const { rows } = await this.run({
241
296
  params: params.values,
242
- text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE ${this.where({ id } as Where<T>, params)} RETURNING ${this.selectList()}`,
297
+ text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE ${this.where({ id } as Where<T>, params, options)} RETURNING ${this.selectList()}`,
243
298
  });
244
299
  return this.toRecords(rows)[0] ?? null;
245
300
  }
246
301
 
247
- async updateOne(where: Where<T>, update: DataStoreUpdate<T>): Promise<null | StoredRecord<T>> {
248
- const patch = await this.preparePatch(update);
302
+ async updateOne(
303
+ where: Where<T>,
304
+ update: DataStoreUpdate<T>,
305
+ options: WriteOptions = {},
306
+ ): Promise<null | StoredRecord<T>> {
307
+ const patch = await this.preparePatch(update, options);
249
308
  for (let attempt = 1; ; attempt++) {
250
309
  const params = new SqlParams();
251
310
  const assignments = this.compileUpdate(patch, params);
252
311
  if (!assignments.length) {
253
- return this.findOne(where);
312
+ return this.findOne(where, options);
254
313
  }
255
314
  // The sub-select locks the row it picks, and a row a concurrent caller changed meanwhile is re-checked and
256
315
  // skipped for the next match; the condition is repeated outside so the row provably still matches when it is
257
316
  // written — the guarantee of MongoDB's findOneAndUpdate
258
- const match = this.where(where, params);
259
- const recheck = this.where(where, params);
317
+ const match = this.where(where, params, options);
318
+ const recheck = this.where(where, params, options);
260
319
  const { rows } = await this.run({
261
320
  params: params.values,
262
321
  text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE "id" = (SELECT "id" FROM ${this.table} WHERE ${match} LIMIT 1 FOR UPDATE) AND ${recheck} RETURNING ${this.selectList()}`,
@@ -266,35 +325,44 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
266
325
  }
267
326
  // Nothing written: nothing matches any more — or the chosen row stopped matching between the pick and the write
268
327
  // while another one still does. Only the second is retried
269
- if (attempt >= UPDATE_ONE_ATTEMPTS || !(await this.exists(where))) {
328
+ if (attempt >= UPDATE_ONE_ATTEMPTS || !(await this.exists(where, options))) {
270
329
  return null;
271
330
  }
272
331
  }
273
332
  }
274
333
 
275
- async deleteById(id: string): Promise<null | StoredRecord<T>> {
334
+ async deleteById(id: string, options: DeleteOptions = {}): Promise<null | StoredRecord<T>> {
335
+ if (this.model.sync && !options.hardDelete) {
336
+ return this.tombstone({ id } as Where<T>, options);
337
+ }
276
338
  const params = new SqlParams();
277
339
  const { rows } = await this.run({
278
340
  params: params.values,
279
- text: `DELETE FROM ${this.table} WHERE ${this.where({ id } as Where<T>, params)} RETURNING ${this.selectList()}`,
341
+ text: `DELETE FROM ${this.table} WHERE ${this.where({ id } as Where<T>, params, options)} RETURNING ${this.selectList()}`,
280
342
  });
281
343
  return this.toRecords(rows)[0] ?? null;
282
344
  }
283
345
 
284
- async deleteMany(where: Where<T>): Promise<number> {
346
+ async deleteMany(where: Where<T>, options: DeleteOptions = {}): Promise<number> {
347
+ if (this.model.sync && !options.hardDelete) {
348
+ return this.updateRows(where, await this.tombstonePatch(), options);
349
+ }
285
350
  const params = new SqlParams();
286
351
  const { rowCount } = await this.run({
287
352
  params: params.values,
288
- text: `DELETE FROM ${this.table} WHERE ${this.where(where, params)}`,
353
+ text: `DELETE FROM ${this.table} WHERE ${this.where(where, params, options)}`,
289
354
  });
290
355
  return rowCount;
291
356
  }
292
357
 
293
- async deleteOne(where: Where<T>): Promise<null | StoredRecord<T>> {
358
+ async deleteOne(where: Where<T>, options: DeleteOptions = {}): Promise<null | StoredRecord<T>> {
359
+ if (this.model.sync && !options.hardDelete) {
360
+ return this.tombstone(where, options);
361
+ }
294
362
  // Picks and locks one match, and re-checks it at the write — the same guarantee as updateOne()
295
363
  const params = new SqlParams();
296
- const match = this.where(where, params);
297
- const recheck = this.where(where, params);
364
+ const match = this.where(where, params, options);
365
+ const recheck = this.where(where, params, options);
298
366
  const { rows } = await this.run({
299
367
  params: params.values,
300
368
  text: `DELETE FROM ${this.table} WHERE "id" = (SELECT "id" FROM ${this.table} WHERE ${match} LIMIT 1 FOR UPDATE) AND ${recheck} RETURNING ${this.selectList()}`,
@@ -302,8 +370,11 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
302
370
  return this.toRecords(rows)[0] ?? null;
303
371
  }
304
372
 
305
- async updateMany(where: Where<T>, update: DataStoreUpdate<T>): Promise<number> {
306
- const patch = await this.preparePatch(update);
373
+ async updateMany(where: Where<T>, update: DataStoreUpdate<T>, options: UpdateOptions = {}): Promise<number> {
374
+ const patch = await this.preparePatch(update, options);
375
+ if (this.model.sync) {
376
+ return this.updateRows(where, patch, options);
377
+ }
307
378
  const params = new SqlParams();
308
379
  const assignments = this.compileUpdate(patch, params);
309
380
  if (!assignments.length) {
@@ -365,6 +436,165 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
365
436
  await this.updateColumn(id, column, pullExpression(this.model, field, column, condition, params), params);
366
437
  }
367
438
 
439
+ // -- synchronized models ------------------------------------------------------------------------------
440
+
441
+ /**
442
+ * Insert with the id the caller supplied, or a server id that is generated anew while it is taken (plan §3.3, E6)
443
+ */
444
+ protected async withServerId<R>(
445
+ doc: Record<string, any>,
446
+ attempt: (doc: Record<string, any>) => Promise<R>,
447
+ ): Promise<R> {
448
+ const supplied = suppliedId(doc);
449
+ if (!this.model.sync || (supplied !== undefined && supplied !== null)) {
450
+ return attempt(doc);
451
+ }
452
+ const { _id: _ignored, ...rest } = doc;
453
+ return insertWithServerId(
454
+ this.model.sync.idFormat,
455
+ (id) => attempt({ ...rest, id }),
456
+ (error) => error instanceof DuplicateKeyError && error.primaryKey,
457
+ );
458
+ }
459
+
460
+ /** The update that turns a record into a tombstone: `deletedAt` set, every content field cleared (protocol §2) */
461
+ protected async tombstonePatch(): Promise<Record<string, any>> {
462
+ const cleared = tombstoneClearedFields(Object.keys(this.model.fields), this.model.sync);
463
+ return this.preparePatch(
464
+ {
465
+ $set: { [SYNC_DELETED_FIELD]: new Date() },
466
+ ...(cleared.length ? { $unset: Object.fromEntries(cleared.map((field) => [field, 1])) } : {}),
467
+ } as unknown as DataStoreUpdate<T>,
468
+ { tombstone: true },
469
+ );
470
+ }
471
+
472
+ /** Turn the first record matching `where` into a tombstone and return it as it was before */
473
+ protected async tombstone(where: Where<T>, options: DeleteOptions): Promise<null | StoredRecord<T>> {
474
+ const patch = await this.tombstonePatch();
475
+ return this.transact(async (run) => {
476
+ const params = new SqlParams();
477
+ const { rows } = await run({
478
+ params: params.values,
479
+ text: `SELECT ${this.selectList()} FROM ${this.table} WHERE ${this.where(where, params, options)} LIMIT 1 FOR UPDATE`,
480
+ });
481
+ if (!rows.length) {
482
+ return null;
483
+ }
484
+ // Mapped before the write: a row outside the tenant scope fails the row check and nothing changes
485
+ const [record] = this.toRecords(rows);
486
+ const write = new SqlParams();
487
+ const assignments = this.compileUpdate(patch, write);
488
+ await run({
489
+ params: write.values,
490
+ text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE ${this.quote('id')} = ${write.add(rows[0].id)}`,
491
+ });
492
+ await this.numberPending(run, [rows[0].id]);
493
+ return record;
494
+ });
495
+ }
496
+
497
+ /**
498
+ * A write to several rows of a synchronized model (plan §3.4, C1d): the rows are locked first, then the transaction
499
+ * takes its change numbers, then its final statements write the rows with their numbers set explicitly — the order
500
+ * is always "rows first, counter last", so the multi-row write cannot deadlock with another store write — on MySQL
501
+ * and MariaDB, whose `FOR UPDATE` also locks index gaps, only because every store write there takes its number last
502
+ * too (`numbersLast()`). The numbers commit with the write. Returns how many rows matched.
503
+ */
504
+ protected async updateRows(where: Where<T>, patch: Record<string, any>, options: UpdateOptions): Promise<number> {
505
+ // The rows get their numbers in the final statements, not the pending marker
506
+ const { [SYNC_SEQUENCE_FIELD]: _pending, ...set } = patch.$set ?? {};
507
+ const update = { ...patch, $set: set };
508
+ return this.transact(async (run) => {
509
+ const params = new SqlParams();
510
+ const { rows } = await run<{ id: string }>({
511
+ params: params.values,
512
+ text: `SELECT ${this.quote('id')} AS id FROM ${this.table} WHERE ${this.where(where, params, options)} ORDER BY ${this.quote('id')} FOR UPDATE`,
513
+ });
514
+ if (!rows.length) {
515
+ return 0;
516
+ }
517
+ const ids = rows.map((row) => row.id);
518
+ await this.writeChangeNumbers(run, ids, await this.takeChangeNumbers(run, ids.length), update);
519
+ return ids.length;
520
+ });
521
+ }
522
+
523
+ /**
524
+ * Whether a store write takes its change number LAST, in its own transaction, instead of through the trigger: the
525
+ * write marks the row pending (`sync_seq = -1`, which the trigger leaves alone), then the number is taken and set.
526
+ * Needed where a trigger's counter would be held while the write still waits for a lock — MySQL and MariaDB, whose
527
+ * inserts wait for gap locks after the BEFORE INSERT trigger ran. PostgreSQL takes no gap locks: the trigger suffices
528
+ */
529
+ protected numbersLast(): boolean {
530
+ return false;
531
+ }
532
+
533
+ /** Number the rows a store write marked pending, as the last step of its transaction (`numbersLast()`) */
534
+ protected async numberPending(run: SqlRun, ids: readonly string[]): Promise<void> {
535
+ if (!this.model.sync || !this.numbersLast() || !ids.length) {
536
+ return;
537
+ }
538
+ await this.writeChangeNumbers(run, ids, await this.takeChangeNumbers(run, ids.length));
539
+ }
540
+
541
+ /** Set the rows' change numbers explicitly (the trigger keeps them), with the update's other assignments, in chunks */
542
+ protected async writeChangeNumbers(
543
+ run: SqlRun,
544
+ ids: readonly string[],
545
+ numbers: readonly string[],
546
+ update: Record<string, any> = {},
547
+ ): Promise<void> {
548
+ for (let start = 0; start < ids.length; start += SYNC_CHUNK) {
549
+ const chunk = ids.slice(start, start + SYNC_CHUNK);
550
+ const write = new SqlParams();
551
+ const cases = chunk
552
+ .map((id, index) => `WHEN ${write.add(id)} THEN ${this.bigintParam(write, numbers[start + index])}`)
553
+ .join(' ');
554
+ const assignments = [
555
+ ...(Object.keys(update).length ? this.compileUpdate(update, write) : []),
556
+ `${this.quote('sync_seq')} = CASE ${this.quote('id')} ${cases} END`,
557
+ ];
558
+ const list = chunk.map((id) => write.add(id)).join(', ');
559
+ await run({
560
+ params: write.values,
561
+ text: `UPDATE ${this.table} SET ${assignments.join(', ')} WHERE ${this.quote('id')} IN (${list})`,
562
+ });
563
+ }
564
+ }
565
+
566
+ /**
567
+ * `count` consecutive change numbers for this table, in commit order: the model's advisory lock, held until the
568
+ * transaction ends, then the sequence — the trigger's own order (`syncFunctionStatements()`)
569
+ */
570
+ protected async takeChangeNumbers(run: SqlRun, count: number): Promise<string[]> {
571
+ const table = tableName(this.model);
572
+ await run({
573
+ params: [table],
574
+ text: `SELECT pg_advisory_xact_lock(hashtext('lenne.tech:sync:' || current_schema() || '.' || $1))`,
575
+ });
576
+ const { rows } = await run<{ n: string }>({
577
+ params: [syncSequenceName(table), count],
578
+ text: `SELECT nextval(format('%I.%I', current_schema(), $1::text)::regclass)::text AS n FROM generate_series(1, $2::int)`,
579
+ });
580
+ return rows.map((row) => row.n).sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : 1));
581
+ }
582
+
583
+ /** A bound bigint value as this database reads it */
584
+ protected bigintParam(params: SqlParams, value: string): string {
585
+ return `${params.add(value)}::bigint`;
586
+ }
587
+
588
+ /** Several statements in one transaction with the request's tenant scope; errors mapped like `run()` */
589
+ protected async transact<R>(fn: (run: SqlRun) => Promise<R>): Promise<R> {
590
+ const scope = resolveTenantScope();
591
+ try {
592
+ return await this.executor.transaction((tx) => fn((statement) => tx.run(statement, scope)));
593
+ } catch (error) {
594
+ throw this.mapError(error);
595
+ }
596
+ }
597
+
368
598
  // -- native access --------------------------------------------------------------------------------------
369
599
 
370
600
  native<N = unknown>(reason: string): N {
@@ -382,7 +612,9 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
382
612
  // -- before-layer ---------------------------------------------------------------------------------------
383
613
 
384
614
  /** The policies the Mongoose plugins apply to `save()`, in their registration order */
385
- protected async beforeInsert(doc: Record<string, any>): Promise<Record<string, any>> {
615
+ protected async beforeInsert(input: Record<string, any>): Promise<Record<string, any>> {
616
+ // A synchronized model's system fields are the store's, whatever the caller sent
617
+ const doc = this.model.sync ? syncInsertRecord(input) : input;
386
618
  if (this.model.tenantScoped) {
387
619
  const tenantId = resolveSingleTenantId(this.modelName);
388
620
  if (tenantId) {
@@ -415,11 +647,23 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
415
647
  if (updatedAt) {
416
648
  doc[updatedAt] = now;
417
649
  }
650
+ if (this.model.sync && this.numbersLast()) {
651
+ doc[SYNC_SEQUENCE_FIELD] = SYNC_SEQUENCE_PENDING;
652
+ }
418
653
  return doc;
419
654
  }
420
655
 
421
656
  /** The policies the Mongoose plugins apply to `findOneAndUpdate()` */
422
- protected async beforeUpdate(update: Record<string, any>, options: UpdateOptions = {}): Promise<Record<string, any>> {
657
+ protected async beforeUpdate(update: Record<string, any>, options: WriteOptions = {}): Promise<Record<string, any>> {
658
+ if (this.model.sync) {
659
+ // Only the store writes a synchronized model's system fields: every write increments the version, and only the
660
+ // tombstone write sets `deletedAt` (plan §3.3, E1). The change position is the trigger's
661
+ stripSyncFields(update, options.tombstone === true);
662
+ update.$inc = { ...update.$inc, [SYNC_VERSION_FIELD]: 1 };
663
+ if (this.numbersLast()) {
664
+ update.$set = { ...update.$set, [SYNC_SEQUENCE_FIELD]: SYNC_SEQUENCE_PENDING };
665
+ }
666
+ }
423
667
  if (this.model.tenantScoped) {
424
668
  const single = resolveSingleTenantId(this.modelName);
425
669
  if (single) {
@@ -451,7 +695,7 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
451
695
  }
452
696
 
453
697
  /** The update as the store writes it: the caller's object untouched, the policies applied, undeclared paths dropped */
454
- protected async preparePatch(update: DataStoreUpdate<T>, options: UpdateOptions = {}): Promise<Record<string, any>> {
698
+ protected async preparePatch(update: DataStoreUpdate<T>, options: WriteOptions = {}): Promise<Record<string, any>> {
455
699
  return this.dropUndeclared(await this.beforeUpdate(clone(update), options));
456
700
  }
457
701
 
@@ -484,12 +728,24 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
484
728
 
485
729
  // -- statements -----------------------------------------------------------------------------------------
486
730
 
487
- /** The caller's filter AND the tenant filter; throws the safety net's 403 before any statement runs */
488
- protected where(where: undefined | Where<T>, params: SqlParams): string {
731
+ /**
732
+ * The caller's filter AND the tenant filter AND — on a synchronized model, unless `includeTombstones` — the live-record
733
+ * condition; throws the safety net's 403 before any statement runs
734
+ */
735
+ protected where(
736
+ where: undefined | Where<T>,
737
+ params: SqlParams,
738
+ options: { includeTombstones?: boolean } = {},
739
+ ): string {
489
740
  const tenantFilter = this.model.tenantScoped ? resolveTenantFilter(this.modelName) : undefined;
490
- const combined =
491
- tenantFilter && Object.keys(tenantFilter).length ? { $and: [where ?? {}, tenantFilter] } : (where ?? {});
492
- return this.compileWhere(combined as Where, params);
741
+ const conditions: Where[] = [where ?? {}];
742
+ if (tenantFilter && Object.keys(tenantFilter).length) {
743
+ conditions.push(tenantFilter);
744
+ }
745
+ if (this.model.sync && !options.includeTombstones) {
746
+ conditions.push(liveFilter());
747
+ }
748
+ return this.compileWhere((conditions.length > 1 ? { $and: conditions } : conditions[0]) as Where, params);
493
749
  }
494
750
 
495
751
  /** The condition of a neutral filter in this database's SQL */
@@ -497,11 +753,11 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
497
753
  return compilePostgresWhere(this.model, where, params);
498
754
  }
499
755
 
500
- protected async exists(where: undefined | Where<T>): Promise<boolean> {
756
+ protected async exists(where: undefined | Where<T>, options: { includeTombstones?: boolean } = {}): Promise<boolean> {
501
757
  const params = new SqlParams();
502
758
  const { rows } = await this.run<{ found: boolean }>({
503
759
  params: params.values,
504
- text: `SELECT EXISTS (SELECT 1 FROM ${this.table} WHERE ${this.where(where, params)}) AS found`,
760
+ text: `SELECT EXISTS (SELECT 1 FROM ${this.table} WHERE ${this.where(where, params, options)}) AS found`,
505
761
  });
506
762
  return rows[0].found;
507
763
  }
@@ -630,7 +886,8 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
630
886
  return undefined;
631
887
  }
632
888
  if (!field.isArray && field.kind === 'bigint') {
633
- return BigInt(value as string);
889
+ // The change position travels as a number: JSON has no bigint, and the sequence stays far below 2^53
890
+ return field.name === SYNC_SEQUENCE_FIELD ? Number(value) : BigInt(value as string);
634
891
  }
635
892
  return value;
636
893
  }
@@ -726,7 +983,10 @@ export class SqlDataStore<T = Record<string, any>> implements DataStore<T> {
726
983
  const code = (error as { code?: unknown })?.code;
727
984
  switch (code) {
728
985
  case '23505':
729
- return new DuplicateKeyError(this.modelName);
986
+ return new DuplicateKeyError(
987
+ this.modelName,
988
+ String((error as { constraint?: unknown }).constraint ?? '').endsWith('_pkey'),
989
+ );
730
990
  case '23514':
731
991
  // The roles CHECK is the only check constraint the framework creates
732
992
  return new BadRequestException(`${this.modelName}: the value is not allowed`);
@@ -757,6 +1017,27 @@ function clone<V>(value: V): V {
757
1017
  return value;
758
1018
  }
759
1019
 
1020
+ /** Remove a synchronized model's system fields from a caller's update; the tombstone write keeps `deletedAt` */
1021
+ function stripSyncFields(update: Record<string, any>, tombstone: boolean): void {
1022
+ const system = (path: string) => {
1023
+ const head = path.split('.', 1)[0];
1024
+ return SYNC_SYSTEM_FIELDS.includes(head) && !(tombstone && head === SYNC_DELETED_FIELD);
1025
+ };
1026
+ for (const [key, value] of Object.entries(update)) {
1027
+ if (!key.startsWith('$')) {
1028
+ if (system(key)) {
1029
+ delete update[key];
1030
+ }
1031
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
1032
+ for (const path of Object.keys(value)) {
1033
+ if (system(path)) {
1034
+ delete value[path];
1035
+ }
1036
+ }
1037
+ }
1038
+ }
1039
+ }
1040
+
760
1041
  function hasOperator(update: Record<string, any>, field: string): boolean {
761
1042
  return Object.values(update).some((part) => part && typeof part === 'object' && field in part) || field in update;
762
1043
  }
@@ -43,7 +43,11 @@ export class DuplicateKeyError extends BadRequestException {
43
43
  override readonly name = 'DuplicateKeyError';
44
44
  readonly code = 11000;
45
45
 
46
- constructor(readonly model: string) {
46
+ /** `primaryKey`: the id itself is taken (an insert with a server id generates a new one, plan §3.3 E6) */
47
+ constructor(
48
+ readonly model: string,
49
+ readonly primaryKey = false,
50
+ ) {
47
51
  super(`E11000 duplicate key error: a unique value of ${model} already exists`);
48
52
  }
49
53
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * How a change of access to synchronized records reaches the sync module (offline-sync plan §3.8) without the
3
+ * persistence layer or the tenant module importing it: `CrudService.update()` and `CoreTenantGuard.invalidateTenant()`
4
+ * report here, and the sync module — when it is registered — bumps the access epochs, so the clients concerned answer
5
+ * their next pull with a full resync. Every successful write of `CrudService.create()` / `update()` / `delete()` on a
6
+ * synchronized model reports here too, which the sync module turns into a hint (plan §3.9). Nothing is registered
7
+ * without a `sync` configuration, and every report is then a no-op.
8
+ *
9
+ * Import-free leaf (types only): every caller may import it without an import cycle.
10
+ */
11
+
12
+ /** A record whose access changed through `CrudService.update()` */
13
+ export interface SyncRecordMove {
14
+ /** The model's registered name */
15
+ model: string;
16
+ /** The tenant the record was in before the write (`undefined` on a model without tenants) */
17
+ tenantId?: string;
18
+ /** The write moved the record to another tenant: every member of `tenantId` lost it */
19
+ tenantChanged: boolean;
20
+ /** Users a scope field (`sync.scope`, e.g. `ownerId`) pointed at before and no longer points at */
21
+ usersLost: string[];
22
+ }
23
+
24
+ /** A successful write of a synchronized record, for the hints (plan §3.9) */
25
+ export interface SyncRecordChange {
26
+ /** The model's registered name */
27
+ model: string;
28
+ /** The tenants the record was in before and after the write (none on a model without tenants) */
29
+ tenantIds: string[];
30
+ }
31
+
32
+ export interface SyncAccessListener {
33
+ /** A write of `CrudService.create()` / `update()` / `delete()` on a synchronized model succeeded */
34
+ recordChanged?(change: SyncRecordChange): void;
35
+ /** `CrudService.update()` moved a synchronized record away from users who could see it */
36
+ recordMoved(move: SyncRecordMove): Promise<void>;
37
+ /** `CoreTenantGuard.invalidateTenant()`: what the tenant's members may see may have changed */
38
+ tenantInvalidated(tenantId: string): Promise<void>;
39
+ }
40
+
41
+ let listener: SyncAccessListener | undefined;
42
+
43
+ /** Set by the sync module when it starts, cleared when it stops */
44
+ export function setSyncAccessListener(value: SyncAccessListener | undefined): void {
45
+ listener = value;
46
+ }
47
+
48
+ export function getSyncAccessListener(): SyncAccessListener | undefined {
49
+ return listener;
50
+ }