@lenne.tech/nest-server 12.1.1 → 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 (191) hide show
  1. package/.claude/rules/architecture.md +1 -0
  2. package/.claude/rules/configurable-features.md +1 -0
  3. package/.claude/rules/package-management.md +16 -1
  4. package/.claude/rules/persistence.md +26 -0
  5. package/.claude/rules/testing.md +79 -14
  6. package/CLAUDE.md +2 -1
  7. package/FRAMEWORK-API.md +17 -2
  8. package/README.md +18 -0
  9. package/dist/core/common/interfaces/server-options.interface.d.ts +41 -0
  10. package/dist/core/common/interfaces/service-options.interface.d.ts +3 -0
  11. package/dist/core/common/persistence/core-persistence.module.js +12 -2
  12. package/dist/core/common/persistence/core-persistence.module.js.map +1 -1
  13. package/dist/core/common/persistence/data-source.runtime.js +4 -2
  14. package/dist/core/common/persistence/data-source.runtime.js.map +1 -1
  15. package/dist/core/common/persistence/data-store.interface.d.ts +24 -9
  16. package/dist/core/common/persistence/id-format.d.ts +2 -0
  17. package/dist/core/common/persistence/id-format.js +14 -0
  18. package/dist/core/common/persistence/id-format.js.map +1 -1
  19. package/dist/core/common/persistence/model-descriptor.interface.d.ts +6 -0
  20. package/dist/core/common/persistence/model-metadata.registry.d.ts +2 -1
  21. package/dist/core/common/persistence/model-metadata.registry.js +20 -2
  22. package/dist/core/common/persistence/model-metadata.registry.js.map +1 -1
  23. package/dist/core/common/persistence/mongo/sync-schema.plugin.d.ts +18 -0
  24. package/dist/core/common/persistence/mongo/sync-schema.plugin.js +368 -0
  25. package/dist/core/common/persistence/mongo/sync-schema.plugin.js.map +1 -0
  26. package/dist/core/common/persistence/mongoose-data-store.d.ts +19 -9
  27. package/dist/core/common/persistence/mongoose-data-store.js +107 -31
  28. package/dist/core/common/persistence/mongoose-data-store.js.map +1 -1
  29. package/dist/core/common/persistence/persisted-model.decorator.d.ts +3 -0
  30. package/dist/core/common/persistence/persisted-model.decorator.js +9 -0
  31. package/dist/core/common/persistence/persisted-model.decorator.js.map +1 -1
  32. package/dist/core/common/persistence/policies/sync.policy.d.ts +17 -0
  33. package/dist/core/common/persistence/policies/sync.policy.js +77 -0
  34. package/dist/core/common/persistence/policies/sync.policy.js.map +1 -0
  35. package/dist/core/common/persistence/sql/mysql/mysql-data-store.d.ts +12 -8
  36. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js +82 -29
  37. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js.map +1 -1
  38. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.d.ts +10 -0
  39. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js +61 -4
  40. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js.map +1 -1
  41. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +101 -4
  42. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js.map +1 -1
  43. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.d.ts +10 -0
  44. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js +41 -2
  45. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js.map +1 -1
  46. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js +98 -10
  47. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js.map +1 -1
  48. package/dist/core/common/persistence/sql/sql-data-store.d.ts +38 -14
  49. package/dist/core/common/persistence/sql/sql-data-store.js +212 -38
  50. package/dist/core/common/persistence/sql/sql-data-store.js.map +1 -1
  51. package/dist/core/common/persistence/sql/sql-errors.d.ts +2 -1
  52. package/dist/core/common/persistence/sql/sql-errors.js +3 -1
  53. package/dist/core/common/persistence/sql/sql-errors.js.map +1 -1
  54. package/dist/core/common/persistence/sync-access.registry.d.ts +17 -0
  55. package/dist/core/common/persistence/sync-access.registry.js +8 -0
  56. package/dist/core/common/persistence/sync-access.registry.js.map +1 -0
  57. package/dist/core/common/persistence/sync-model.d.ts +29 -0
  58. package/dist/core/common/persistence/sync-model.js +138 -0
  59. package/dist/core/common/persistence/sync-model.js.map +1 -0
  60. package/dist/core/common/services/crud.service.d.ts +13 -1
  61. package/dist/core/common/services/crud.service.js +174 -16
  62. package/dist/core/common/services/crud.service.js.map +1 -1
  63. package/dist/core/modules/error-code/error-codes.d.ts +54 -0
  64. package/dist/core/modules/error-code/error-codes.js +48 -0
  65. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  66. package/dist/core/modules/migrate/sql-migration.helper.d.ts +15 -1
  67. package/dist/core/modules/migrate/sql-migration.helper.js +90 -5
  68. package/dist/core/modules/migrate/sql-migration.helper.js.map +1 -1
  69. package/dist/core/modules/sync/core-sync-base.controller.d.ts +7 -0
  70. package/dist/core/modules/sync/core-sync-base.controller.js +44 -0
  71. package/dist/core/modules/sync/core-sync-base.controller.js.map +1 -0
  72. package/dist/core/modules/sync/core-sync-cursor.d.ts +19 -0
  73. package/dist/core/modules/sync/core-sync-cursor.js +77 -0
  74. package/dist/core/modules/sync/core-sync-cursor.js.map +1 -0
  75. package/dist/core/modules/sync/core-sync-hints.controller.d.ts +4 -0
  76. package/dist/core/modules/sync/core-sync-hints.controller.js +55 -0
  77. package/dist/core/modules/sync/core-sync-hints.controller.js.map +1 -0
  78. package/dist/core/modules/sync/core-sync-hints.service.d.ts +28 -0
  79. package/dist/core/modules/sync/core-sync-hints.service.js +155 -0
  80. package/dist/core/modules/sync/core-sync-hints.service.js.map +1 -0
  81. package/dist/core/modules/sync/core-sync-infrastructure.models.d.ts +2 -0
  82. package/dist/core/modules/sync/core-sync-infrastructure.models.js +17 -0
  83. package/dist/core/modules/sync/core-sync-infrastructure.models.js.map +1 -0
  84. package/dist/core/modules/sync/core-sync-mutation.store.d.ts +43 -0
  85. package/dist/core/modules/sync/core-sync-mutation.store.js +88 -0
  86. package/dist/core/modules/sync/core-sync-mutation.store.js.map +1 -0
  87. package/dist/core/modules/sync/core-sync-state.store.d.ts +18 -0
  88. package/dist/core/modules/sync/core-sync-state.store.js +64 -0
  89. package/dist/core/modules/sync/core-sync-state.store.js.map +1 -0
  90. package/dist/core/modules/sync/core-sync.constants.d.ts +28 -0
  91. package/dist/core/modules/sync/core-sync.constants.js +23 -0
  92. package/dist/core/modules/sync/core-sync.constants.js.map +1 -0
  93. package/dist/core/modules/sync/core-sync.controller.d.ts +8 -0
  94. package/dist/core/modules/sync/core-sync.controller.js +79 -0
  95. package/dist/core/modules/sync/core-sync.controller.js.map +1 -0
  96. package/dist/core/modules/sync/core-sync.decorators.d.ts +12 -0
  97. package/dist/core/modules/sync/core-sync.decorators.js +28 -0
  98. package/dist/core/modules/sync/core-sync.decorators.js.map +1 -0
  99. package/dist/core/modules/sync/core-sync.module.d.ts +16 -0
  100. package/dist/core/modules/sync/core-sync.module.js +50 -0
  101. package/dist/core/modules/sync/core-sync.module.js.map +1 -0
  102. package/dist/core/modules/sync/core-sync.resolver.d.ts +9 -0
  103. package/dist/core/modules/sync/core-sync.resolver.js +46 -0
  104. package/dist/core/modules/sync/core-sync.resolver.js.map +1 -0
  105. package/dist/core/modules/sync/core-sync.service.d.ts +142 -0
  106. package/dist/core/modules/sync/core-sync.service.js +1208 -0
  107. package/dist/core/modules/sync/core-sync.service.js.map +1 -0
  108. package/dist/core/modules/sync/interfaces/core-sync.interfaces.d.ts +56 -0
  109. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js +2 -0
  110. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js.map +1 -0
  111. package/dist/core/modules/sync/models/core-sync-hint.model.d.ts +4 -0
  112. package/dist/core/modules/sync/models/core-sync-hint.model.js +30 -0
  113. package/dist/core/modules/sync/models/core-sync-hint.model.js.map +1 -0
  114. package/dist/core/modules/tenant/core-tenant.guard.js +4 -0
  115. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  116. package/dist/core.module.js +14 -0
  117. package/dist/core.module.js.map +1 -1
  118. package/dist/index.d.ts +16 -0
  119. package/dist/index.js +16 -0
  120. package/dist/index.js.map +1 -1
  121. package/dist/server/modules/error-code/error-codes.d.ts +6 -0
  122. package/dist/server/modules/sync-note/inputs/sync-note-create.input.d.ts +4 -0
  123. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js +27 -0
  124. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js.map +1 -0
  125. package/dist/server/modules/sync-note/inputs/sync-note.input.d.ts +6 -0
  126. package/dist/server/modules/sync-note/inputs/sync-note.input.js +37 -0
  127. package/dist/server/modules/sync-note/inputs/sync-note.input.js.map +1 -0
  128. package/dist/server/modules/sync-note/sync-note.model.d.ts +10 -0
  129. package/dist/server/modules/sync-note/sync-note.model.js +73 -0
  130. package/dist/server/modules/sync-note/sync-note.model.js.map +1 -0
  131. package/dist/server/modules/sync-note/sync-note.module.d.ts +2 -0
  132. package/dist/server/modules/sync-note/sync-note.module.js +21 -0
  133. package/dist/server/modules/sync-note/sync-note.module.js.map +1 -0
  134. package/dist/server/modules/sync-note/sync-note.service.d.ts +10 -0
  135. package/dist/server/modules/sync-note/sync-note.service.js +35 -0
  136. package/dist/server/modules/sync-note/sync-note.service.js.map +1 -0
  137. package/docs/REQUEST-LIFECYCLE.md +1 -0
  138. package/docs/offline-sync-protocol.md +96 -62
  139. package/docs/security-overrides.md +14 -0
  140. package/migration-guides/12.1.x-to-12.2.x.md +154 -0
  141. package/package.json +4 -2
  142. package/src/core/common/interfaces/server-options.interface.ts +149 -0
  143. package/src/core/common/interfaces/service-options.interface.ts +15 -0
  144. package/src/core/common/persistence/core-persistence.module.ts +21 -2
  145. package/src/core/common/persistence/data-source.runtime.ts +5 -2
  146. package/src/core/common/persistence/data-store.interface.ts +65 -12
  147. package/src/core/common/persistence/id-format.ts +26 -0
  148. package/src/core/common/persistence/model-descriptor.interface.ts +12 -0
  149. package/src/core/common/persistence/model-metadata.registry.ts +31 -2
  150. package/src/core/common/persistence/mongo/sync-schema.plugin.ts +508 -0
  151. package/src/core/common/persistence/mongoose-data-store.ts +171 -35
  152. package/src/core/common/persistence/persisted-model.decorator.ts +26 -0
  153. package/src/core/common/persistence/policies/sync.policy.ts +121 -0
  154. package/src/core/common/persistence/sql/mysql/mysql-data-store.ts +108 -30
  155. package/src/core/common/persistence/sql/mysql/mysql-schema.compiler.ts +107 -4
  156. package/src/core/common/persistence/sql/mysql/mysql-schema.migrator.ts +129 -3
  157. package/src/core/common/persistence/sql/postgres/postgres-schema.compiler.ts +74 -2
  158. package/src/core/common/persistence/sql/postgres/postgres-schema.migrator.ts +139 -7
  159. package/src/core/common/persistence/sql/sql-data-store.ts +325 -44
  160. package/src/core/common/persistence/sql/sql-errors.ts +5 -1
  161. package/src/core/common/persistence/sync-access.registry.ts +50 -0
  162. package/src/core/common/persistence/sync-model.ts +270 -0
  163. package/src/core/common/services/crud.service.ts +257 -19
  164. package/src/core/modules/error-code/error-codes.ts +59 -0
  165. package/src/core/modules/migrate/sql-migration.helper.ts +141 -7
  166. package/src/core/modules/sync/INTEGRATION-CHECKLIST.md +95 -0
  167. package/src/core/modules/sync/README.md +188 -0
  168. package/src/core/modules/sync/SECURITY.md +65 -0
  169. package/src/core/modules/sync/core-sync-base.controller.ts +43 -0
  170. package/src/core/modules/sync/core-sync-cursor.ts +117 -0
  171. package/src/core/modules/sync/core-sync-hints.controller.ts +52 -0
  172. package/src/core/modules/sync/core-sync-hints.service.ts +178 -0
  173. package/src/core/modules/sync/core-sync-infrastructure.models.ts +40 -0
  174. package/src/core/modules/sync/core-sync-mutation.store.ts +146 -0
  175. package/src/core/modules/sync/core-sync-state.store.ts +89 -0
  176. package/src/core/modules/sync/core-sync.constants.ts +51 -0
  177. package/src/core/modules/sync/core-sync.controller.ts +68 -0
  178. package/src/core/modules/sync/core-sync.decorators.ts +84 -0
  179. package/src/core/modules/sync/core-sync.module.ts +75 -0
  180. package/src/core/modules/sync/core-sync.resolver.ts +35 -0
  181. package/src/core/modules/sync/core-sync.service.ts +1728 -0
  182. package/src/core/modules/sync/interfaces/core-sync.interfaces.ts +85 -0
  183. package/src/core/modules/sync/models/core-sync-hint.model.ts +18 -0
  184. package/src/core/modules/tenant/core-tenant.guard.ts +8 -0
  185. package/src/core.module.ts +25 -0
  186. package/src/index.ts +21 -0
  187. package/src/server/modules/sync-note/inputs/sync-note-create.input.ts +14 -0
  188. package/src/server/modules/sync-note/inputs/sync-note.input.ts +23 -0
  189. package/src/server/modules/sync-note/sync-note.model.ts +77 -0
  190. package/src/server/modules/sync-note/sync-note.module.ts +16 -0
  191. package/src/server/modules/sync-note/sync-note.service.ts +25 -0
