@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.
- package/.claude/rules/architecture.md +1 -0
- package/.claude/rules/configurable-features.md +1 -0
- package/.claude/rules/package-management.md +16 -1
- package/.claude/rules/persistence.md +26 -0
- package/.claude/rules/testing.md +79 -14
- package/CLAUDE.md +2 -1
- package/FRAMEWORK-API.md +17 -2
- package/README.md +18 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +41 -0
- package/dist/core/common/interfaces/service-options.interface.d.ts +3 -0
- package/dist/core/common/persistence/core-persistence.module.js +12 -2
- package/dist/core/common/persistence/core-persistence.module.js.map +1 -1
- package/dist/core/common/persistence/data-source.runtime.js +4 -2
- package/dist/core/common/persistence/data-source.runtime.js.map +1 -1
- package/dist/core/common/persistence/data-store.interface.d.ts +24 -9
- package/dist/core/common/persistence/id-format.d.ts +2 -0
- package/dist/core/common/persistence/id-format.js +14 -0
- package/dist/core/common/persistence/id-format.js.map +1 -1
- package/dist/core/common/persistence/model-descriptor.interface.d.ts +6 -0
- package/dist/core/common/persistence/model-metadata.registry.d.ts +2 -1
- package/dist/core/common/persistence/model-metadata.registry.js +20 -2
- package/dist/core/common/persistence/model-metadata.registry.js.map +1 -1
- package/dist/core/common/persistence/mongo/sync-schema.plugin.d.ts +18 -0
- package/dist/core/common/persistence/mongo/sync-schema.plugin.js +368 -0
- package/dist/core/common/persistence/mongo/sync-schema.plugin.js.map +1 -0
- package/dist/core/common/persistence/mongoose-data-store.d.ts +19 -9
- package/dist/core/common/persistence/mongoose-data-store.js +107 -31
- package/dist/core/common/persistence/mongoose-data-store.js.map +1 -1
- package/dist/core/common/persistence/persisted-model.decorator.d.ts +3 -0
- package/dist/core/common/persistence/persisted-model.decorator.js +9 -0
- package/dist/core/common/persistence/persisted-model.decorator.js.map +1 -1
- package/dist/core/common/persistence/policies/sync.policy.d.ts +17 -0
- package/dist/core/common/persistence/policies/sync.policy.js +77 -0
- package/dist/core/common/persistence/policies/sync.policy.js.map +1 -0
- package/dist/core/common/persistence/sql/mysql/mysql-data-store.d.ts +12 -8
- package/dist/core/common/persistence/sql/mysql/mysql-data-store.js +82 -29
- package/dist/core/common/persistence/sql/mysql/mysql-data-store.js.map +1 -1
- package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.d.ts +10 -0
- package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js +61 -4
- package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js.map +1 -1
- package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +101 -4
- package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js.map +1 -1
- package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.d.ts +10 -0
- package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js +41 -2
- package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js.map +1 -1
- package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js +98 -10
- package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js.map +1 -1
- package/dist/core/common/persistence/sql/sql-data-store.d.ts +38 -14
- package/dist/core/common/persistence/sql/sql-data-store.js +212 -38
- package/dist/core/common/persistence/sql/sql-data-store.js.map +1 -1
- package/dist/core/common/persistence/sql/sql-errors.d.ts +2 -1
- package/dist/core/common/persistence/sql/sql-errors.js +3 -1
- package/dist/core/common/persistence/sql/sql-errors.js.map +1 -1
- package/dist/core/common/persistence/sync-access.registry.d.ts +17 -0
- package/dist/core/common/persistence/sync-access.registry.js +8 -0
- package/dist/core/common/persistence/sync-access.registry.js.map +1 -0
- package/dist/core/common/persistence/sync-model.d.ts +29 -0
- package/dist/core/common/persistence/sync-model.js +138 -0
- package/dist/core/common/persistence/sync-model.js.map +1 -0
- package/dist/core/common/services/crud.service.d.ts +13 -1
- package/dist/core/common/services/crud.service.js +174 -16
- package/dist/core/common/services/crud.service.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +54 -0
- package/dist/core/modules/error-code/error-codes.js +48 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/migrate/sql-migration.helper.d.ts +15 -1
- package/dist/core/modules/migrate/sql-migration.helper.js +90 -5
- package/dist/core/modules/migrate/sql-migration.helper.js.map +1 -1
- package/dist/core/modules/sync/core-sync-base.controller.d.ts +7 -0
- package/dist/core/modules/sync/core-sync-base.controller.js +44 -0
- package/dist/core/modules/sync/core-sync-base.controller.js.map +1 -0
- package/dist/core/modules/sync/core-sync-cursor.d.ts +19 -0
- package/dist/core/modules/sync/core-sync-cursor.js +77 -0
- package/dist/core/modules/sync/core-sync-cursor.js.map +1 -0
- package/dist/core/modules/sync/core-sync-hints.controller.d.ts +4 -0
- package/dist/core/modules/sync/core-sync-hints.controller.js +55 -0
- package/dist/core/modules/sync/core-sync-hints.controller.js.map +1 -0
- package/dist/core/modules/sync/core-sync-hints.service.d.ts +28 -0
- package/dist/core/modules/sync/core-sync-hints.service.js +155 -0
- package/dist/core/modules/sync/core-sync-hints.service.js.map +1 -0
- package/dist/core/modules/sync/core-sync-infrastructure.models.d.ts +2 -0
- package/dist/core/modules/sync/core-sync-infrastructure.models.js +17 -0
- package/dist/core/modules/sync/core-sync-infrastructure.models.js.map +1 -0
- package/dist/core/modules/sync/core-sync-mutation.store.d.ts +43 -0
- package/dist/core/modules/sync/core-sync-mutation.store.js +88 -0
- package/dist/core/modules/sync/core-sync-mutation.store.js.map +1 -0
- package/dist/core/modules/sync/core-sync-state.store.d.ts +18 -0
- package/dist/core/modules/sync/core-sync-state.store.js +64 -0
- package/dist/core/modules/sync/core-sync-state.store.js.map +1 -0
- package/dist/core/modules/sync/core-sync.constants.d.ts +28 -0
- package/dist/core/modules/sync/core-sync.constants.js +23 -0
- package/dist/core/modules/sync/core-sync.constants.js.map +1 -0
- package/dist/core/modules/sync/core-sync.controller.d.ts +8 -0
- package/dist/core/modules/sync/core-sync.controller.js +79 -0
- package/dist/core/modules/sync/core-sync.controller.js.map +1 -0
- package/dist/core/modules/sync/core-sync.decorators.d.ts +12 -0
- package/dist/core/modules/sync/core-sync.decorators.js +28 -0
- package/dist/core/modules/sync/core-sync.decorators.js.map +1 -0
- package/dist/core/modules/sync/core-sync.module.d.ts +16 -0
- package/dist/core/modules/sync/core-sync.module.js +50 -0
- package/dist/core/modules/sync/core-sync.module.js.map +1 -0
- package/dist/core/modules/sync/core-sync.resolver.d.ts +9 -0
- package/dist/core/modules/sync/core-sync.resolver.js +46 -0
- package/dist/core/modules/sync/core-sync.resolver.js.map +1 -0
- package/dist/core/modules/sync/core-sync.service.d.ts +142 -0
- package/dist/core/modules/sync/core-sync.service.js +1208 -0
- package/dist/core/modules/sync/core-sync.service.js.map +1 -0
- package/dist/core/modules/sync/interfaces/core-sync.interfaces.d.ts +56 -0
- package/dist/core/modules/sync/interfaces/core-sync.interfaces.js +2 -0
- package/dist/core/modules/sync/interfaces/core-sync.interfaces.js.map +1 -0
- package/dist/core/modules/sync/models/core-sync-hint.model.d.ts +4 -0
- package/dist/core/modules/sync/models/core-sync-hint.model.js +30 -0
- package/dist/core/modules/sync/models/core-sync-hint.model.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +4 -0
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core.module.js +14 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +6 -0
- package/dist/server/modules/sync-note/inputs/sync-note-create.input.d.ts +4 -0
- package/dist/server/modules/sync-note/inputs/sync-note-create.input.js +27 -0
- package/dist/server/modules/sync-note/inputs/sync-note-create.input.js.map +1 -0
- package/dist/server/modules/sync-note/inputs/sync-note.input.d.ts +6 -0
- package/dist/server/modules/sync-note/inputs/sync-note.input.js +37 -0
- package/dist/server/modules/sync-note/inputs/sync-note.input.js.map +1 -0
- package/dist/server/modules/sync-note/sync-note.model.d.ts +10 -0
- package/dist/server/modules/sync-note/sync-note.model.js +73 -0
- package/dist/server/modules/sync-note/sync-note.model.js.map +1 -0
- package/dist/server/modules/sync-note/sync-note.module.d.ts +2 -0
- package/dist/server/modules/sync-note/sync-note.module.js +21 -0
- package/dist/server/modules/sync-note/sync-note.module.js.map +1 -0
- package/dist/server/modules/sync-note/sync-note.service.d.ts +10 -0
- package/dist/server/modules/sync-note/sync-note.service.js +35 -0
- package/dist/server/modules/sync-note/sync-note.service.js.map +1 -0
- package/docs/REQUEST-LIFECYCLE.md +1 -0
- package/docs/offline-sync-protocol.md +96 -62
- package/docs/security-overrides.md +14 -0
- package/migration-guides/12.1.x-to-12.2.x.md +154 -0
- package/package.json +4 -2
- package/src/core/common/interfaces/server-options.interface.ts +149 -0
- package/src/core/common/interfaces/service-options.interface.ts +15 -0
- package/src/core/common/persistence/core-persistence.module.ts +21 -2
- package/src/core/common/persistence/data-source.runtime.ts +5 -2
- package/src/core/common/persistence/data-store.interface.ts +65 -12
- package/src/core/common/persistence/id-format.ts +26 -0
- package/src/core/common/persistence/model-descriptor.interface.ts +12 -0
- package/src/core/common/persistence/model-metadata.registry.ts +31 -2
- package/src/core/common/persistence/mongo/sync-schema.plugin.ts +508 -0
- package/src/core/common/persistence/mongoose-data-store.ts +171 -35
- package/src/core/common/persistence/persisted-model.decorator.ts +26 -0
- package/src/core/common/persistence/policies/sync.policy.ts +121 -0
- package/src/core/common/persistence/sql/mysql/mysql-data-store.ts +108 -30
- package/src/core/common/persistence/sql/mysql/mysql-schema.compiler.ts +107 -4
- package/src/core/common/persistence/sql/mysql/mysql-schema.migrator.ts +129 -3
- package/src/core/common/persistence/sql/postgres/postgres-schema.compiler.ts +74 -2
- package/src/core/common/persistence/sql/postgres/postgres-schema.migrator.ts +139 -7
- package/src/core/common/persistence/sql/sql-data-store.ts +325 -44
- package/src/core/common/persistence/sql/sql-errors.ts +5 -1
- package/src/core/common/persistence/sync-access.registry.ts +50 -0
- package/src/core/common/persistence/sync-model.ts +270 -0
- package/src/core/common/services/crud.service.ts +257 -19
- package/src/core/modules/error-code/error-codes.ts +59 -0
- package/src/core/modules/migrate/sql-migration.helper.ts +141 -7
- package/src/core/modules/sync/INTEGRATION-CHECKLIST.md +95 -0
- package/src/core/modules/sync/README.md +188 -0
- package/src/core/modules/sync/SECURITY.md +65 -0
- package/src/core/modules/sync/core-sync-base.controller.ts +43 -0
- package/src/core/modules/sync/core-sync-cursor.ts +117 -0
- package/src/core/modules/sync/core-sync-hints.controller.ts +52 -0
- package/src/core/modules/sync/core-sync-hints.service.ts +178 -0
- package/src/core/modules/sync/core-sync-infrastructure.models.ts +40 -0
- package/src/core/modules/sync/core-sync-mutation.store.ts +146 -0
- package/src/core/modules/sync/core-sync-state.store.ts +89 -0
- package/src/core/modules/sync/core-sync.constants.ts +51 -0
- package/src/core/modules/sync/core-sync.controller.ts +68 -0
- package/src/core/modules/sync/core-sync.decorators.ts +84 -0
- package/src/core/modules/sync/core-sync.module.ts +75 -0
- package/src/core/modules/sync/core-sync.resolver.ts +35 -0
- package/src/core/modules/sync/core-sync.service.ts +1728 -0
- package/src/core/modules/sync/interfaces/core-sync.interfaces.ts +85 -0
- package/src/core/modules/sync/models/core-sync-hint.model.ts +18 -0
- package/src/core/modules/tenant/core-tenant.guard.ts +8 -0
- package/src/core.module.ts +25 -0
- package/src/index.ts +21 -0
- package/src/server/modules/sync-note/inputs/sync-note-create.input.ts +14 -0
- package/src/server/modules/sync-note/inputs/sync-note.input.ts +23 -0
- package/src/server/modules/sync-note/sync-note.model.ts +77 -0
- package/src/server/modules/sync-note/sync-note.module.ts +16 -0
- 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:
|
|
4
|
-
>
|
|
5
|
-
>
|
|
6
|
-
>
|
|
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
|
|
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
|
|
52
|
-
| uuidv7 | 36 characters, `xxxxxxxx-xxxx-7xxx-[89ab]xxx-xxxxxxxxxxxx`, lowercase | `crypto.randomUUID()` is v4
|
|
53
|
-
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
*
|
|
57
|
-
|
|
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.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
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
|
|
90
|
-
trusted origin or whose `Sec-Fetch-Site` is
|
|
91
|
-
|
|
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": "
|
|
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`.
|
|
135
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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": "
|
|
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
|
|
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": "
|
|
239
|
+
{ "mutationId": "0190f8b3-…", "id": "6720c0…", "status": "applied", "version": 3 },
|
|
223
240
|
{ "mutationId": "0190f8b4-…", "id": "6720c1…", "status": "conflict", "version": 7, "record": { … } },
|
|
224
|
-
{ "mutationId": "0190f8b5-…", "id": "
|
|
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
|
|
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
|
|
276
|
-
|
|
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` |
|
|
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
|
|
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
|
|
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
|
|
308
|
-
|
|
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
|
|
316
|
-
|
|
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
|
|
323
|
-
|
|
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
|
|
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
|
|
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 |
|
|
371
|
-
| P2-2 | Whether a page may repeat an unchanged record |
|
|
372
|
-
| P2-3 | MongoDB collections holding ObjectId and string `_id` side by side: casting, populate, sort, references |
|
|
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) |
|
|
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 |
|
|
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.
|
|
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",
|