@lenne.tech/nest-server 12.1.0 → 12.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/.claude/rules/architecture.md +1 -0
  2. package/.claude/rules/better-auth.md +18 -6
  3. package/.claude/rules/configurable-features.md +5 -4
  4. package/.claude/rules/package-management.md +16 -1
  5. package/.claude/rules/persistence.md +26 -0
  6. package/.claude/rules/testing.md +102 -28
  7. package/CLAUDE.md +3 -2
  8. package/FRAMEWORK-API.md +17 -2
  9. package/README.md +24 -3
  10. package/dist/core/common/helpers/file.helper.js +3 -4
  11. package/dist/core/common/helpers/file.helper.js.map +1 -1
  12. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
  13. package/dist/core/common/interfaces/server-options.interface.d.ts +41 -0
  14. package/dist/core/common/interfaces/service-options.interface.d.ts +3 -0
  15. package/dist/core/common/persistence/core-persistence.module.js +12 -2
  16. package/dist/core/common/persistence/core-persistence.module.js.map +1 -1
  17. package/dist/core/common/persistence/data-source.runtime.js +14 -19
  18. package/dist/core/common/persistence/data-source.runtime.js.map +1 -1
  19. package/dist/core/common/persistence/data-store.interface.d.ts +24 -9
  20. package/dist/core/common/persistence/id-format.d.ts +2 -0
  21. package/dist/core/common/persistence/id-format.js +14 -0
  22. package/dist/core/common/persistence/id-format.js.map +1 -1
  23. package/dist/core/common/persistence/model-descriptor.interface.d.ts +6 -0
  24. package/dist/core/common/persistence/model-metadata.registry.d.ts +2 -1
  25. package/dist/core/common/persistence/model-metadata.registry.js +20 -2
  26. package/dist/core/common/persistence/model-metadata.registry.js.map +1 -1
  27. package/dist/core/common/persistence/mongo/sync-schema.plugin.d.ts +18 -0
  28. package/dist/core/common/persistence/mongo/sync-schema.plugin.js +368 -0
  29. package/dist/core/common/persistence/mongo/sync-schema.plugin.js.map +1 -0
  30. package/dist/core/common/persistence/mongoose-data-store.d.ts +19 -9
  31. package/dist/core/common/persistence/mongoose-data-store.js +107 -31
  32. package/dist/core/common/persistence/mongoose-data-store.js.map +1 -1
  33. package/dist/core/common/persistence/persisted-model.decorator.d.ts +3 -0
  34. package/dist/core/common/persistence/persisted-model.decorator.js +9 -0
  35. package/dist/core/common/persistence/persisted-model.decorator.js.map +1 -1
  36. package/dist/core/common/persistence/persistence-runtime.registry.d.ts +5 -1
  37. package/dist/core/common/persistence/persistence-runtime.registry.js.map +1 -1
  38. package/dist/core/common/persistence/policies/sync.policy.d.ts +17 -0
  39. package/dist/core/common/persistence/policies/sync.policy.js +77 -0
  40. package/dist/core/common/persistence/policies/sync.policy.js.map +1 -0
  41. package/dist/core/common/persistence/sql/mysql/mysql-data-store.d.ts +12 -8
  42. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js +82 -29
  43. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js.map +1 -1
  44. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.d.ts +10 -0
  45. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js +61 -4
  46. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js.map +1 -1
  47. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.d.ts +2 -0
  48. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +145 -4
  49. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js.map +1 -1
  50. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.d.ts +10 -0
  51. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js +41 -2
  52. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js.map +1 -1
  53. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js +98 -10
  54. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js.map +1 -1
  55. package/dist/core/common/persistence/sql/sql-data-store.d.ts +38 -14
  56. package/dist/core/common/persistence/sql/sql-data-store.js +212 -38
  57. package/dist/core/common/persistence/sql/sql-data-store.js.map +1 -1
  58. package/dist/core/common/persistence/sql/sql-dialect.d.ts +2 -0
  59. package/dist/core/common/persistence/sql/sql-dialect.js +14 -2
  60. package/dist/core/common/persistence/sql/sql-dialect.js.map +1 -1
  61. package/dist/core/common/persistence/sql/sql-errors.d.ts +2 -1
  62. package/dist/core/common/persistence/sql/sql-errors.js +3 -1
  63. package/dist/core/common/persistence/sql/sql-errors.js.map +1 -1
  64. package/dist/core/common/persistence/sync-access.registry.d.ts +17 -0
  65. package/dist/core/common/persistence/sync-access.registry.js +8 -0
  66. package/dist/core/common/persistence/sync-access.registry.js.map +1 -0
  67. package/dist/core/common/persistence/sync-model.d.ts +29 -0
  68. package/dist/core/common/persistence/sync-model.js +138 -0
  69. package/dist/core/common/persistence/sync-model.js.map +1 -0
  70. package/dist/core/common/services/core-cron-jobs.service.d.ts +27 -0
  71. package/dist/core/common/services/core-cron-jobs.service.js +269 -13
  72. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  73. package/dist/core/common/services/core-redis-pubsub.js +5 -5
  74. package/dist/core/common/services/core-redis-pubsub.js.map +1 -1
  75. package/dist/core/common/services/core-redis.service.d.ts +6 -0
  76. package/dist/core/common/services/core-redis.service.js +57 -0
  77. package/dist/core/common/services/core-redis.service.js.map +1 -1
  78. package/dist/core/common/services/crud.service.d.ts +13 -1
  79. package/dist/core/common/services/crud.service.js +174 -16
  80. package/dist/core/common/services/crud.service.js.map +1 -1
  81. package/dist/core/modules/better-auth/better-auth-sql.helper.d.ts +1 -0
  82. package/dist/core/modules/better-auth/better-auth-sql.helper.js +41 -14
  83. package/dist/core/modules/better-auth/better-auth-sql.helper.js.map +1 -1
  84. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  85. package/dist/core/modules/better-auth/core-better-auth-sql.store.d.ts +4 -7
  86. package/dist/core/modules/better-auth/core-better-auth-sql.store.js +23 -16
  87. package/dist/core/modules/better-auth/core-better-auth-sql.store.js.map +1 -1
  88. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +12 -2
  89. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +82 -5
  90. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  91. package/dist/core/modules/better-auth/core-better-auth.module.js +1 -1
  92. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  93. package/dist/core/modules/error-code/error-codes.d.ts +54 -0
  94. package/dist/core/modules/error-code/error-codes.js +48 -0
  95. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  96. package/dist/core/modules/hub/hub-buffer.js +3 -4
  97. package/dist/core/modules/hub/hub-buffer.js.map +1 -1
  98. package/dist/core/modules/migrate/index.js.map +1 -1
  99. package/dist/core/modules/migrate/migration-runner.d.ts +6 -0
  100. package/dist/core/modules/migrate/migration-runner.js +28 -2
  101. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  102. package/dist/core/modules/migrate/mongo-state-store.d.ts +0 -1
  103. package/dist/core/modules/migrate/mongo-state-store.js +0 -5
  104. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  105. package/dist/core/modules/migrate/sql-migration.helper.d.ts +15 -1
  106. package/dist/core/modules/migrate/sql-migration.helper.js +90 -5
  107. package/dist/core/modules/migrate/sql-migration.helper.js.map +1 -1
  108. package/dist/core/modules/sync/core-sync-base.controller.d.ts +7 -0
  109. package/dist/core/modules/sync/core-sync-base.controller.js +44 -0
  110. package/dist/core/modules/sync/core-sync-base.controller.js.map +1 -0
  111. package/dist/core/modules/sync/core-sync-cursor.d.ts +19 -0
  112. package/dist/core/modules/sync/core-sync-cursor.js +77 -0
  113. package/dist/core/modules/sync/core-sync-cursor.js.map +1 -0
  114. package/dist/core/modules/sync/core-sync-hints.controller.d.ts +4 -0
  115. package/dist/core/modules/sync/core-sync-hints.controller.js +55 -0
  116. package/dist/core/modules/sync/core-sync-hints.controller.js.map +1 -0
  117. package/dist/core/modules/sync/core-sync-hints.service.d.ts +28 -0
  118. package/dist/core/modules/sync/core-sync-hints.service.js +155 -0
  119. package/dist/core/modules/sync/core-sync-hints.service.js.map +1 -0
  120. package/dist/core/modules/sync/core-sync-infrastructure.models.d.ts +2 -0
  121. package/dist/core/modules/sync/core-sync-infrastructure.models.js +17 -0
  122. package/dist/core/modules/sync/core-sync-infrastructure.models.js.map +1 -0
  123. package/dist/core/modules/sync/core-sync-mutation.store.d.ts +43 -0
  124. package/dist/core/modules/sync/core-sync-mutation.store.js +88 -0
  125. package/dist/core/modules/sync/core-sync-mutation.store.js.map +1 -0
  126. package/dist/core/modules/sync/core-sync-state.store.d.ts +18 -0
  127. package/dist/core/modules/sync/core-sync-state.store.js +64 -0
  128. package/dist/core/modules/sync/core-sync-state.store.js.map +1 -0
  129. package/dist/core/modules/sync/core-sync.constants.d.ts +28 -0
  130. package/dist/core/modules/sync/core-sync.constants.js +23 -0
  131. package/dist/core/modules/sync/core-sync.constants.js.map +1 -0
  132. package/dist/core/modules/sync/core-sync.controller.d.ts +8 -0
  133. package/dist/core/modules/sync/core-sync.controller.js +79 -0
  134. package/dist/core/modules/sync/core-sync.controller.js.map +1 -0
  135. package/dist/core/modules/sync/core-sync.decorators.d.ts +12 -0
  136. package/dist/core/modules/sync/core-sync.decorators.js +28 -0
  137. package/dist/core/modules/sync/core-sync.decorators.js.map +1 -0
  138. package/dist/core/modules/sync/core-sync.module.d.ts +16 -0
  139. package/dist/core/modules/sync/core-sync.module.js +50 -0
  140. package/dist/core/modules/sync/core-sync.module.js.map +1 -0
  141. package/dist/core/modules/sync/core-sync.resolver.d.ts +9 -0
  142. package/dist/core/modules/sync/core-sync.resolver.js +46 -0
  143. package/dist/core/modules/sync/core-sync.resolver.js.map +1 -0
  144. package/dist/core/modules/sync/core-sync.service.d.ts +142 -0
  145. package/dist/core/modules/sync/core-sync.service.js +1208 -0
  146. package/dist/core/modules/sync/core-sync.service.js.map +1 -0
  147. package/dist/core/modules/sync/interfaces/core-sync.interfaces.d.ts +56 -0
  148. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js +2 -0
  149. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js.map +1 -0
  150. package/dist/core/modules/sync/models/core-sync-hint.model.d.ts +4 -0
  151. package/dist/core/modules/sync/models/core-sync-hint.model.js +30 -0
  152. package/dist/core/modules/sync/models/core-sync-hint.model.js.map +1 -0
  153. package/dist/core/modules/tenant/core-tenant.guard.js +8 -3
  154. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  155. package/dist/core.module.js +14 -0
  156. package/dist/core.module.js.map +1 -1
  157. package/dist/index.d.ts +16 -0
  158. package/dist/index.js +16 -0
  159. package/dist/index.js.map +1 -1
  160. package/dist/server/modules/error-code/error-codes.d.ts +6 -0
  161. package/dist/server/modules/sync-note/inputs/sync-note-create.input.d.ts +4 -0
  162. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js +27 -0
  163. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js.map +1 -0
  164. package/dist/server/modules/sync-note/inputs/sync-note.input.d.ts +6 -0
  165. package/dist/server/modules/sync-note/inputs/sync-note.input.js +37 -0
  166. package/dist/server/modules/sync-note/inputs/sync-note.input.js.map +1 -0
  167. package/dist/server/modules/sync-note/sync-note.model.d.ts +10 -0
  168. package/dist/server/modules/sync-note/sync-note.model.js +73 -0
  169. package/dist/server/modules/sync-note/sync-note.model.js.map +1 -0
  170. package/dist/server/modules/sync-note/sync-note.module.d.ts +2 -0
  171. package/dist/server/modules/sync-note/sync-note.module.js +21 -0
  172. package/dist/server/modules/sync-note/sync-note.module.js.map +1 -0
  173. package/dist/server/modules/sync-note/sync-note.service.d.ts +10 -0
  174. package/dist/server/modules/sync-note/sync-note.service.js +35 -0
  175. package/dist/server/modules/sync-note/sync-note.service.js.map +1 -0
  176. package/docs/REQUEST-LIFECYCLE.md +3 -2
  177. package/docs/choosing-a-database.md +7 -4
  178. package/docs/offline-sync-protocol.md +410 -0
  179. package/docs/security-overrides.md +14 -0
  180. package/migration-guides/12.0.x-to-12.1.x.md +9 -4
  181. package/migration-guides/12.1.0-to-12.1.1.md +125 -0
  182. package/migration-guides/12.1.x-to-12.2.x.md +154 -0
  183. package/package.json +10 -8
  184. package/src/core/common/helpers/file.helper.ts +7 -5
  185. package/src/core/common/helpers/graphql-ws-connection.helper.ts +3 -2
  186. package/src/core/common/interfaces/cron-job-config.interface.ts +7 -2
  187. package/src/core/common/interfaces/server-options.interface.ts +149 -0
  188. package/src/core/common/interfaces/service-options.interface.ts +15 -0
  189. package/src/core/common/persistence/core-persistence.module.ts +21 -2
  190. package/src/core/common/persistence/data-source.runtime.ts +22 -23
  191. package/src/core/common/persistence/data-store.interface.ts +65 -12
  192. package/src/core/common/persistence/id-format.ts +26 -0
  193. package/src/core/common/persistence/model-descriptor.interface.ts +12 -0
  194. package/src/core/common/persistence/model-metadata.registry.ts +31 -2
  195. package/src/core/common/persistence/mongo/sync-schema.plugin.ts +508 -0
  196. package/src/core/common/persistence/mongoose-data-store.ts +171 -35
  197. package/src/core/common/persistence/persisted-model.decorator.ts +26 -0
  198. package/src/core/common/persistence/persistence-runtime.registry.ts +16 -5
  199. package/src/core/common/persistence/policies/sync.policy.ts +121 -0
  200. package/src/core/common/persistence/sql/mysql/mysql-data-store.ts +108 -30
  201. package/src/core/common/persistence/sql/mysql/mysql-schema.compiler.ts +107 -4
  202. package/src/core/common/persistence/sql/mysql/mysql-schema.migrator.ts +194 -3
  203. package/src/core/common/persistence/sql/postgres/postgres-schema.compiler.ts +74 -2
  204. package/src/core/common/persistence/sql/postgres/postgres-schema.migrator.ts +139 -7
  205. package/src/core/common/persistence/sql/sql-data-store.ts +325 -44
  206. package/src/core/common/persistence/sql/sql-dialect.ts +38 -1
  207. package/src/core/common/persistence/sql/sql-errors.ts +5 -1
  208. package/src/core/common/persistence/sql/sql-executor.interface.ts +4 -1
  209. package/src/core/common/persistence/sync-access.registry.ts +50 -0
  210. package/src/core/common/persistence/sync-model.ts +270 -0
  211. package/src/core/common/services/core-cron-jobs.service.ts +441 -27
  212. package/src/core/common/services/core-redis-pubsub.ts +8 -6
  213. package/src/core/common/services/core-redis.service.ts +99 -0
  214. package/src/core/common/services/crud.service.ts +257 -19
  215. package/src/core/modules/better-auth/README.md +25 -1
  216. package/src/core/modules/better-auth/better-auth-sql.helper.ts +56 -24
  217. package/src/core/modules/better-auth/better-auth.config.ts +4 -3
  218. package/src/core/modules/better-auth/core-better-auth-sql.store.ts +28 -21
  219. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +122 -6
  220. package/src/core/modules/better-auth/core-better-auth.module.ts +2 -2
  221. package/src/core/modules/error-code/error-codes.ts +59 -0
  222. package/src/core/modules/hub/README.md +5 -2
  223. package/src/core/modules/hub/hub-buffer.ts +13 -7
  224. package/src/core/modules/migrate/README.md +20 -11
  225. package/src/core/modules/migrate/index.ts +1 -0
  226. package/src/core/modules/migrate/migration-runner.ts +68 -3
  227. package/src/core/modules/migrate/mongo-state-store.ts +7 -30
  228. package/src/core/modules/migrate/sql-migration.helper.ts +141 -7
  229. package/src/core/modules/sync/INTEGRATION-CHECKLIST.md +95 -0
  230. package/src/core/modules/sync/README.md +188 -0
  231. package/src/core/modules/sync/SECURITY.md +65 -0
  232. package/src/core/modules/sync/core-sync-base.controller.ts +43 -0
  233. package/src/core/modules/sync/core-sync-cursor.ts +117 -0
  234. package/src/core/modules/sync/core-sync-hints.controller.ts +52 -0
  235. package/src/core/modules/sync/core-sync-hints.service.ts +178 -0
  236. package/src/core/modules/sync/core-sync-infrastructure.models.ts +40 -0
  237. package/src/core/modules/sync/core-sync-mutation.store.ts +146 -0
  238. package/src/core/modules/sync/core-sync-state.store.ts +89 -0
  239. package/src/core/modules/sync/core-sync.constants.ts +51 -0
  240. package/src/core/modules/sync/core-sync.controller.ts +68 -0
  241. package/src/core/modules/sync/core-sync.decorators.ts +84 -0
  242. package/src/core/modules/sync/core-sync.module.ts +75 -0
  243. package/src/core/modules/sync/core-sync.resolver.ts +35 -0
  244. package/src/core/modules/sync/core-sync.service.ts +1728 -0
  245. package/src/core/modules/sync/interfaces/core-sync.interfaces.ts +85 -0
  246. package/src/core/modules/sync/models/core-sync-hint.model.ts +18 -0
  247. package/src/core/modules/tenant/README.md +1 -0
  248. package/src/core/modules/tenant/core-tenant.guard.ts +16 -3
  249. package/src/core.module.ts +25 -0
  250. package/src/index.ts +21 -0
  251. package/src/server/modules/sync-note/inputs/sync-note-create.input.ts +14 -0
  252. package/src/server/modules/sync-note/inputs/sync-note.input.ts +23 -0
  253. package/src/server/modules/sync-note/sync-note.model.ts +77 -0
  254. package/src/server/modules/sync-note/sync-note.module.ts +16 -0
  255. package/src/server/modules/sync-note/sync-note.service.ts +25 -0
@@ -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
  }
@@ -42,7 +42,10 @@ export interface SqlTransaction {
42
42
 
43
43
  export interface SqlExecutor {
44
44
  close(): Promise<void>;
45
- /** The node-postgres pool the executor runs on — for Better-Auth's own adapter. */
45
+ /**
46
+ * The driver's pool the executor runs on (node-postgres, or `mysql2`'s callback pool) — a library with its own adapter
47
+ * (Better-Auth) receives what it can run on through `SqlDialect.foreignPool()`.
48
+ */
46
49
  readonly driverPool: unknown;
47
50
  readonly engine: DatabaseSqlEngine;
48
51
  /** The engine's own API, outside any framework transaction. */
@@ -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
+ }
@@ -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`, plan `.claude/plans/offline-sync/PLAN.md` §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
+ }