@@ -1,9 +1,11 @@
1
1
  # Offline sync protocol
2
2
 
3
- > **Status: specification, not implemented yet.** Phase 1 of the offline-sync epic (DEV-3819, this phase DEV-3898).
4
- > Nothing described here exists in the package before the sync module ships (phase 4). The implementation plan, the
5
- > port extensions and the reasoning live in the framework repository under `.claude/plans/offline-sync/PLAN.md`.
6
- > Points the cursor prototype (phase 2) still decides are marked **OPEN** and listed in §15, next to the owner's decisions.
3
+ > **Status: implemented** by the sync module (`src/core/modules/sync/`, 12.2.0): pull, push, conflicts, client ids and
4
+ > resync (DEV-3901), hints (§9) and the tombstone purge (DEV-3902), proven end to end by a PWA and a Node test client
5
+ > and by two server instances with skewed clocks (DEV-3903). Specified in
6
+ > phase 1 of the offline-sync epic (DEV-3819, DEV-3898), the open points decided by the cursor prototype of phase 2
7
+ > (DEV-3899). The implementation plan, the port extensions, the reasoning and the measurements live in the framework
8
+ > repository under `.claude/plans/offline-sync/PLAN.md`; §15 lists every decision.
7
9
 
8
10
  Clients read and write while disconnected and reconcile with the server when they are back: they pull what changed
9
11
  since their cursor, push what they changed locally, and receive tombstones for deletions. The protocol is HTTP with
@@ -37,58 +39,69 @@ supports (MongoDB, PostgreSQL with any engine, MySQL / MariaDB) with the same ob
37
39
  | `version` | An integer the server increments on every change of the record, starting at 1. Never set by a client. |
38
40
  | `updatedAt` | The server's time of the last change. Informational; ordering never relies on it. |
39
41
  | `deletedAt` | `null` for a live record, the deletion time for a tombstone. |
40
- | Tombstone | What remains of a deleted record: `id`, `version`, `deletedAt`, `updatedAt`, on a tenant-scoped model `tenantId`, and the user-id fields the model declares as its scope (e.g. `ownerId`). No content field. |
42
+ | Tombstone | What remains of a deleted record: `id`, `version`, `deletedAt`, `updatedAt`, on a tenant-scoped model `tenantId`, and the user-id fields the model declares as its scope (e.g. `ownerId`). The server also keeps its audit fields (`createdAt`, `createdBy`, `updatedBy` — who deleted it); a pull sends none of them. No content field. |
41
43
  | Cursor | Opaque string the server returns with every pull page; the client sends it back to continue. |
42
44
  | Retention horizon | How long tombstones are kept. A cursor older than the horizon cannot be continued (§8). |
43
45
  | Mutation id | A unique id the client gives every pushed change, so a repeated push is recognised (§6.5). |
44
46
 
45
47
  ## 3. Identifiers
46
48
 
47
- **Clients create the ids of records they create offline.** Two formats are accepted for sync models:
49
+ **Clients create the ids of records they create offline**, in the one format the model declares:
48
50
 
49
- | Format | Shape | Generated by |
50
- |---|---|---|
51
- | ObjectId-hex | 24 lowercase hex characters (`^[0-9a-f]{24}$`) | the server's own format; any ObjectId generator |
52
- | uuidv7 | 36 characters, `xxxxxxxx-xxxx-7xxx-[89ab]xxx-xxxxxxxxxxxx`, lowercase | `crypto.randomUUID()` is v4 — use a uuidv7 generator |
53
-
54
- * The server validates the format of every id it receives and refuses anything else (`INVALID_ID`, §7). It never
55
- rewrites a client id.
56
- * Records the server creates itself (a create through the normal API) keep the server's format, ObjectId-hex. Server
57
- ids are predictable from an observed one, so a client could occupy the next ones; on a sync model the server
51
+ | Format | Shape | Used by | Generated by |
52
+ |---|---|---|---|
53
+ | ObjectId-hex | 24 lowercase hex characters (`^[0-9a-f]{24}$`) | every model unless declared otherwise — existing models and projects change nothing | the client, as in MongoDB's own model where the driver creates the id — but with a random tail: 4 timestamp bytes plus 8 bytes from a secure random source, fresh for every id (see below); not a driver's default `new ObjectId()` |
54
+ | uuidv7 | 36 characters, `xxxxxxxx-xxxx-7xxx-[89ab]xxx-xxxxxxxxxxxx`, lowercase | a model declared with it — a choice for **new** models only | a uuidv7 generator (`crypto.randomUUID()` is v4) |
55
+
56
+ * **One format per model, never mixed** in a collection or table (decided 2026-10-29). An existing model does not
57
+ switch: that would mix both formats in its data.
58
+ * The server validates every id it receives against the model's format and refuses anything else (`INVALID_ID`, §7).
59
+ It never rewrites a client id.
60
+ * Records the server creates itself (a create through the normal API) get the model's format. Server-generated
61
+ ObjectIds are predictable from an observed one, so a client could occupy the next ones; on a sync model the server
58
62
  therefore generates a fresh id and retries when its own id is taken, and a normal create never fails on that.
59
- * Ids of models that are not syncable keep today's rule: ObjectId-hex only. Declaring a model syncable widens the
60
- accepted formats for that model alone.
61
- * **Prefer uuidv7.** It carries 74 random bits, so ids of other clients cannot be guessed. An ObjectId carries a
62
- timestamp, a per-process value and a counter; ids generated close to a known one are predictable.
63
+ * Ids of models that are not syncable keep today's rule: ObjectId-hex only.
64
+ * **A client's next ids must not be predictable.** A uuidv7 carries 74 random bits by construction. A driver's default
65
+ ObjectId does not: its per-process value and counter make every next id of a client predictable from one it has
66
+ created, so a member of the same tenant who sees one could create those ids first, and the client's own create
67
+ would then meet a record that is not its own. That is why a client fills the 8 bytes after an ObjectId's timestamp
68
+ from a secure random source (`crypto.getRandomValues`) for every id — still a valid ObjectId, as unguessable as a
69
+ uuidv7. The server cannot tell how an id was generated, so this rule is the client's. Beyond it, every access is
70
+ checked against the caller's rights, an id taken by a record the caller may not see answers `ID_UNAVAILABLE`, and
71
+ the push rate limit bounds probing. ObjectId stays the default; uuidv7 is a model's choice for new models.
63
72
  * Records created offline may reference each other by these ids before any of them reached the server. Push them in
64
73
  dependency order (§6.3) or accept that a reference may point to a record the server has not received yet — the server
65
74
  checks a reference against the caller's rights only where the model declares that check.
66
75
 
67
- How each database stores both formats: the id column is already a string column wide enough for both on PostgreSQL
76
+ How each database stores the formats: the id column is already a string column wide enough for both on PostgreSQL
68
77
  (`text`) and MySQL / MariaDB (`varchar(255)`). On MongoDB an ObjectId-hex id is stored as an ObjectId and a uuidv7 id
69
- as a string `_id`; the format alone decides the representation, so every lookup by id is deterministic. References to
70
- a sync model use the same mapping. **OPEN (phase 2):** Mongoose casting, populate and sort on a collection holding both
71
- representations are verified by the prototype before phase 3 builds on them.
78
+ as a BSON UUID (binary subtype 4, MongoDB's recommended UUID representation); the core keeps ids as strings, the store
79
+ converts at its boundary. References to a model use that model's representation. Because a collection holds one
80
+ format, casting, populate and sort never meet two representations side by side.
72
81
 
73
- **A create whose id is already taken** by a record or tombstone the caller may see is answered `conflict` with that record as the
74
- caller may see it (§6.5) — without comparing the stored values with `data`: an answer that depended on the comparison
82
+ **A create whose id is already taken** by a record or tombstone the caller may see — inside their tenant scope, with the
83
+ model's pull roles, not hidden by `securityCheck` — is answered `conflict` with that record as the caller may see it (§6.5) — without comparing the stored values with `data`: an answer that depended on the comparison
75
84
  would let the caller test guesses for fields hidden from them. Taken by a record or tombstone the caller may **not**
76
85
  see, the item is refused with `ID_UNAVAILABLE` — the same answer,
77
86
  same timing, for every reason an id cannot be used; it carries no tenant, owner, content or deletion state. That an id
78
87
  is occupied stays observable — decided (O2, §15): ids stay unique across tenants, the push rate limit bounds probing,
79
- and a uuidv7 id of someone else cannot be guessed in the first place.
88
+ and an id generated as above (uuidv7, or ObjectId-hex with its random tail) cannot be guessed in the first place.
80
89
 
81
90
  ## 4. Requests and authentication
82
91
 
83
92
  * **Base path** `/sync` (configurable). Model names in paths are the names the project registered for sync.
93
+ * **Tenant:** with `X-Tenant-Id` a request covers that one tenant (and requires an active membership in it); without the
94
+ header it spans every tenant the caller is an active member of.
84
95
  * **Authentication:** a Better-Auth session cookie or a bearer token (JWT or session token), exactly as for every other
85
96
  route. API tokens are refused on sync routes unless the project releases a sync scope for them.
86
97
  * **Writes with cookie authentication** follow the rules of every writing endpoint — the session cookie is
87
98
  `SameSite=lax`, credentialed CORS is answered for the trusted origins (for any origin under `cors.allowAll`, a
88
99
  development setting) — and push additionally requires
89
- `Content-Type: application/json` (which forces a CORS preflight) and refuses a request whose `Origin` is not a
90
- trusted origin or whose `Sec-Fetch-Site` is `cross-site`. A bearer-authenticated request carries no ambient
91
- credential and is not subject to these checks.
100
+ `Content-Type: application/json` (which forces a CORS preflight; required of every push, `400` otherwise) and,
101
+ while the server uses cookies, refuses a request whose `Origin` is not a trusted origin or whose `Sec-Fetch-Site` is
102
+ `cross-site` (`403`). The trusted origins are those credentialed CORS answers plus the app's and the API's own. A
103
+ client without fetch metadata (a native app, a server) sends neither header and is not affected; a browser app
104
+ using a bearer token runs on a trusted origin anyway, or CORS would refuse it first.
92
105
  * **Session expired while offline:** push answers `401` for the whole request before it looks at any item. Nothing is
93
106
  applied and nothing is discarded; after signing in again the client sends the same items with the same mutation ids
94
107
  and gets no duplicates (§6.5).
@@ -114,7 +127,7 @@ GET /sync/{model}?cursor={cursor}&limit={n}
114
127
  ```json
115
128
  {
116
129
  "items": [
117
- { "id": "0190f8a2-…", "version": 3, "updatedAt": "2026-11-02T09:14:03.120Z", "deletedAt": null, "data": { "title": "…" } },
130
+ { "id": "6720c0…", "version": 3, "updatedAt": "2026-11-02T09:14:03.120Z", "deletedAt": null, "data": { "title": "…" } },
118
131
  { "id": "6720c1…", "version": 5, "updatedAt": "2026-11-02T09:15:44.002Z", "deletedAt": "2026-11-02T09:15:44.002Z" }
119
132
  ],
120
133
  "cursor": "c3luYzpub3RlOjE…",
@@ -131,8 +144,10 @@ GET /sync/{model}?cursor={cursor}&limit={n}
131
144
  cursor continues with page n+1. Apply a page's items and store its cursor in one local transaction.
132
145
  * **Ordering and duplicates.** Pages are ordered by the server's change order, not by `updatedAt`. A record may appear
133
146
  more than once across pages (it changed again while the client was paging) — keep the item with the higher
134
- `version`. **OPEN (phase 2):** whether the chosen cursor also delivers an unchanged record twice (an overlap window
135
- does, a change sequence does not); clients must tolerate it either way by comparing `version`.
147
+ `version`. Depending on the database the server runs on, a pull also repeats **unchanged** records (decided in
148
+ phase 2, P2-2): on MongoDB a run re-reads what changed within a window W before its cursor (on a replica set the
149
+ server's transaction lifetime — 60 s by default — plus 30 s, on a standalone 30 s; decided in DEV-3901), on SQL
150
+ databases it never does. Clients compare `version` and need not know which.
136
151
  * Without a cursor the client receives every record and every unexpired tombstone it may see, then continues with the
137
152
  last cursor like any delta. A client whose local storage was cleared (Safari removes the storage of a site not
138
153
  installed after seven days without interaction) simply starts without a cursor.
@@ -142,8 +157,9 @@ GET /sync/{model}?cursor={cursor}&limit={n}
142
157
  * Every change committed before the pull started and visible to the caller is delivered in some page of the run that
143
158
  starts at the given cursor — regardless of how many server instances wrote it, whether their clocks differ, whether
144
159
  two changes carry the same timestamp, or whether a transaction committed after a younger one was already pulled.
145
- **OPEN (phase 2):** the mechanism (P §3.4: a server-assigned change sequence with a commit horizon, or an
146
- overlap window with `version` comparison) is chosen by the prototype on every target database.
160
+ The mechanism (decided in phase 2, P §3.4, measured on every target database): on SQL databases a change sequence
161
+ the server assigns in commit order, on MongoDB the server's clock with a window that a commit cannot outlast. The
162
+ cursor is opaque either way; a client never reads or compares it.
147
163
  * No record and no tombstone of another tenant, and no record the caller's rights or `securityCheck` hide.
148
164
 
149
165
  ## 6. Push
@@ -160,7 +176,7 @@ Content-Type: application/json
160
176
  ### 6.2 Item
161
177
 
162
178
  ```json
163
- { "mutationId": "0190f8b3-…", "op": "upsert", "id": "0190f8a2-…", "baseVersion": 2, "data": { "title": "…" } }
179
+ { "mutationId": "0190f8b3-…", "op": "upsert", "id": "6720c0…", "baseVersion": 2, "data": { "title": "…" } }
164
180
  { "mutationId": "0190f8b4-…", "op": "delete", "id": "6720c1…", "baseVersion": 5 }
165
181
  ```
166
182
 
@@ -182,8 +198,9 @@ Content-Type: application/json
182
198
  * `upsert` with `baseVersion` is an **atomic conditional change**: it applies only when the stored version still equals
183
199
  `baseVersion` (`WHERE id = … AND version = …` or the MongoDB equivalent; never read-then-write), and it increments
184
200
  `version`.
185
- * `delete` with a matching `baseVersion` turns the record into a tombstone through the normal update pipeline (rights,
186
- `securityCheck`, audit apply). Deleting a tombstone again is answered `applied`.
201
+ * `delete` with a matching `baseVersion` turns the record into a tombstone through the normal delete pipeline (the
202
+ model's delete rights, `securityCheck`, audit apply). Deleting a tombstone again is answered `applied`. A delete through
203
+ any other route of the server leaves a tombstone too, so clients learn of it the same way.
187
204
  * An `upsert` on a tombstone is a conflict, never a silent resurrection.
188
205
 
189
206
  ### 6.4 Conflicts
@@ -219,9 +236,9 @@ A conflict is a `baseVersion` that no longer matches. What happens is the model'
219
236
  ```json
220
237
  {
221
238
  "results": [
222
- { "mutationId": "0190f8b3-…", "id": "0190f8a2-…", "status": "applied", "version": 3 },
239
+ { "mutationId": "0190f8b3-…", "id": "6720c0…", "status": "applied", "version": 3 },
223
240
  { "mutationId": "0190f8b4-…", "id": "6720c1…", "status": "conflict", "version": 7, "record": { … } },
224
- { "mutationId": "0190f8b5-…", "id": "0190f8a9-…", "status": "rejected", "code": "VALIDATION_FAILED", "details": [ … ] }
241
+ { "mutationId": "0190f8b5-…", "id": "6720c9…", "status": "rejected", "code": "VALIDATION_FAILED", "details": [ … ] }
225
242
  ]
226
243
  }
227
244
  ```
@@ -241,7 +258,7 @@ A conflict is a `baseVersion` that no longer matches. What happens is the model'
241
258
  |---|---|
242
259
  | `VALIDATION_FAILED` | `data` failed input validation; `details` lists the fields. |
243
260
  | `FORBIDDEN` | The caller may not change this record or these fields. |
244
- | `INVALID_ID` | The id has neither accepted format. |
261
+ | `INVALID_ID` | The id does not match the model's id format (§3). |
245
262
  | `ID_UNAVAILABLE` | The id cannot be used (§3); no further detail by design. |
246
263
  | `CUSTOM_REFUSED` | The `custom` conflict hook refused the change. |
247
264
 
@@ -257,7 +274,7 @@ When the server cannot continue a cursor consistently, pull answers:
257
274
  | `reason` | Cause |
258
275
  |---|---|
259
276
  | `horizon` | The cursor is older than the tombstone retention horizon; deletions in between may be gone. |
260
- | `access` | The caller's access changed since the cursor was issued: a tenant membership removed, a role changed, a record moved to another tenant or visibility scope. |
277
+ | `access` | The caller's access changed since the cursor was issued while the caller may still pull the model: a tenant membership removed (on a pull without `X-Tenant-Id`), a role changed, a record moved to another tenant or visibility scope. Once the caller may not pull the model at all, the pull answers `403` instead (§10). |
261
278
  | `invalid` | The cursor is malformed, was issued for another model or user, or for an incompatible sync configuration. |
262
279
 
263
280
  **Client procedure:** keep the unsent local changes, delete every local record of that model, pull without a cursor
@@ -272,13 +289,18 @@ start, on the `online` event and on focus. The server never assumes a client syn
272
289
  Sync API exists only in Chromium).
273
290
 
274
291
  * **SSE** for every web client: `GET /sync/hints?models=note,task` with `Accept: text/event-stream`, authenticated like
275
- any request. Events: `event: change` with `data: {"model":"note","at":"2026-11-02T09:15:44.002Z"}`; a comment line
276
- every 25 seconds keeps proxies from closing the stream.
292
+ any request (`models` optional: all models without it; a name that is not offered never matches). Events:
293
+ `event: change` with `data: {"model":"note","at":"2026-11-02T09:15:44.002Z"}`; a comment line every 25 seconds keeps
294
+ proxies from closing the stream, and `retry: 3000` tells the EventSource how soon to reconnect. A signed-in caller
295
+ always gets the stream (`200`); models the caller may not pull simply never send a hint, so the stream reveals
296
+ nothing about which models exist. `404` when the server switched the SSE channel off.
277
297
  * **GraphQL subscription** for clients that use GraphQL: `subscription { syncHint(models: ["note"]) { model at } }`.
278
298
  * A hint carries the model name and a time — no record, no id, no tenant. It is sent only to subscribers who hold the
279
299
  model's pull roles and may read the changed tenant's records of that model; bursts are collapsed (default 250 ms).
280
300
  * Both channels are long-lived, so the server checks them again during their lifetime like its GraphQL WebSockets: a
281
- channel whose user lost access, signed out or changed roles is closed; the client reconnects and is checked anew.
301
+ channel whose user lost access, signed out or changed roles is closed; the client reconnects and is checked anew. An
302
+ SSE stream also ends before its first hint after the re-check interval (30 s by default) and is reconnected — a hint
303
+ is never delivered on rights older than that.
282
304
  * On reconnect a client pulls once, because hints sent while it was disconnected are not replayed.
283
305
 
284
306
  ## 10. HTTP errors
@@ -287,40 +309,49 @@ Sync API exists only in Chromium).
287
309
  |---|---|
288
310
  | `400` | Malformed body or query, unknown `op`, more than `maxPushItems` items. |
289
311
  | `401` | Not authenticated or session expired. Nothing was applied. |
290
- | `403` | Authenticated, but without the model's pull roles (pull, hints); a cookie-authenticated push failing the origin rules. A push item lacking the operation's roles is `rejected` / `FORBIDDEN` (§7). |
312
+ | `403` | On a pull: the caller may not pull the model (pull roles withdrawn), or `X-Tenant-Id` names a tenant they left or that was deactivated — access loss like `410 access`: delete that model's (and tenant's) local records and cursor; the unsent changes cannot be pushed under this account (§11). On a push: a cookie-authenticated push failing the origin rules. A push item lacking the operation's roles is `rejected` / `FORBIDDEN` (§7). |
291
313
  | `404` | The model is not registered for sync. |
292
314
  | `410` | Resync required (§8). |
293
315
  | `413` | The body exceeds the server's limit. |
294
316
  | `429` | Rate limit; `Retry-After` says when to try again. |
295
317
  | `503` | A store needed for the request is unavailable; retry later. |
296
318
 
297
- Error bodies use the framework's error-code format (`#LTNS_…` codes, registered when the module ships).
319
+ Error bodies use the framework's error-code format, sync codes in `#LTNS_0700`–`#LTNS_0799` (`SYNC_RESYNC_REQUIRED`,
320
+ `SYNC_MODEL_NOT_FOUND`, `SYNC_INVALID_REQUEST`, `SYNC_ORIGIN_REFUSED`, `SYNC_STORE_UNAVAILABLE`; 429 is
321
+ `RATE_LIMIT_EXCEEDED`). The `410` body is always the one §8 shows, whatever exception filter the project registered.
298
322
 
299
323
  ## 11. Client duties
300
324
 
301
325
  * Sync at start, on `online`, on focus, and after a hint; back off exponentially on `429`, `503` and network errors.
302
- * Generate ids (uuidv7 recommended) and mutation ids locally; persist unsent changes with their mutation ids until a
326
+ * Generate ids in the model's format with the random part §3 requires, and mutation ids, locally; persist unsent changes with their mutation ids until a
303
327
  terminal status (`applied`, `merged`, `conflict`, `rejected`, `not_found`) arrives.
304
328
  * Give every queued change of one record the `baseVersion` the change before it produces (§6.2), or fold them into one
305
329
  item; push them in that order.
306
330
  * Store each pulled page and its cursor atomically; compare `version` before overwriting a local record.
307
- * **On logout delete every local record and the cursors.** The device holds personal data outside the server; the
308
- integration guide of the sync module lists what to delete.
331
+ * **On logout delete every local record, the cursors and the unsent changes**, and do the same before another user's
332
+ first pull. The device holds personal data outside the server; the sync module's `SECURITY.md` lists what to delete
333
+ and how to keep the credential out of reach.
334
+ * **On `410 access` and on `403` to a pull, delete the local records of that model** (and tenant) and the cursor — the
335
+ caller lost access, and the device must not keep what the server would no longer send (§8, §10, the module's
336
+ `SECURITY.md`).
309
337
  * Treat a cleared storage as a fresh start (no cursor).
310
338
 
311
339
  ## 12. What the server guarantees
312
340
 
313
341
  * Pull and push run through the same `@Restricted`, `securityCheck`, tenant and output pipeline as every other
314
342
  request, with the operation roles the model declares for sync (§1); hints reach only callers with the pull roles.
315
- * A pull never delivers a record or tombstone of another tenant; a push never changes one and never reveals that one
316
- exists (`not_found` / `ID_UNAVAILABLE`, §3, §7).
343
+ * A pull never delivers a record or tombstone of another tenant; a push never changes one. An update or delete of it
344
+ answers `not_found` like an unused id, a create on its id `ID_UNAVAILABLE` — the same answer for every reason, with
345
+ nothing about the record; that an id is taken stays observable by design (§3, decided O2, §15).
317
346
  * Tombstones carry no content field. On a model whose records only certain users may see, the tombstone keeps the
318
347
  user-id fields the model declares as its scope (e.g. `ownerId`, decided O1, §15), so it reaches exactly the users who
319
348
  could see the record — never the rest of the tenant.
320
349
  * Hard deletes are not reachable through sync.
321
350
  * Changes are never lost by the cursor (§5.3) and never duplicated by retries (§6.5).
322
- * Idempotency records, rate-limit counters and the retention horizon live in infrastructure stores with an explicit
323
- expiry condition, never in a TTL index; expired tombstones are removed by a cron job under the `LockStore` lease.
351
+ * Idempotency records and rate-limit counters live in infrastructure stores with an explicit expiry condition that
352
+ every reader checks itself — a TTL index (MongoDB) or the expiry sweep (SQL) only removes what has expired. The
353
+ retention horizon never expires. Tombstones older than `sync.retention.tombstonesDays` (90 days) are removed every
354
+ hour under a lease, after the horizon moved past them.
324
355
 
325
356
  ## 13. Compatibility
326
357
 
@@ -353,13 +384,16 @@ Every requirement of DEV-3819 and where this document meets it (P = `.claude/pla
353
384
  | Unique indexes do not fail on tombstones (partial; MySQL via generated column) | P §3.3 (E3), P §3.6 |
354
385
  | Cursor without loss across instances, clocks, equal timestamps, late commits; prototype decides | §5.3, P §3.4, P phase 2 |
355
386
  | Push item-atomic; batch transaction only with `capabilities.transactions`, not the default | §6.3 |
356
- | Hints via `capabilities.changeFeed` where present, else emitted by the CrudService via in-process / Redis pub/sub | §9, P §3.9 |
387
+ | Hints emitted by the store after every successful write, via in-process / Redis pub/sub; no change feed (P2-5, §15) | §9, P §3.9 |
357
388
  | Idempotency keys, rate-limit counters, retention horizon in infrastructure stores with explicit expiry; cron sweep under `LockStore` | §12, P §3.7 |
358
389
  | Every pull/push through the full pipeline; no `pullLean` | §1, §12 |
359
- | Tombstones carry `tenantId` and only `id`, `version`, `deletedAt`, `updatedAt` | §2, §12; plus declared user-id scope fields, decided O1 (§15) |
390
+ | Tombstones carry `tenantId` and only `id`, `version`, `deletedAt`, `updatedAt` | §2, §12; plus declared user-id scope fields, decided O1 (§15); the server keeps the audit fields too (DEV-3900), a pull sends none |
360
391
  | Hard delete not reachable through sync | §6.2, §12 |
361
- | Access loss → client deletes local copies; at least a forced full resync | §8 |
362
- | Integration doc says what a client deletes on logout | §11 (detailed in the module's checklist, phase 6) |
392
+ | Access loss → client deletes local copies; at least a forced full resync | §8 (`410 access`), §10 / §11 (`403` on a pull) |
393
+ | Integration doc says what a client deletes on logout | §11, the module's `SECURITY.md` and its checklist |
394
+ | Acceptance: the same contract suite green on MongoDB standalone and replica set, PostgreSQL × three engines (and MySQL, MariaDB) | `tests/sync-contract.e2e-spec.ts` (framework repository) |
395
+ | Acceptance: no lost change with parallel writers on two instances with skewed clocks | `tests/sync-clock-skew.e2e-spec.ts` |
396
+ | Acceptance: PWA test client in a browser, and a client without one, pass the same scenarios | `tests/sync-pwa-client.e2e-spec.ts`, `tests/sync-node-client.e2e-spec.ts` |
363
397
  | Without sync configuration nothing changes | §1, §13 |
364
398
  | Decisions 2026-10-29: both id formats, full resync on access loss, SSE + GraphQL, test clients, prototype decides the cursor | §3, §8, §9, P §11 |
365
399
 
@@ -367,10 +401,10 @@ Every requirement of DEV-3819 and where this document meets it (P = `.claude/pla
367
401
 
368
402
  | # | Question | Decided by | Notes |
369
403
  |---|---|---|---|
370
- | P2-1 | Cursor mechanism: change sequence with a commit horizon, or overlap window with `version` comparison | phase 2 prototype on MongoDB standalone and replica set, PostgreSQL × TypeORM / Kysely / Drizzle, MySQL, MariaDB | P §3.4 lists the candidates and what is measured |
371
- | P2-2 | Whether a page may repeat an unchanged record | phase 2, follows from P2-1 | Clients compare `version` either way (§5.2) |
372
- | P2-3 | MongoDB collections holding ObjectId and string `_id` side by side: casting, populate, sort, references | phase 2 | §3 |
373
- | P2-4 | Partial unique index on MongoDB with `{ deletedAt: { $type: 'null' } }`, on MySQL / MariaDB through virtual live-key columns (both refuse a plain partial unique index) | phase 2 contract case | P §3.6 |
374
- | P2-5 | Whether `capabilities.changeFeed` (change streams on a replica set, `LISTEN/NOTIFY` on PostgreSQL) is worth it for hints, measured against CrudService-emitted events | phase 2 | §9 |
404
+ | P2-1 | Cursor mechanism: change sequence with a commit horizon, or overlap window with `version` comparison | **decided 2026-10-31 (DEV-3899, measured on all seven targets):** SQL — a commit-ordered sequence, the counter taken last (no horizon needed); MongoDB — the server clock with a window W derived from the transaction lifetime | P §3.4, P §5 |
405
+ | P2-2 | Whether a page may repeat an unchanged record | **decided 2026-10-31 (DEV-3899):** on MongoDB yes, within W before the cursor; on SQL no | Clients compare `version` either way (§5.2) |
406
+ | P2-3 | MongoDB collections holding ObjectId and string `_id` side by side: casting, populate, sort, references | **dropped 2026-10-29 (owner, DEV-3899):** one id format per model, never mixed — the question cannot arise | §3 |
407
+ | P2-4 | Partial unique index on MongoDB with `{ deletedAt: { $type: 'null' } }`, on MySQL / MariaDB through virtual live-key columns (both refuse a plain partial unique index) | **decided 2026-10-31 (DEV-3899, measured):** MongoDB filters live records with `{ deletedAt: null }` (accepted on 5.0, 7.0, 8.2, covers a missing field, used by the matching query); PostgreSQL keeps `NULLS NOT DISTINCT` with `WHERE deleted_at IS NULL`; MySQL / MariaDB use the virtual live-key column. Every target refuses a second live value and a second live `NULL` and lets tombstones repeat a value | P §3.6, P §5 |
408
+ | P2-5 | Whether `capabilities.changeFeed` (change streams on a replica set, `LISTEN/NOTIFY` on PostgreSQL) is worth it for hints, measured against CrudService-emitted events | **decided 2026-10-31 (DEV-3899):** no. A feed would add hints only for writes that bypass the store. Those reach a client with its next pull at the earliest (on SQL the sequence trigger numbers them too, on MongoDB they carry no cursor field and are not pulled at all — sync models are written through the store), and hints are optional by design. The store path is needed on MySQL, MariaDB and standalone MongoDB regardless. One source, the store's write events | §9, P §3.9 |
375
409
  | O1 | Tombstones on per-user-visibility models | **decided 2026-10-29 (owner):** a tombstone keeps the user-id fields the model declares as its scope (`syncScope`, e.g. `ownerId`) — only user ids, never content | §2, §12; otherwise a tombstone either reaches the whole tenant (ids and deletion times of other users' records) or nobody (a deletion never reaches the owner's other devices) |
376
410
  | O2 | Id collision with a record the caller may not see | **decided 2026-10-29 (owner):** the uniform `ID_UNAVAILABLE` stays, ids stay unique across tenants | §3; ids unique per tenant would change every lookup, reference and route by id in the framework |
@@ -89,6 +89,20 @@ overrides:
89
89
  '@graphql-tools/utils@>=12.0.0 <12.1.0': '12.1.0'
90
90
  ```
91
91
 
92
+ ### Advisories no override can fix yet: `minimatch` and `glob` (2026-10-31)
93
+
94
+ [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249) (minimatch `< 11.0.1`, moderate, DoS
95
+ through globparts amplification) and [GHSA-3g3m-2fjr-6pw4](https://github.com/advisories/GHSA-3g3m-2fjr-6pw4)
96
+ (glob `<= 14.0.1`, moderate) are fixed only in new majors that no consumer can take yet: `@nestjs/cli` 12.0.11
97
+ pins minimatch 10.2.6 exactly, ts-morph 28 (`@ts-morph/common` 0.29), nodemon, `@swc/cli` and glob 13 need the 9.x /
98
+ 10.x lines, rimraf 6.1.3 needs glob 13 — all on their newest release. An override across the major is no way out
99
+ (minimatch 11 removed `makeRe()` and `Minimatch#regexp`). nest-server suppresses both in its own repository with
100
+ `auditConfig.unusableFixConsumers`, which `check:overrides` re-tests on every run. What it found for its own tree:
101
+ the only production path is `@lenne.tech/nest-server > ts-morph > @ts-morph/common > minimatch`, used by the
102
+ Permissions module's scanner with globs the developer writes; every other path is dev tooling. Your project's tree
103
+ may have other paths, so check `pnpm why minimatch --prod` and `pnpm why glob --prod` before you suppress the same
104
+ way, and remove the entries once your consumers move to minimatch 11 / glob 14.
105
+
92
106
  ### Only on nest-server 11.x: `ws` and `js-yaml`
93
107
 
94
108
  `@nestjs/graphql` 13 and `@nestjs/swagger` 11 exact-pin vulnerable versions of these two; their 12.x
@@ -0,0 +1,154 @@
1
+ # Migration Guide: 12.1.x → 12.2.x
2
+
3
+ ## No breaking change
4
+
5
+ 12.2.0 adds **offline sync**: mobile apps (React Native, Capacitor) with SQLite, web apps and PWAs with IndexedDB, and
6
+ desktop or Node clients read and write while disconnected and reconcile with the server afterwards. It is opt-in in
7
+ two places — the `sync` configuration and a declaration per model — and **without both nothing changes**: no field,
8
+ index, route or code path is added, and a project's test suite stays as it is (protocol §13). It is a MINOR because of
9
+ its size, not because anything existing moves.
10
+
11
+ ## Overview
12
+
13
+ | Category | Details |
14
+ |---|---|
15
+ | **Breaking Changes** | None |
16
+ | **New Features** | Synchronized models: `version`, tombstones, live-only unique indexes, a change order on every database (§2). The sync module: `GET/POST /sync/{model}` with cursor pull, idempotent push, conflict strategies, client ids, resync (§3). Change hints over SSE and GraphQL, tombstone purge, the `syncUpdate` migration helper (§4). `ServiceOptions.expectedVersion` for conditional changes of a synchronized model (§2) |
17
+ | **Bugfixes** | None that change existing behaviour |
18
+ | **Migration Effort** | None for an update. Offering a model to offline clients: about an hour per model, plus the client (§5) |
19
+
20
+ ## Quick Migration
21
+
22
+ ```bash
23
+ pnpm add @lenne.tech/nest-server@12.2.0
24
+ pnpm run build
25
+ pnpm test
26
+ ```
27
+
28
+ No dependency of a project changes. **Vendor mode**: run `/lt-dev:backend:update-nest-server-core` as usual; the new
29
+ module is `src/core/modules/sync/`.
30
+
31
+ ## 1. What changes for an existing project
32
+
33
+ Nothing, unless it switches offline sync on. The framework's own models are not synchronized, the `CrudService` keeps
34
+ its behaviour for every model that does not declare `sync`, and no route is registered without the `sync`
35
+ configuration.
36
+
37
+ ## 2. Synchronized models
38
+
39
+ A model takes part in offline sync when it is declared with `sync` and registered with
40
+ `CorePersistenceModule.forFeature()`:
41
+
42
+ ```typescript
43
+ @PersistedModel({
44
+ sync: {
45
+ roles: { create: [RoleEnum.S_USER], delete: [RoleEnum.S_CREATOR], pull: [RoleEnum.S_USER], update: [RoleEnum.S_CREATOR] },
46
+ strategy: 'merge',
47
+ },
48
+ })
49
+ @Schema({ timestamps: true })
50
+ @ObjectType()
51
+ export class Note extends CorePersistenceModel { … }
52
+ ```
53
+
54
+ The store then keeps three system fields on every write path — `version`, `deletedAt` and the change position
55
+ (`syncSeq` on SQL, `syncAt` on MongoDB) — never settable through an input:
56
+
57
+ - **Deletes leave tombstones**: `CrudService.delete()` and every port delete keep the record as a tombstone without
58
+ content, so clients learn of the deletion. Reads leave tombstones out.
59
+ - **Unique indexes apply to live records only**, so delete-then-recreate with the same value works.
60
+ - **Conditional changes**: `ServiceOptions.expectedVersion` on `update()` / `delete()` is one statement with the
61
+ version as its condition; another version answers `409` (`VersionConflictError`). Only a synchronized model has a
62
+ `version`: on any other model `expectedVersion` throws `UnsupportedCapabilityError`.
63
+
64
+ **Making an EXISTING model synchronized is a migration** on every database — the boot refuses with the statements to
65
+ run (SQL: the new column and live-only indexes; MongoDB: `SyncMigrationRequiredError`, dropping the old unique indexes
66
+ and stamping every document). Details: `.claude/rules/persistence.md` → "Synchronized models".
67
+
68
+ ## 3. The sync module
69
+
70
+ ```typescript
71
+ // config.env.ts
72
+ sync: {
73
+ cursorSecret: process.env.SYNC_CURSOR_SECRET, // else SECRETS_ENCRYPTION_KEY; required in production/staging
74
+ },
75
+ ```
76
+
77
+ ```typescript
78
+ @SyncService({ createInput: NoteCreateInput, model: Note, updateInput: NoteInput })
79
+ @Injectable()
80
+ export class NoteService extends CrudService<Note, NoteCreateInput, NoteInput> { … }
81
+ ```
82
+
83
+ - `GET /sync/{model}`: one page of changes after an opaque, signed cursor; `410` with a reason when the client must
84
+ resync (`invalid`, `horizon`, `access`).
85
+ - `POST /sync/{model}`: items processed in order, each atomic; idempotent through mutation ids; client-generated ids
86
+ (ObjectId-hex with a random tail, or uuidv7 for new models); strategies `reject`, `lww`, `merge` (`@SyncField()` for
87
+ fields), `custom` (`resolveSyncConflict()` on the service).
88
+ - Every pull and push runs through the model's `CrudService` with the roles from `sync.roles` — the roles of the
89
+ project's own routes do not apply here, and an operation without roles is ADMIN only.
90
+ - Without `X-Tenant-Id` a request spans every tenant the caller belongs to.
91
+
92
+ The protocol for client authors: `docs/offline-sync-protocol.md`. The module: `src/core/modules/sync/README.md`.
93
+
94
+ ## 4. Hints, purge, data migrations
95
+
96
+ - **Hints**: `GET /sync/hints` (Server-Sent Events, event `change`) and the GraphQL subscription `syncHint(models)` tell
97
+ a client "pull now" — `{ model, at }` only, to callers with the pull roles and access to the tenant. With `redis`
98
+ every replica's subscribers receive them. `sync.hints: false` switches them off.
99
+ - **Purge**: tombstones older than `sync.retention.tombstonesDays` (90) are deleted every hour on one replica, after
100
+ the retention horizon moved past them; a client behind it resyncs.
101
+ - **Data migrations** of a synchronized SQL table: `runSqlMigration(ownerUrl, [{ syncUpdate: { table, set, where } }])`
102
+ raises `version` and numbers the rows like a store write. A bare `UPDATE` would let a stale client overwrite the
103
+ migrated value without a conflict.
104
+ - **Writes past the CrudService** (data store, native API) call `CoreSyncService.notifyChange()` for hints and
105
+ `bumpAccessEpoch()` when a record changes tenant or owner.
106
+
107
+ ## 5. Offering a model to offline clients — checklist
108
+
109
+ Follow `src/core/modules/sync/INTEGRATION-CHECKLIST.md`. In short:
110
+
111
+ 1. Declare `@PersistedModel({ sync: { roles, strategy } })` and register the model with `CorePersistenceModule.forFeature()`.
112
+ 2. Put `@SyncService({ model, createInput, updateInput })` on its `CrudService`.
113
+ 3. Add `sync: {}` (with a `cursorSecret` for deployed stages) to the configuration.
114
+ 4. Give the client its duties: delete every local record, cursor and unsent change on logout and on an account switch
115
+ (`src/core/modules/sync/SECURITY.md`).
116
+
117
+ The reference model is `src/server/modules/sync-note/`; the framework's own test clients (a PWA on IndexedDB and a Node
118
+ client on SQLite, `tests/clients/` in the framework repository) show a complete client in a few hundred lines.
119
+
120
+ ## Compatibility Notes
121
+
122
+ | Pattern | Status |
123
+ |---|---|
124
+ | Project without `sync` configuration | Unchanged |
125
+ | Model without `sync` in a project with `sync` | Unchanged; not reachable under `/sync` |
126
+ | `CrudService.delete()` on a synchronized model | Leaves a tombstone; a hard delete is the purge's alone |
127
+ | Project controller extending `CoreSyncController` | Fine — the hint route is `CoreSyncHintsController`'s, registered first, so re-declaring `pull()` cannot shadow it |
128
+ | TTL index on a synchronized model | Refused at registration — an expired record would vanish without a tombstone |
129
+
130
+ ## Troubleshooting
131
+
132
+ | Symptom | Cause | Fix |
133
+ |---|---|---|
134
+ | Boot fails: `cursorSecret` missing | Production / staging without `sync.cursorSecret` or `SECRETS_ENCRYPTION_KEY` | Set one of them |
135
+ | Boot fails with SQL statements or `SyncMigrationRequiredError` | An existing model was declared `sync` | Run the printed migration (§2) |
136
+ | Warning "declares no sync roles" | `sync.roles` missing an operation | Declare roles per operation; otherwise ADMIN only |
137
+ | `400` on a push | `Content-Type` is not `application/json` | Send JSON |
138
+ | `403` on a cookie push | `Sec-Fetch-Site: cross-site` or an untrusted `Origin` | Push from a trusted origin (`appUrl`, `baseUrl`, `cors.allowedOrigins`) |
139
+ | Every cursor answers `410 invalid` | `cursorSecret` changed | Expected: clients resync once |
140
+ | Boot fails on MySQL / MariaDB with error 1419 (`You do not have the SUPER privilege and binary logging is enabled`) once a model is declared `sync` | The change-order triggers are created by the owner account (`database.ownerUrl`); with binary logging on (the MySQL 8.4 default) that needs `SUPER` or `log_bin_trust_function_creators=1` | Set `log_bin_trust_function_creators=1` on the server (the parameter group on managed MySQL), or grant the owner `SUPER`. The application account needs neither |
141
+ | A client keeps records after its user left a tenant | The client treats only `410 access` as access loss | A pull answering `403` (pull roles withdrawn, or `X-Tenant-Id` naming a left tenant) is access loss too: drop that model's local records and cursor (`SECURITY.md`) |
142
+ | Hints only on one replica | No `redis` configured | Configure `redis`; hints are never a correctness signal |
143
+
144
+ ## Module Documentation
145
+
146
+ - `src/core/modules/sync/README.md` — module overview, configuration, security model, hints, purge
147
+ - `src/core/modules/sync/INTEGRATION-CHECKLIST.md` — integration steps
148
+ - `src/core/modules/sync/SECURITY.md` — what the server enforces, what a client must do
149
+ - `docs/offline-sync-protocol.md` — the client contract
150
+ - `src/server/modules/sync-note/` — reference model
151
+
152
+ ## References
153
+
154
+ - nest-server-starter: unaffected (it declares no synchronized model)
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "12.1.1",
3
+ "version": "12.2.0",
4
4
  "type": "module",
5
- "description": "Server framework in TypeScript on top of NestJS: GraphQL and REST APIs on MongoDB, PostgreSQL, MySQL or MariaDB, with authentication, field-level permissions, multi-tenancy and an AI assistant.",
5
+ "description": "Server framework in TypeScript on top of NestJS: GraphQL and REST APIs on MongoDB, PostgreSQL, MySQL or MariaDB, with authentication, field-level permissions, multi-tenancy, offline sync and an AI assistant.",
6
6
  "keywords": [
7
7
  "node",
8
8
  "nest",
@@ -15,6 +15,7 @@
15
15
  "graphql",
16
16
  "rest",
17
17
  "multi-tenancy",
18
+ "offline-sync",
18
19
  "typescript"
19
20
  ],
20
21
  "author": "lenne.Tech <info@lenne.tech> (https://lenne.tech)",
@@ -263,6 +264,7 @@
263
264
  "oxlint": "1.90.0",
264
265
  "oxlint-tsgolint": "7.0.2005",
265
266
  "pg": "8.23.1",
267
+ "playwright-core": "1.64.1",
266
268
  "rimraf": "6.1.3",
267
269
  "smtp-server": "3.21.1",
268
270
  "tsx": "4.23.15",