@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
@@ -70,6 +70,7 @@ proves the build loads and runs with them unresolvable. Rules: `.claude/rules/pe
70
70
  | **Hub** | Build-free ADMIN-gated operator cockpit at `/hub` (config-gated per environment; 17 panels, runtime collectors, mailbox, admin actions) |
71
71
  | **Migrate** | Database migration utilities |
72
72
  | **Permissions** | ADMIN-gated security-map report (routes + roles + `@Restricted` fields); also surfaced in the Hub's "Routes / Permissions" panel |
73
+ | **Sync** | Opt-in (`sync`) offline sync for models declared `@PersistedModel({ sync })`: pull by cursor, push with idempotency and conflict strategies, resync on access loss — through the model's CrudService; change hints over SSE and GraphQL, filtered per subscriber; hourly tombstone purge under a lease |
73
74
  | **SystemSetup** | Initial admin creation for fresh deployments |
74
75
  | **Tus** | Resumable file uploads via tus.io protocol |
75
76
  | **User** | Core user management functionality |
@@ -325,6 +325,7 @@ This pattern is currently applied to:
325
325
  | Multi-Tenancy | `multiTenancy` | Presence Implies Enabled | **`excludeSchemas` is an OFF SWITCH for isolation, per model** — a listed schema gets no tenant filter at all; since 11.35.0 the plugin WARNS once per model when the excluded schema declares `tenantId` (i.e. was built for isolation). The framework's own docs recommended `['User', 'Session']` until then; that is withdrawn — it is only right for the global-user model. Since 11.35.0 GraphQL operations over the WEBSOCKET also run inside a `RequestContext` (`CoreModule` installs a context-aware `execute`/`subscribe` pair inside `subscriptions`, where `ApolloDriver` forwards it): the tenant comes from the handshake header validated against an active membership, else from the subscriber's memberships, and an unresolvable tenant leaves the safety net to refuse the read rather than returning everything. `headerName: 'x-tenant-id'`, `membershipModel: 'TenantMember'`, `adminBypass: true`, `excludeSchemas: []`, `roleHierarchy: { member: 1, manager: 2, owner: 3 }`, `cacheTtlMs: 30000` (0 disables, process-local). System roles (`S_EVERYONE`, `S_USER`, `S_VERIFIED`) are checked as OR alternatives before real roles; method-level system roles take precedence; membership validated for context when system role grants access + header present. Hierarchy roles use level comparison, normal roles use exact match. Use `DefaultHR` or `createHierarchyRoles()` for type-safe role constants. Bypass: `RequestContext.runWithBypassTenantGuard()`. Cache invalidation: `invalidateUser(userId)` / `invalidateAll()` are **instance** methods on the singleton `CoreTenantGuard` — inject it and call `this.tenantGuard?.invalidateUser(userId)`, never `CoreTenantGuard.invalidateUser(...)` (there is no static). With `redis` configured, both additionally BROADCAST the invalidation to every replica (`<keyPrefix>:tenant-cache:invalidate` pub/sub); without Redis they clear the local process only, so other replicas stay stale until `cacheTtlMs`. A received broadcast clears locally without re-publishing. **`isTenantActive` (11.42.0)**: optional hook `(tenantId, { connection, store }) => boolean \| Promise<boolean>`, absent = every tenant active (no change). `store(modelName)` (12.x, db-agnostic) is the data store of a model registered with `CorePersistenceModule.forFeature()`, wherever `database` routes it, and THROWS for any other model — an `undefined` would read as "active" in a hook written with `?.` and open every deactivated tenant. Asked AFTER the membership check on every path that establishes a tenant: 403 on header paths and for tenant API tokens, no enrichment on `S_EVERYONE`, dropped from the header-less tenant list, no tenant for a NEW WebSocket subscription, and since 11.42.3 an OPEN one is closed with 4403 on `invalidateTenant()` (or at its next event once `graphQl.subscriptionRevalidationMs` has passed — see the GraphQL Subscription Re-check row); an admin under `adminBypass` is exempt (someone must be able to switch it back on). Only `false` deactivates. Cached for `cacheTtlMs`; `invalidateTenant(tenantId)` clears and broadcasts (`{ scope: 'tenant' }`). A function, so `config.env.ts` only — not reachable through `NSC__*`. **`adminBypass: false` (11.42.0)** additionally withholds two things that went around it: `CoreUserService.update()` refuses changing another account's e-mail or password (`assertCredentialChangeAllowed()`, administrator targets included — promote-then-change — system work without a current user exempt), and the AI tool registry grants no ADMIN bypass |
326
326
  | GraphQL Subscription Re-check | `graphQl.subscriptionRevalidationMs` | Numeric — `0` = STRICTEST, `false` = off, invalid → default (a fifth shape: Family C's "invalid never means unbounded", but `0` asks for MORE checking, not less) | **30000 since 11.42.3.** Before, a WebSocket was authorized once, at the handshake, and every event and operation on it ran on that snapshot until the client reconnected. Every connection a `CoreModule` `onConnect` accepts is now registered (`registerWsConnection()`) and re-checked — at once when an invalidation names it (`CoreTenantGuard.invalidateUser()` / `invalidateTenant()` / `invalidateAll()`, `CoreUserService.setRoles()` and role/verification `update()`s, the legacy password reset, and Better-Auth deleting a session through `wsSessionRevocationPlugin()`), and otherwise before the next event or operation once the last check is older than this value. The check re-runs the handshake's own authentication (session store, Better-Auth JWT signature/expiry, or for legacy tokens `CoreAuthService.verifyJwt()` — signature/expiry, verified on the WebSocket since 11.42.3 — plus the device binding) and the tenant resolution; any difference in user id, roles, verification state or tenant scope closes the socket with **4403**, which the graphql-ws client retries — the user continues with current rights or is refused at the handshake. `0` re-checks on every event and operation (one session lookup each); `false` switches only the TIME-based check off, invalidations still apply. Process-local registry: an invalidation reaches other replicas only where a Redis broadcast carries it — the tenant guard's (memberships, tenants) and, since 12.1.1, the Better-Auth user mapper's (`invalidateUserCache()`: roles and verification of IAM users); everything else is bounded by the interval. A project that replaces `onConnect` through `graphQl.driver.subscriptions` must call `registerWsConnection()` itself to keep this. Implementation: `common/helpers/graphql-ws-connection.helper.ts` |
327
327
  | API Tokens | `apiTokens` | Boolean Shorthand + Presence Implies Enabled — absent = off | `prefix: 'ltt'`, `scopes: []` (vocabulary; empty = no token creatable, boot warning), `manageRole`: highest hierarchy role, `maxAssertionLifetimeSeconds: 900` (invalid → default, never unbounded — Family C), `rateLimit: { max: 600, windowSeconds: 60 }` per token (**on by default**, `false` disables; Redis-shared when configured), `userTokens: true`, `tenantTokens: true` (effective only with `multiTenancy`), `encryptionKey` → `SECRETS_ENCRYPTION_KEY` → dev default (boot FAILS in production/staging). Top-level, NOT under `multiTenancy`: user tokens must work without it. **Deny by default**: every guard refuses a token on a route without `@ApiTokenScopes()`, public routes included, via ONE shared function (`enforceApiTokenRoute`) called BEFORE each guard's public-route shortcut. USER tokens act as their user with global roles REMOVED (no admin bypass); TENANT tokens act with the lowest hierarchy role in their own tenant, and a route guarded ONLY by `S_SELF` / `S_CREATOR` refuses them (a tenant token is nobody's self — skipping those roles would grant for any target). A tenant token depends on no member, so it outlives an off-boarded or deleted tenant until the project calls `deleteAllForTenant()`. The boot refuses a hierarchy in which that lowest role reaches `manageRole`. A prefixed credential that fails answers 401 on every route (not anonymous); `CoreBetterAuthMiddleware` skips prefixed credentials entirely, cookie fallback included. A password reset that ends the user's sessions ends their USER tokens too (legacy reset always; IAM reset while `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset` is on), wired through `core-api-token.registry.ts` so neither reset path imports the optional module. Implementation: `src/core/modules/api-token/` |
328
+ | Offline Sync | `sync` | Boolean Shorthand + Presence Implies Enabled — absent = off | **Absent → nothing registered**: no route, no infrastructure model, no listener on the write paths. `true` / `{}` enables `GET /sync/{model}` (pull) and `POST /sync/{model}` (push) for every model whose CrudService carries `@SyncService({ model, createInput, updateInput })` and whose model is declared with `@PersistedModel({ sync: { roles, strategy } })`. Defaults: `path: 'sync'`, `pull: { defaultLimit: 200, maxLimit: 1000 (ceiling 1000), maxBytes: 1048576 }`, `push: { maxItems: 100 }`, `retention: { mutationsDays: 30, tombstonesDays: 90 }`, `hints: { debounceMs: 250 }`, `rateLimit: { pull: { max: 120, windowSeconds: 60 }, push: { max: 60, windowSeconds: 60 } }` per user (`false` switches both or one off; Redis-shared when configured). `cursorSecret` → `SECRETS_ENCRYPTION_KEY` → development default (boot FAILS in production/staging); changing it answers every cursor `410 invalid`. `pull.windowSeconds` (MongoDB only) overrides the derived window W: replica set or sharded — the server's `transactionLifetimeLimitSeconds` (60 when unreadable) + 30, standalone — 30; it must cover the longest time a write takes from its stamp until it is visible, a smaller one loses changes for good. **Operation roles are the model's** (`sync.roles.pull/create/update/delete`): the roles a project puts on its own routes never reach `/sync`, and an operation without roles is ADMIN only (boot warning). Every pull and push runs through the model's CrudService (`process()`, `securityCheck()`, tenant policy, RLS). Without `X-Tenant-Id` a request spans the caller's memberships. Cookie pushes need `Content-Type: application/json`; while cookies are in use `Sec-Fetch-Site: cross-site` and an untrusted `Origin` answer 403. API tokens are refused unless a project controller releases a scope. Error codes `LTNS_0700`–`LTNS_0799`. **Hints (12.x, DEV-3902)**: `GET /sync/hints` (Server-Sent Events, event `change`, `?models=` narrows) and the GraphQL subscription `syncHint(models)` tell a client "pull now" — payload `{ model, at }` only, never content or ids. Sent from `CrudService.create()` / `update()` / `delete()` of a synchronized model (`CoreSyncService.notifyChange()` for a write past the CrudService), at most one per model and tenant per `hints.debounceMs`; received only by a subscriber with the model's pull roles and access to the tenant. With `redis` every replica's subscribers receive them (`<keyPrefix>:sync:hints`), without it only this replica's. A stream is registered like a WebSocket (`registerWsConnection()`) and ENDS on every invalidation or re-check, the client reconnects with its current rights. `hints: false` switches both channels off, `hints.sse` / `hints.graphql: false` one; no hint is a correctness signal — a missed one is caught by the next pull. **Tombstone purge**: tombstones older than `retention.tombstonesDays` (90; `false` keeps them for good, an invalid value → 90) are hard-deleted every hour on one replica (lease: Redis → the default SQL source's `lt_locks` → `cron-locks`), after the retention horizon moved past them — a client whose cursor predates the horizon answers `410 horizon`. `purgeTombstones()` runs it by hand. **Data migrations** of a synchronized table go through `runSqlMigration()` with `{ syncUpdate: { table, set, where } }`, which raises `version` and numbers the rows like a store write. **Client duties** (delete local data on logout and account switch, no token in `localStorage`, no cached API responses): `src/core/modules/sync/SECURITY.md`; reference test clients (PWA on IndexedDB, Node on SQLite) in `tests/clients/`. Overrides: `CoreModule.forRoot(env, { sync: { controller, hintsController, resolver, service } })` — the hints route is a controller of its own, registered before the pull route, because a project controller re-declaring `pull()` (`@Get(':model')`) would otherwise read `hints` as a model name. The purge deletes in batches of 1000 by id (one `DELETE … WHERE deleted_at < ?` locks every row it scans on MySQL / MariaDB). Implementation: `src/core/modules/sync/` (README, INTEGRATION-CHECKLIST), protocol `docs/offline-sync-protocol.md` |
328
329
  | BetterAuth Tenant Skip | `betterAuth.skipTenantCheck` | Explicit Boolean | `true` (default). When `true` and no `X-Tenant-Id` header is sent, IAM endpoints (controller + resolver) skip `CoreTenantGuard` tenant validation. When header IS present, normal membership validation runs regardless. Set `false` for tenant-aware auth scenarios (subdomain-based, invite links, SSO per tenant) |
329
330
  | Debug Process Input | `debugProcessInput` | Explicit Boolean | `false` (default). When `true`, logs a debug message when `prepareInput()` changes the input type during `process()`. Has performance cost due to `JSON.stringify` on every `process()` call — enable only for debugging |
330
331
  | JSONTransport Production Guard | `email.smtp` with `jsonTransport` | Runtime Guard | Throws `Error` when `email.smtp` has a truthy `jsonTransport` property in `production` or `staging` environments (read from config `env` field). JSONTransport silently discards all outgoing mail — the guard prevents accidental misconfiguration that causes password-reset, 2FA, and verification emails to vanish. Use `{ jsonTransport: true }` only in CI/e2e/local environments |
@@ -401,6 +401,13 @@ any has moved:
401
401
  second version of the same consumer is a path nobody assessed, and comparing by name alone let
402
402
  one ride along on the other's declaration, with the winner decided by lockfile order.
403
403
 
404
+ **Several consumers (12.1.1+)** are joined with ` + `, each at its assessed version —
405
+ `'@nestjs/cli@12.0.11 + nodemon@3.1.14 cannot use 11.0.1'`. One vulnerable package often sits under
406
+ several dev tools at once, and before this a declaration could name only one, so such a finding had no
407
+ honest way in. Every listed consumer gets checks 1 and 3, and one more: a listed consumer that no
408
+ longer pulls an affected package fails too (mutation `unusable-fix-accepts-idle-consumer`), because a
409
+ list is where a path that is gone would otherwise keep covering.
410
+
404
411
  The third check reads **both halves** of the lockfile. `importers:` holds what the repository itself
405
412
  declares (`.`, `projects/api`, `projects/app`), `snapshots:` the edges between third-party packages
406
413
  — and the first is the likelier production path, because a direct dependency can simply be raised.
@@ -430,7 +437,15 @@ plus one for the opposite failure, a guard that rejects a declaration which is s
430
437
  matters just as much: a check that cries wolf on correct entries is a check that gets switched off,
431
438
  which is how the hole this feature closes came about in the first place.
432
439
 
433
- **No current entry (since 2026-10-24).** `GHSA-vfj7-8cjw-p6xm` (braces, via nodemon / npm-watch) was
440
+ **Current entries (since 2026-10-31, owner decision, DEV-3899).** `GHSA-g3hv-75wg-m249` (minimatch
441
+ `< 11.0.1`) and `GHSA-3g3m-2fjr-6pw4` (glob `<= 14.0.1`), both moderate, both fixed only in a new major
442
+ released 2026-10-27 that none of their consumers can take yet — five for minimatch (`@nestjs/cli` pins
443
+ 10.2.6 exactly), rimraf for glob. The justification is in the comment above `ignoreGhsas`, the
444
+ declarations in `unusableFixConsumers`. `tests/unit/audit-suppression-scope.spec.ts` keeps glob out of
445
+ the production tree and allows minimatch there only under `@ts-morph/common` (ts-morph, the Permissions
446
+ module's scanner, matching globs the developer writes) — its `prodVia` form.
447
+
448
+ **Before that, no entry from 2026-10-24.** `GHSA-vfj7-8cjw-p6xm` (braces, via nodemon / npm-watch) was
434
449
  listed from 2026-10-11 to 2026-10-24: a dev-tooling-only path of an advisory with no patched release
435
450
  at all. GitHub withdrew the advisory on 2026-10-23, and `check:overrides` reported the entry as
436
451
  obsolete on the next run — the guard doing exactly its job, since a withdrawal changes nothing in
@@ -91,6 +91,32 @@ On every SQL database a path segment `__proto__`, `constructor` or `prototype` i
91
91
  object from path segments would otherwise reach `Object.prototype` — on MySQL that switched tenant isolation off for the
92
92
  whole process. Never build an update path from request data without validating it against the model.
93
93
 
94
+ ## Synchronized models (offline sync, 12.x)
95
+
96
+ `@PersistedModel({ sync: true | { idFormat, scope } })` makes a model synchronizable (`common/persistence/sync-model.ts`,
97
+ plan `.claude/plans/offline-sync/PLAN.md` §3.3). Only a model registered with `CorePersistenceModule.forFeature()`; a
98
+ model without `sync` keeps every behaviour bit for bit.
99
+
100
+ | Rule | Why |
101
+ |---|---|
102
+ | `version`, `deletedAt` and the change position (`syncSeq` on SQL, `syncAt` on MongoDB) are written by the store only — never put them in an input or update | they are stripped anyway; a version the caller could set would defeat the conditional change, a `deletedAt` would delete with update rights |
103
+ | Every framework delete of such a model leaves a tombstone (`CrudService.delete()`, `deleteById/One/Many`); `hardDelete` with `includeTombstones` is the purge's | a hard delete through a normal route reaches no client — the record stays on every device |
104
+ | Reads leave tombstones out; only the sync module passes `includeTombstones` (`ServiceOptions` or the port's options) | counts, distinct values and populated references must not show deleted records |
105
+ | A conditional change is `ServiceOptions.expectedVersion` on `CrudService.update()` / `delete()` — one statement with the version as its condition, `VersionConflictError` (409) on another version | read-then-write lets two clients both apply their change |
106
+ | A unique index applies to live records only, also for a missing value (MongoDB partial index, PostgreSQL `NULLS NOT DISTINCT … WHERE deleted_at IS NULL`, MySQL / MariaDB virtual `lt_lk_*` columns); a unique index inside an embedded object is refused | delete-then-recreate with the same value must work; tombstones keep no content, so their missing values must not collide |
107
+ | Making an EXISTING model synchronized is a migration on every database. SQL: the boot refuses with the statements (the `deleted_at` column, the live-only index, `DROP` of the full index it replaces); the other columns, the trigger and the numbering of existing rows follow automatically. MongoDB: the boot refuses with `SyncMigrationRequiredError` — drop the old unique indexes and give every document `version`, `deletedAt` and `syncAt` (the error prints both statements); Mongoose then builds the live-only indexes | a full unique index left in place refuses the second tombstone; a document without `syncAt` never reaches a client |
108
+ | No TTL on a synchronized model (refused at registration); no `replaceOne` / pipeline update on MongoDB (refused) | an expired record vanishes without a tombstone; a replacement carries no version increment |
109
+ | On SQL the database numbers changes: a trigger per table (PostgreSQL: advisory lock + sequence; MySQL / MariaDB: the counter row `lt_sync_counters`); a write that sets `sync_seq` itself is not renumbered. On MySQL / MariaDB every store write takes its number LAST instead (marks the row `-1`, then numbers it before COMMIT) | tier-2/3 writes are numbered too; on MySQL / MariaDB `FOR UPDATE` locks index gaps, and a write that held the counter while it waited for one deadlocked with a multi-row write (measured) — a multi-row write outside the store must lock its rows first and set the numbers last, as `updateRows()` does |
110
+ | On MySQL with binary logging, creating the triggers needs `SUPER` or `log_bin_trust_function_creators=1` for the owner account | MySQL refuses `CREATE TRIGGER` otherwise (error 1419); the application account never needs it |
111
+ | MongoDB stamps updates with `$currentDate` and inserts with the server clock read through `hello.localTime` — never `Date.now()` | application clocks differ between replicas; the pull's window relies on one clock |
112
+ | A model reaches `/sync` only with `@SyncService({ model, createInput, updateInput })` on its CrudService AND `sync.roles` for every operation (`pull`, `create`, `update`, `delete`); without roles an operation is ADMIN only | the roles of a project's own routes never reach the sync routes — a push without its own roles lets any reader overwrite |
113
+ | The sync module reads and writes through the model's CrudService only (`find`, `get`, `create`, `update`, `delete` with `expectedVersion`), never `native()` / `raw()` or the driver name; what differs it asks `store.capabilities` and `store.changeClock()` | the security pipeline (`process()`, `securityCheck()`, tenant policy, RLS) is the point; DEV-3819 forbids a lean path |
114
+ | A write that moves a synchronized record to another tenant or away from a scope user OUTSIDE `CrudService.update()` (store, native API, migration) calls `CoreSyncService.bumpAccessEpoch()` | only `CrudService.update()` reports such a move; otherwise the clients that lost the record keep their copy until a full resync |
115
+ | A write of a synchronized model OUTSIDE the CrudService (store, native API, migration) calls `CoreSyncService.notifyChange(model, tenantIds)` | only `CrudService.create()` / `update()` / `delete()` send a hint; without it online clients learn of the change at their next scheduled pull instead of at once — nothing is lost, it is only later |
116
+ | A data migration that changes rows of a synchronized table uses `runSqlMigration()` with `{ syncUpdate: { table, set, where } }`, never a bare `UPDATE` | the helper raises `version` and numbers the rows like a store write; a bare `UPDATE` keeps the old version, so a client's conditional push overwrites the migrated value without a conflict |
117
+ | Tombstones are removed only by the purge (`retention.tombstonesDays`), which moves the retention horizon first | a tombstone removed before the horizon passes it is never seen by a client whose cursor is older — the record stays on that device |
118
+ | A client id on create goes through `ServiceOptions.createId`, never inside the input | an input class keeps only the fields it declares — mapping it drops an `id` |
119
+
94
120
  ## Tests
95
121
 
96
122
  A suite proves a behaviour on both databases: the `e2e` vitest project runs it on MongoDB, the `postgres` project on
@@ -32,10 +32,10 @@ that every `*.spec.ts` / `*.test.ts` in the repo is claimed by **exactly one** r
32
32
  mis-named suite fails the build instead of silently passing. Type-only tests
33
33
  (`tests/types/*.type-test.ts`) are compiled by `pnpm run test:types`, never executed.
34
34
 
35
- ## Infrastructure containers (Redis + S3 + PostgreSQL + MySQL + MariaDB)
35
+ ## Infrastructure containers (Redis + S3 + PostgreSQL + MySQL + MariaDB + MongoDB replica set)
36
36
 
37
- **Twenty** e2e suites talk to a **real** Redis, a **real** S3-compatible store and/or a **real**
38
- PostgreSQL, MySQL or MariaDB rather than a mock, and they **fail loudly** when it is missing — a silently skipped
37
+ **Twenty-two** e2e suites talk to a **real** Redis, a **real** S3-compatible store and/or a **real**
38
+ PostgreSQL, MySQL, MariaDB or MongoDB replica set rather than a mock, and they **fail loudly** when it is missing — a silently skipped
39
39
  infrastructure test is how an untested driver ships:
40
40
 
41
41
  | Suite | Needs |
@@ -60,13 +60,15 @@ infrastructure test is how an untested driver ships:
60
60
  | `tests/persistence-mysql-executor.e2e-spec.ts` | MySQL + MariaDB |
61
61
  | `tests/persistence-mysql-schema.e2e-spec.ts` | MySQL + MariaDB |
62
62
  | `tests/persistence-mysql-wiring.e2e-spec.ts` | MySQL + MariaDB |
63
+ | `tests/sync-contract.e2e-spec.ts` | PostgreSQL + MySQL + MariaDB + MongoDB replica set (and MongoDB) |
64
+ | `tests/sync-clock-skew.e2e-spec.ts` | PostgreSQL + MySQL + MongoDB replica set (and MongoDB) |
63
65
 
64
- Beyond these twenty, the `postgres` vitest project runs the whole e2e suite once more on PostgreSQL (see "Behaviour
66
+ Beyond these twenty-two, the `postgres` vitest project runs the whole e2e suite once more on PostgreSQL (see "Behaviour
65
67
  matrices" below), so without the PostgreSQL container that project fails as a whole while `e2e` runs.
66
68
 
67
- `tests/global-setup.ts` starts all five containers automatically, so `pnpm test` works from a clean
69
+ `tests/global-setup.ts` starts all six containers automatically, so `pnpm test` works from a clean
68
70
  machine with no manual docker command. It is idempotent (a running container is reused) and never
69
- fatal: without Docker every other suite still runs, and the twenty above report their own actionable
71
+ fatal: without Docker every other suite still runs, and the twenty-two above report their own actionable
70
72
  error.
71
73
 
72
74
  ```bash
@@ -75,10 +77,10 @@ pnpm run test:infra:status # what is running
75
77
  pnpm run test:infra:down # stop and remove
76
78
  ```
77
79
 
78
- | | Redis | S3 (RustFS) | PostgreSQL | MySQL 8.4 | MariaDB 11.8 |
79
- |---|---|---|---|---|---|
80
- | Port | 6380 (6379 is an auth-protected TurboOps Redis on lt dev machines) | 9102 | 5440 | 3316 | 3317 |
81
- | Container | `nest-server-2985-redis` | `nest-server-2985-rustfs` | `nest-server-2985-postgres` | `nest-server-2985-mysql` | `nest-server-2985-mariadb` |
80
+ | | Redis | S3 (RustFS) | PostgreSQL | MySQL 8.4 | MariaDB 11.8 | MongoDB 8.0 replica set |
81
+ |---|---|---|---|---|---|---|
82
+ | Port | 6380 (6379 is an auth-protected TurboOps Redis on lt dev machines) | 9102 | 5440 | 3316 | 3317 | 27019 |
83
+ | Container | `nest-server-2985-redis` | `nest-server-2985-rustfs` | `nest-server-2985-postgres` | `nest-server-2985-mysql` | `nest-server-2985-mariadb` | `nest-server-2985-mongo-rs` |
82
84
 
83
85
  **PostgreSQL runs with two roles, as every environment does** (`.claude/plans/db-agnostic/PLAN.md`
84
86
  §3.9): `nest_owner` owns the databases and their schema, `nest_app` owns nothing and has neither
@@ -98,6 +100,12 @@ Overridable via `MYSQL_HOST`, `MYSQL_PORT`, `MARIADB_HOST`, `MARIADB_PORT`, `MYS
98
100
  whole e2e suite: the matrices carry their columns (persistence: one per engine; core modules and where
99
101
  parity: one per server).
100
102
 
103
+ **The MongoDB replica set** (one node, `rs0`) exists for the sync contract matrix (DEV-3901): on a replica set a
104
+ project's transaction can commit a synchronized write long after its stamp — what the pull window must cover — and the
105
+ standalone MongoDB every other suite uses shows nothing of that. The readiness step initiates the set idempotently;
106
+ the suite connects with `directConnection=true` and drops its database itself in `afterAll` (the database lifecycle
107
+ collects only the standalone). Overridable via `MONGO_RS_HOST`, `MONGO_RS_PORT`.
108
+
101
109
  **CI provisions its own** containers in `.github/workflows/*.yml` and sets `LT_TEST_INFRA=0` so the
102
110
  automatic start stays out of the way. Same images, same ports — only who starts them differs. Set
103
111
  `LT_TEST_INFRA=0` locally too if you want to manage them yourself.
@@ -311,6 +319,36 @@ reason, never fall back to MongoDB, while the `e2e` project runs. `--project e2e
311
319
 
312
320
  The **data store** contract follows the same discipline: `tests/helpers/persistence-matrix.ts` declares the cases (CRUD contract plus the security policies), `tests/persistence-parity.e2e-spec.ts` executes them against a store obtained via `CorePersistenceModule.forFeature()` + `@InjectStore()` with every security plugin registered, and `tests/unit/persistence-parity-matrix.spec.ts` guards that every (case, database) cell is declared. It has ten columns: `mongodb`, PostgreSQL once per engine (`postgres-typeorm`, `postgres-kysely`, `postgres-drizzle`) — a `SqlDataStore` over the schema the migrator created, connected as the application role, so row-level security binds every case — and MySQL and MariaDB once per engine (`mysql-*`, `mariadb-*`, phase 6), a `MysqlDataStore` connected as the DML-only application account. Without row-level security there, `native-api-tenant-refused` is DIFFERENT-BY-DESIGN for those six columns and its complement asserts that a native query reaches every tenant while the store still isolates. Each tenant layer of the SQL store is additionally proven ALONE in `tests/persistence-sql-store.e2e-spec.ts` — the matrix runs them together, where one layer hides the loss of another.
313
321
 
322
+ The **offline-sync protocol** follows it one level above the store: `tests/helpers/sync-contract-matrix.ts` declares
323
+ what `GET/POST /sync/{model}` promise — pull, resume, resync, push with every strategy, idempotency, two tabs, tenant
324
+ isolation, the infrastructure stores, the change hints over SSE and GraphQL (tenant and role filter, the stream ending
325
+ on a re-check), the tombstone purge behind the horizon and the `syncUpdate` migration helper — and
326
+ `tests/sync-contract.e2e-spec.ts` runs it through HTTP on seven columns: MongoDB standalone and replica set, PostgreSQL
327
+ × three engines, MySQL, MariaDB. The window repeats of MongoDB are DIFFERENT-BY-DESIGN against SQL's "never repeated",
328
+ each side with its own case; the migration helper is IMPOSSIBLE on the two MongoDB columns (it is a SQL statement). Its
329
+ structural guard is `tests/unit/sync-contract-matrix.spec.ts`; the reference model it runs on is
330
+ `src/server/modules/sync-note/`. Hints across replicas need a second application on one Redis and live in
331
+ `tests/multi-replica.e2e-spec.ts`.
332
+
333
+ **Offline sync end to end (DEV-3903).** Three suites prove the protocol from the client's side, all on the reference
334
+ server (`tests/helpers/sync-test-app.ts`, which the contract matrix boots too):
335
+
336
+ | Suite | What | Needs |
337
+ |-------|------|-------|
338
+ | `tests/sync-pwa-client.e2e-spec.ts` | The PWA test client (`tests/clients/pwa/`, IndexedDB, service worker) in Chrome, served by the test server, offline via Playwright | an installed Chrome (`playwright-core` downloads none; GitHub's Ubuntu runners ship one; `SYNC_PWA_CHROME` names another executable) |
339
+ | `tests/sync-node-client.e2e-spec.ts` | The same client on SQLite (`node:sqlite`), bearer session token | nothing extra |
340
+ | `tests/sync-clock-skew.e2e-spec.ts` | Two server PROCESSES with clocks 5 s ahead and behind, parallel writers on both, readers on either instance and one switching instances per page: no change lost. MongoDB runs with `pull.windowSeconds: 2` — the derived window (30 s / 90 s) would cover any skew — and needs the reader that STAYS with the fast clock: a switching reader soon starts a run on the slow one and picks a misstamped change up again, so with it alone the suite stayed green against mutation `sync-mongo-stamp-app-clock` | the containers above |
341
+
342
+ The two client suites share one protocol implementation (`tests/clients/sync-client.mjs`) and one scenario list
343
+ (`tests/helpers/sync-client-scenarios.ts`); `tests/unit/sync-client-scenarios.spec.ts` fails when a suite misses a
344
+ scenario, and `tests/unit/sync-client.spec.ts` checks the client against an in-memory server, so a client bug does not
345
+ cost an e2e run. Both run on MongoDB (`e2e`) and PostgreSQL (`postgres`) like every suite. The clock-skew suite starts
346
+ its servers through the framework's SWC loader (`tests/helpers/sync-server.process.ts`, relative imports with `.js`)
347
+ with `tests/helpers/skewed-clock.mjs` preloaded, because two Nest applications in one process share the persistence
348
+ registry; it brings its own databases and is excluded from the `postgres` project. The skewed `Date` keeps the name
349
+ `Date` and `prototype.constructor`: Mongoose resolves a schema type by the constructor's name, and `@UnifiedField`
350
+ tells a class from a type factory by `prototype.constructor` — without both the server does not boot.
351
+
314
352
  ### A cell is EXECUTED, IMPOSSIBLE, or DIFFERENT-BY-DESIGN — never merely absent
315
353
 
316
354
  Omitting a case for one implementation and declaring it impossible there produce the same artefact
@@ -372,8 +410,9 @@ pnpm run check:mutations -- --id=<id> # one mutation
372
410
  pnpm run check:mutations -- --list # the registry, without running anything
373
411
  pnpm run check:mutations -- --allow-dirty # when the fix and its evidence share a working tree
374
412
  pnpm run check:mutations -- --jobs=4 # N mutations at a time (default: 2, or 4 on >=12 cores)
375
- pnpm run check:mutations -- --no-infra # only the 248 that need no MongoDB
413
+ pnpm run check:mutations -- --no-infra # only the 260 that need no MongoDB
376
414
  pnpm run check:mutations -- --since=<ref> # only mutations touching files changed since <ref>
415
+ pnpm run check:mutations -- --shard=2/4 # this job's quarter of the selection (CI splits the full run)
377
416
  ```
378
417
 
379
418
  `--no-infra` and `--since` narrow the run for LOCAL work and are deliberately not wired into the
@@ -391,7 +430,7 @@ when the registry, a vitest config or a setup file changed, since those can move
391
430
  commit, and in CI alongside the publish without blocking it. The price of caching is a cache that
392
431
  has to model each spec's full dependency closure correctly, and getting that wrong produces a stale
393
432
  PASS for a test that has since gone vacuous — exactly what the gate is there to prevent. Bad trade
394
- at 442 mutations.
433
+ at 476 mutations.
395
434
 
396
435
  **Decided 2026-10-03 (repo owner): keep the full run.** Measured at 128 mutations, the CI job for
397
436
  11.41.6 took **14 minutes**. The trigger to revisit is that wall time, not the mutation count —
@@ -399,6 +438,32 @@ the count was never the cost: re-evaluate once the "Regression evidence" job in
399
438
  exceeds **30 minutes** (`gh run view <id> --json jobs` shows start and end). Below that line,
400
439
  adding a mutation needs no decision.
401
440
 
441
+ **Re-evaluated 2026-10-30 (DEV-3910): the run stays full, it is SPLIT.** The line was crossed by far —
442
+ 58.5 min for 407 mutations at the 12.1.0 publish, 53 min for 442 at 12.1.1, almost all of it the cold
443
+ start below. Both workflows now run the gate as a matrix of four jobs, each `--shard=i/4` of the same
444
+ selection: every mutation in exactly one shard, unit and e2e mutations dealt out separately so no shard
445
+ collects the slow ones, nothing cached or skipped. The new trigger: **the slowest shard exceeds 30
446
+ minutes → raise the shard count** (matrix and `--shard` together, one line per workflow). What the split
447
+ must keep is pinned by `tests/unit/mutation-sharding.spec.ts`:
448
+
449
+ | Property | Why it is load-bearing |
450
+ |----------|------------------------|
451
+ | Every mutation in exactly one shard (mutation `mutation-shard-drops-residue`) | a lost mutation is invisible — every shard is green, each honestly reporting what it ran |
452
+ | The matrix lists 1..N for the N `--shard` names (mutations `evidence-matrix-misses-a-shard`, and `evidence-plan-lists-fewer-shards` for the on-demand plan job) | a shorter matrix never starts the missing shards |
453
+ | `fail-fast: false` in both workflows | GitHub cancels the rest of a matrix on the first failure, and with it their verdicts |
454
+ | One issue per red run, from the `regression-evidence-report` job after the matrix | two red shards each looking for an open issue at once both create one |
455
+
456
+ A shard that holds none of a non-empty selection (`--id=<x>` with four shards) ends 0 and says so; an
457
+ empty SELECTION still fails before sharding (2 for `--since` / `--no-infra`, 1 for an unmatched
458
+ `--id`). `regression-evidence.yml` takes a `shards` input (default 4) so a single `--id` run can use
459
+ one job. `/lt-dev:publish` reads the workflow run's conclusion, which any red shard turns red.
460
+
461
+ **More shards stop helping at the slowest single mutation.** A mutation that makes tests HANG costs
462
+ retries × timeout per red test — 3 attempts × 60 s in low-resource mode. At 12.1.1 four such mutations
463
+ took 44 % of all lane time, the largest (`gridfs-upload-source-error-unhandled`) ~18 minutes on its
464
+ own, so no shard count brings a shard below that. When the slowest shard is one such mutation, shorten
465
+ the spec's failure path (a tighter per-test timeout, a fail-fast assertion), not the split.
466
+
402
467
  Not part of `pnpm run check` — it edits source and re-runs whole e2e suites. It belongs in review
403
468
  and on the publish path. It is also reachable on demand, without cutting a release:
404
469
 
@@ -436,9 +501,9 @@ exactly the environment where the answer matters.
436
501
 
437
502
  ### The cost is vitest's cold start, not the tests
438
503
 
439
- Worth knowing before optimising the wrong thing: the specs behind all 194 e2e mutations add up to
504
+ Worth knowing before optimising the wrong thing: the specs behind all 216 e2e mutations add up to
440
505
  **~40 seconds**. The step takes ~740s. The remaining ~700s is paying vitest's startup — process
441
- spawn, transform, module graph, mongod connect, DB create and drop — once per mutation, 442 times.
506
+ spawn, transform, module graph, mongod connect, DB create and drop — once per mutation, 476 times.
442
507
  That work is largely single-threaded I/O and barely scales with cores: the full registry measures
443
508
  **744s on a 12-core laptop and 777s on a 4-vCPU CI runner**.
444
509
 
package/CLAUDE.md CHANGED
@@ -200,10 +200,11 @@ pnpm run reinit # Clean reinstall + tests + build
200
200
  **Key Components:**
201
201
  - `CoreModule` - Dynamic module with GraphQL (optional, disable via `graphQl: false`), MongoDB, security
202
202
  - `src/core/common/` - Decorators, helpers, interceptors, services
203
- - `src/core/modules/` - ApiToken, Auth, BetterAuth, ErrorCode, File, HealthCheck, Hub, Migrate, Permissions, SystemSetup, Tus, User
203
+ - `src/core/modules/` - ApiToken, Auth, BetterAuth, ErrorCode, File, HealthCheck, Hub, Migrate, Permissions, Sync, SystemSetup, Tus, User
204
204
  - **ApiToken** - Opt-in (`apiTokens`) USER and TENANT tokens + signed assertions. Denied on every route without `@ApiTokenScopes()`; all three guards share `enforceApiTokenRoute()`. See `src/core/modules/api-token/README.md`.
205
205
  - **Hub** - Build-free ADMIN-gated operator cockpit at `/hub` (config-gated per environment; 17 panels, runtime collectors, mailbox, admin actions). See `src/core/modules/hub/README.md`.
206
206
  - **Permissions** - ADMIN-gated security-map report (routes + roles + `@Restricted` fields); also surfaced in the Hub's "Routes / Permissions" panel.
207
+ - **Sync** - Opt-in (`sync`) offline sync: `GET/POST /sync/{model}` for models declared `@PersistedModel({ sync })` whose CrudService carries `@SyncService()`, change hints over SSE (`GET /sync/hints`) and GraphQL (`syncHint`), hourly tombstone purge; protocol in `docs/offline-sync-protocol.md`, client duties in the module's `SECURITY.md`, reference test clients in `tests/clients/`. See `src/core/modules/sync/README.md`.
207
208
  - **Optional central infrastructure** (11.33.0+) - `CoreRedisService` (`redis` config) and `CoreS3Service` (`s3` config) turn process-local state into shared state for multi-replica deployments: rate-limit counters, cron deduplication, GraphQL subscriptions, tenant-cache and IAM role-cache invalidation, Hub collectors, file storage (`file.storage: 's3'`; the driver is otherwise derived — S3 → GridFS → filesystem — and an unavailable one fails the boot rather than falling back) and TUS staging. Both are inert without their config, and their client libraries are **optional peer dependencies** (`ioredis`, `bullmq`, `@aws-sdk/client-s3`, `@aws-sdk/lib-storage`, `@aws-sdk/s3-request-presigner`, `@tus/s3-store`) — a project that uses neither installs nothing extra. When adding a distributed feature, always keep the non-configured fallback path.
208
209
 
209
210
  See `.claude/rules/architecture.md` for detailed documentation.
package/FRAMEWORK-API.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @lenne.tech/nest-server — Framework API Reference
2
2
 
3
- > Auto-generated from source code as of 2026-10-29 (v12.1.1)
3
+ > Auto-generated from source code as of 2026-11-01 (v12.2.0)
4
4
  > File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code
5
5
 
6
6
  ## CoreModule.forRoot()
@@ -52,6 +52,7 @@
52
52
  - `sha256?`: `boolean` — Whether to enable verification and automatic encryption for received passwords that are not in sha256 format
53
53
  - `shutdownDelayMs?`: `number` (default: `0 (no delay)`) — Delay in milliseconds between receiving a shutdown signal and starting the
54
54
  - `staticAssets?`: `{ options?: ServeStaticOptions; path?: string; }` — Configuration for useStaticAssets
55
+ - `sync?`: `boolean | ISync` — Offline sync (`docs/offline-sync-protocol.md`): `GET/POST /sync/{model}` for the models whose service carries
55
56
  - `systemSetup?`: `ISystemSetup` — System setup configuration for initial admin creation.
56
57
  - `templates?`: `{ engine?: string; path?: string; }` — Templates
57
58
  - `trustProxy?`: `string | number | boolean | string[]` (default: `false (Express default — the forwarded chain is not trusted)`) — Express `trust proxy` setting — how far up the `X-Forwarded-For` chain this app believes.
@@ -215,6 +216,7 @@ When `passkey` is enabled, `trustedOrigins` is required (compile-time enforcemen
215
216
  ### ICoreModuleOverrides
216
217
 
217
218
  - `apiToken?`: `{ model?: Type<any>; service?: Type<any>; }` — Override API token collaborators with project-specific subclasses (`apiTokens` config).
219
+ - `sync?`: `{ controller?: Type<any>; hintsController?: Type<any>; resolver?: Type<any>; ...` — Override the offline sync endpoints, the hint subscription and their service (`sync` config).
218
220
  - `ai?`: `{ budgetService?: Type<any>; connectionResolver?: Type<any>; connectionServic...` — Override AI module collaborators with project-specific subclasses.
219
221
  - `betterAuth?`: `{ controller?: Type<any>; resolver?: Type<any>; }` — Override BetterAuth controller and/or resolver.
220
222
  - `errorCode?`: `{ controller?: Type<any>; service?: Type<any>; }` — Override ErrorCode controller and/or service.
@@ -284,9 +286,12 @@ When `passkey` is enabled, `trustedOrigins` is required (compile-time enforcemen
284
286
  - `checkRights?`: `boolean`
285
287
  - `collation?`: `CollationOptions`
286
288
  - `create?`: `boolean`
289
+ - `createId?`: `string`
287
290
  - `currentUser?`: `{ [key: string]: any; id: string; roles?: string[]; }`
288
291
  - `fieldSelection?`: `FieldSelection`
292
+ - `expectedVersion?`: `number`
289
293
  - `force?`: `boolean`
294
+ - `includeTombstones?`: `boolean`
290
295
  - `inputType?`: `new (...params: any[]) => any`
291
296
  - `outputType?`: `new (...params: any[]) => any`
292
297
  - `populate?`: `string | PopulateOptions | (string | PopulateOptions)[]`
@@ -311,7 +316,7 @@ Generic: `CrudService<Model, CreateInput, UpdateInput>`
311
316
  - `async create(input: PlainObject<CreateInput>, serviceOptions?: ServiceOptions)`: `Promise<Model>` — Create item
312
317
  - `async createForce(input: PlainObject<CreateInput>, serviceOptions?: ServiceOptions)`: `Promise<Model>` — Create item without checks or restrictions
313
318
  - `async createRaw(input: PlainObject<CreateInput>, serviceOptions?: ServiceOptions)`: `Promise<Model>` — Create item without checks, restrictions or preparations
314
- - `distinct(property: string)`: `Promise<string[]>` — Get distinct values of a property
319
+ - `distinct(property: string, serviceOptions?: ServiceOptions)`: `Promise<string[]>` — Get distinct values of a property
315
320
  - `async get(id: string, serviceOptions?: ServiceOptions)`: `Promise<Model>` — Get item by ID
316
321
  - `async getForce(id: string, serviceOptions?: ServiceOptions)`: `Promise<Model>` — Get item by ID without checks or restrictions
317
322
  - `async getRaw(id: string, serviceOptions?: ServiceOptions)`: `Promise<Model>` — Get item by ID without checks, restrictions or preparations
@@ -338,6 +343,15 @@ Generic: `CrudService<Model, CreateInput, UpdateInput>`
338
343
  - `async updateForce(id: string, input: PlainObject<UpdateInput>, serviceOptions?: ServiceOptions)`: `Promise<Model>` — Update item via ID without checks or restrictions
339
344
  - `async updateRaw(id: string, input: PlainObject<UpdateInput>, serviceOptions?: ServiceOptions)`: `Promise<Model>` — Update item via ID without checks, restrictions or preparations
340
345
  - `async delete(id: string, serviceOptions?: ServiceOptions)`: `Promise<Model>` — Delete item via ID
346
+ - `withTombstones(query: Q, serviceOptions?: ServiceOptions)`: `Q` — A Mongoose query or aggregation that also sees tombstones when the caller asked (`includeTombstones`)
347
+ - `expectedVersionOf(serviceOptions?: ServiceOptions)`: `number` — `serviceOptions.expectedVersion`, checked; only a synchronized model has a version
348
+ - `assertVersion(id: string, stored: any, expectedVersion: number)`: `void` — The early answer to a conditional change on a record that already has another version
349
+ - `versionConflict(id: string)`: `never`
350
+ - `syncAccessSnapshot(stored: Record<string, any>)`: `Record<string, any>` — The tenant and scope fields of a stored record as they are BEFORE a write — taken before `mergePlain()`, which
351
+ - `async reportSyncAccessMove(stored: Record<string, any>, written: Record<string, any>)`: `Promise<void>` — Tell the sync module that this write took a synchronized record away from users who could see it: moved to
352
+ - `reportSyncChange(records: Record<string, any>[])`: `void` — Tell the sync module that a write of a synchronized record succeeded, so the subscribers of its hints learn of it
353
+ - `syncChanged(created: R)`: `R` — `reportSyncChange()` for a created record, which it answers
354
+ - `async removeOrTombstone(id: string, expectedVersion: number)`: `Promise<void>` — Delete through the store: a synchronized model's record becomes a tombstone (conditional on `expectedVersion`),
341
355
  - `async deleteForce(id: string, serviceOptions?: ServiceOptions)`: `Promise<Model>` — Delete item via ID without checks or restrictions
342
356
  - `async deleteRaw(id: string, serviceOptions?: ServiceOptions)`: `Promise<Model>` — Delete item via ID without checks, restrictions or preparations
343
357
  - `async pushToArray(id: string | Types.ObjectId | { id?: any; _id?: any; }, field: string, items: any, options?: { $slice?: number; $position?: number; $sort?: Record<string, 1 | -1>; })`: `Promise<void>` — Append items to an array field without loading the full array.
@@ -364,6 +378,7 @@ Generic: `CrudService<Model, CreateInput, UpdateInput>`
364
378
  | `hub` | README, CHECKLIST | `src/core/modules/hub/` |
365
379
  | `migrate` | README | `src/core/modules/migrate/` |
366
380
  | `permissions` | README, CHECKLIST | `src/core/modules/permissions/` |
381
+ | `sync` | README, CHECKLIST | `src/core/modules/sync/` |
367
382
  | `system-setup` | README, CHECKLIST | `src/core/modules/system-setup/` |
368
383
  | `tenant` | README, CHECKLIST | `src/core/modules/tenant/` |
369
384
  | `tus` | README, CHECKLIST | `src/core/modules/tus/` |
package/README.md CHANGED
@@ -17,6 +17,7 @@ An enterprise-grade extension layer on top of [NestJS](https://nestjs.com/) for
17
17
  - [Multi-Tenancy](#multi-tenancy)
18
18
  - [Databases](#databases)
19
19
  - [API Tokens](#api-tokens)
20
+ - [Offline Sync](#offline-sync)
20
21
  - [AI Assistant & MCP](#ai-assistant--mcp)
21
22
  - [Operator Hub](#operator-hub)
22
23
  - [Scalability](#scalability)
@@ -63,6 +64,7 @@ This generates a complete module with model, inputs, resolver, controller, and s
63
64
  | **Authorization** | Extensible role-based access control, field-level restrictions (also on nested types), system roles, hierarchy roles; every entry point checked, not just the route |
64
65
  | **Database** | MongoDB (Mongoose), PostgreSQL, MySQL or MariaDB (TypeORM, Kysely or Drizzle) — per project or per model — behind one CrudService and security pipeline; tenant isolation enforced by row-level security on PostgreSQL; schema evolution and SQL migrations by the framework; advanced filtering & pagination |
65
66
  | **Multi-Tenancy** | Header-based tenant isolation, membership management, hierarchy roles, auto-filtering, a tenant row check after every read, deactivatable tenants |
67
+ | **Offline Sync** | Mobile, web and desktop clients read and write offline and reconcile later: delta pull by cursor, push with conflict strategies, tombstones, client-generated ids, change hints over SSE and GraphQL — on every supported database, through the same security pipeline |
66
68
  | **File Handling** | GridFS, S3 or filesystem storage, presigned downloads, resumable uploads via TUS protocol (up to 50 GB, S3 staging) |
67
69
  | **AI** | AI assistant with tool registry, plan mode, confirmation policy, token budgets, conversations and SSE streaming; MCP server (`/ai/mcp`) with OAuth 2.1 |
68
70
  | **Security** | Defense-in-depth: input validation, password hashing, role guard, audit fields, response filtering, security headers, SSRF guard for user-entered URLs, re-checked WebSocket subscriptions |
@@ -361,6 +363,21 @@ Opt-in bearer tokens for integrations and scripts (`apiTokens` configuration):
361
363
 
362
364
  Details: [src/core/modules/api-token/README.md](src/core/modules/api-token/README.md).
363
365
 
366
+ ## Offline Sync
367
+
368
+ Opt-in (`sync` configuration) for apps that must work without a connection — React Native or Capacitor with SQLite,
369
+ PWAs with IndexedDB, desktop and Node clients:
370
+
371
+ - **Protocol** — HTTP with JSON, fully documented in [docs/offline-sync-protocol.md](docs/offline-sync-protocol.md); a client needs `fetch` and nothing else
372
+ - **Pull** by an opaque cursor that loses no change across instances, clocks and late commits; pages bounded by count and size; `410` with a clear reason when a resync is needed
373
+ - **Push** item by item with idempotent mutation ids, client-generated ids, conditional writes on `version`, and the strategies `reject`, `lww`, `merge` and `custom`
374
+ - **Tombstones** for deletions, hourly purge behind a retention horizon; hints over SSE (`GET /sync/hints`) and the GraphQL subscription `syncHint`
375
+ - **Security** — every pull and push runs through the model's `CrudService` (`@Restricted`, `securityCheck()`, tenant policy, row-level security); lost access forces a resync
376
+
377
+ A model takes part with `@PersistedModel({ sync: { roles, strategy } })` and `@SyncService()` on its service.
378
+ Details: [src/core/modules/sync/README.md](src/core/modules/sync/README.md), client duties in
379
+ [src/core/modules/sync/SECURITY.md](src/core/modules/sync/SECURITY.md).
380
+
364
381
  ## AI Assistant & MCP
365
382
 
366
383
  An optional AI module (`ai` configuration) for any OpenAI-compatible model endpoint:
@@ -440,6 +457,7 @@ The Module Inheritance Pattern ensures any core module can be extended to integr
440
457
  | **Hub** | ADMIN-only operator cockpit at `/hub` |
441
458
  | **Migrate** | Migrations for MongoDB and SQL with distributed locking for cluster deployments |
442
459
  | **Permissions** | Security map report: routes, roles and `@Restricted` fields (HTML, JSON, Markdown) |
460
+ | **Sync** | Offline sync for mobile, web and desktop clients: pull, push, conflicts, tombstones, hints |
443
461
  | **SystemSetup** | Initial admin creation for fresh deployments |
444
462
 
445
463
  ### CrudService
@@ -424,6 +424,7 @@ export interface IServerOptions {
424
424
  options?: ServeStaticOptions;
425
425
  path?: string;
426
426
  };
427
+ sync?: boolean | ISync;
427
428
  systemSetup?: ISystemSetup;
428
429
  templates?: {
429
430
  engine?: string;
@@ -458,6 +459,40 @@ export interface IS3Config {
458
459
  secretAccessKey?: string;
459
460
  stagingBucket?: string;
460
461
  }
462
+ export interface ISync {
463
+ cursorSecret?: string;
464
+ enabled?: boolean;
465
+ hints?: boolean | {
466
+ debounceMs?: number;
467
+ enabled?: boolean;
468
+ graphql?: boolean;
469
+ sse?: boolean;
470
+ };
471
+ path?: string;
472
+ pull?: {
473
+ defaultLimit?: number;
474
+ maxBytes?: number;
475
+ maxLimit?: number;
476
+ windowSeconds?: number;
477
+ };
478
+ push?: {
479
+ maxItems?: number;
480
+ };
481
+ rateLimit?: false | {
482
+ pull?: false | {
483
+ max?: number;
484
+ windowSeconds?: number;
485
+ };
486
+ push?: false | {
487
+ max?: number;
488
+ windowSeconds?: number;
489
+ };
490
+ };
491
+ retention?: {
492
+ mutationsDays?: number;
493
+ tombstonesDays?: false | number;
494
+ };
495
+ }
461
496
  export interface ISystemSetup {
462
497
  enabled?: boolean;
463
498
  initialAdmin?: ISystemSetupInitialAdmin;
@@ -541,6 +576,12 @@ export interface ICoreModuleOverrides {
541
576
  model?: Type<any>;
542
577
  service?: Type<any>;
543
578
  };
579
+ sync?: {
580
+ controller?: Type<any>;
581
+ hintsController?: Type<any>;
582
+ resolver?: Type<any>;
583
+ service?: Type<any>;
584
+ };
544
585
  ai?: {
545
586
  budgetService?: Type<any>;
546
587
  connectionResolver?: Type<any>;
@@ -8,13 +8,16 @@ export interface ServiceOptions {
8
8
  checkRights?: boolean;
9
9
  collation?: CollationOptions;
10
10
  create?: boolean;
11
+ createId?: string;
11
12
  currentUser?: {
12
13
  [key: string]: any;
13
14
  id: string;
14
15
  roles?: string[];
15
16
  };
16
17
  fieldSelection?: FieldSelection;
18
+ expectedVersion?: number;
17
19
  force?: boolean;
20
+ includeTombstones?: boolean;
18
21
  inputType?: new (...params: any[]) => any;
19
22
  outputType?: new (...params: any[]) => any;
20
23
  populate?: (PopulateOptions | string)[] | PopulateOptions | string;
@@ -8,8 +8,11 @@ var CorePersistenceModule_1;
8
8
  import { Module } from '@nestjs/common';
9
9
  import { getConnectionToken, getModelToken } from './mongo/mongoose-compat.js';
10
10
  import { registerModelSchema } from './model-metadata.registry.js';
11
+ import { isDeclaredSchema } from './mongo/model-declarations.js';
12
+ import { applySyncSchema, assertSyncCollectionReady } from './mongo/sync-schema.plugin.js';
11
13
  import { MongooseDataStore } from './mongoose-data-store.js';
12
- import { getPersistedModelOptions, getPersistedModelOptionsByName } from './persisted-model.decorator.js';
14
+ import { getPersistedModelOptions, getPersistedModelOptionsByName, getPersistedSyncByName, } from './persisted-model.decorator.js';
15
+ import { resolveSyncOptions } from './sync-model.js';
13
16
  import { bindStoreToModel, getPersistenceRuntime, registerDataStore, registerPersistedModel, } from './persistence-runtime.registry.js';
14
17
  import { getStoreToken } from './persistence.tokens.js';
15
18
  import { guardRoutedModel, isStandInModel, standInModel } from './routed-model.guard.js';
@@ -17,7 +20,11 @@ import { unavailableStore } from './unavailable-store.js';
17
20
  let CorePersistenceModule = CorePersistenceModule_1 = class CorePersistenceModule {
18
21
  static forFeature(models) {
19
22
  for (const model of models) {
20
- registerModelSchema(model.name, model.schema);
23
+ const sync = resolveSyncOptions(model.model ? getPersistedModelOptions(model.model)?.sync : getPersistedSyncByName(model.name), model.name);
24
+ registerModelSchema(model.name, model.schema, sync);
25
+ if (sync && !isDeclaredSchema(model.schema)) {
26
+ applySyncSchema(model.schema, sync);
27
+ }
21
28
  registerPersistedModel({
22
29
  declaredSource: model.dataSource ??
23
30
  (model.model ? getPersistedModelOptions(model.model) : getPersistedModelOptionsByName(model.name))
@@ -51,6 +58,9 @@ let CorePersistenceModule = CorePersistenceModule_1 = class CorePersistenceModul
51
58
  ?
52
59
  unavailableStore(model.name, 'the project runs without a database (`database: false`)')
53
60
  : new MongooseDataStore(mongooseModel);
61
+ if (store.capabilities.mongoosePopulate && store.capabilities.changeOrder) {
62
+ await assertSyncCollectionReady(mongooseModel);
63
+ }
54
64
  registerDataStore(model.name, store);
55
65
  bindStoreToModel(mongooseModel, store);
56
66
  if (!store.capabilities.mongoosePopulate && !isStandInModel(mongooseModel)) {
@@ -1 +1 @@
1
- {"version":3,"file":"core-persistence.module.js","sourceRoot":"","sources":["../../../../src/core/common/persistence/core-persistence.module.ts"],"names":[],"mappings":";;;;;;;AAAA,OAAO,EAAiB,MAAM,EAAY,MAAM,gBAAgB,CAAC;AAEjE,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAG/E,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,EAAE,wBAAwB,EAAE,8BAA8B,EAAE,MAAM,gCAAgC,CAAC;AAC1G,OAAO,EACL,gBAAgB,EAChB,qBAAqB,EACrB,iBAAiB,EACjB,sBAAsB,GACvB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AACzF,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AA6BnD,IAAM,qBAAqB,6BAA3B,MAAM,qBAAqB;IAChC,MAAM,CAAC,UAAU,CAAC,MAAoC;QACpD,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,mBAAmB,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;YAC9C,sBAAsB,CAAC;gBACrB,cAAc,EACZ,KAAK,CAAC,UAAU;oBAChB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,wBAAwB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,8BAA8B,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;wBAChG,EAAE,UAAU;gBAChB,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,MAAM,EAAE,KAAK,CAAC,MAAM;aACrB,CAAC,CAAC;QACL,CAAC;QAID,MAAM,cAAc,GAAe,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC;YAC3D;gBACE,MAAM,EAAE,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,kBAAkB,EAAE,EAAE,CAAC;gBACzD,OAAO,EAAE,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC;gBAClC,UAAU,EAAE,CAAC,UAAwE,EAAE,EAAE,CACvF,UAAU;oBACR,CAAC,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC;oBACjG,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,CAAC;aAC7C;YACD,GAAG,CAAC,KAAK,CAAC,cAAc,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC;gBACtD,MAAM,EAAE,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;gBACnC,OAAO,EAAE,aAAa,CAAC,aAAa,CAAC,IAAI,CAAC;gBAC1C,UAAU,EAAE,CAAC,MAAmC,EAAE,EAAE,CAClD,MAAM,CAAC,aAAa,CAAC,aAAa,CAAC,IAAI,EAAE,aAAa,CAAC,MAAM,EAAE,aAAa,CAAC,KAAK,CAAC;aACtF,CAAC,CAAC;SACJ,CAAC,CAAC;QACH,MAAM,MAAM,GAAe,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;YAChD,MAAM,EAAE,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YACnC,OAAO,EAAE,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC;YAGlC,UAAU,EAAE,KAAK,EAAE,aAAyB,EAAE,EAAE;gBAC9C,MAAM,OAAO,GAAG,qBAAqB,EAAE,CAAC;gBACxC,MAAM,KAAK,GAAG,OAAO;oBACnB,CAAC,CAAC,MAAM,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,IAAI,EAAE,aAAa,CAAC;oBACvD,CAAC,CAAC,cAAc,CAAC,aAAa,CAAC;wBAC7B,CAAC;4BACC,gBAAgB,CAAC,KAAK,CAAC,IAAI,EAAE,yDAAyD,CAAC;wBACzF,CAAC,CAAC,IAAI,iBAAiB,CAAC,aAAa,CAAC,CAAC;gBAC3C,iBAAiB,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;gBACrC,gBAAgB,CAAC,aAAa,EAAE,KAAK,CAAC,CAAC;gBAEvC,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,gBAAgB,IAAI,CAAC,cAAc,CAAC,aAAa,CAAC,EAAE,CAAC;oBAC3E,gBAAgB,CAAC,aAAa,EAAE,KAAK,CAAC,CAAC;gBACzC,CAAC;gBACD,OAAO,KAAK,CAAC;YACf,CAAC;SACF,CAAC,CAAC,CAAC;QACJ,OAAO;YACL,OAAO,EAAE;gBACP,GAAG,cAAc,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAE,QAAgC,CAAC,OAAO,CAAC;gBAC9E,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAE,KAA6B,CAAC,OAAO,CAAC;aACjE;YACD,MAAM,EAAE,uBAAqB;YAC7B,SAAS,EAAE,CAAC,GAAG,cAAc,EAAE,GAAG,MAAM,CAAC;SAC1C,CAAC;IACJ,CAAC;CACF,CAAA;AA/DY,qBAAqB;IADjC,MAAM,CAAC,EAAE,CAAC;GACE,qBAAqB,CA+DjC"}
1
+ {"version":3,"file":"core-persistence.module.js","sourceRoot":"","sources":["../../../../src/core/common/persistence/core-persistence.module.ts"],"names":[],"mappings":"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"}
@@ -4,6 +4,7 @@ import { RequestContext } from '../services/request-context.service.js';
4
4
  import { expiredRecordsFilter } from './expiry-sweep.js';
5
5
  import { redactUrl, resolveModelSource, } from './data-source.config.js';
6
6
  import { getModelDescriptor } from './model-metadata.registry.js';
7
+ import { withSequenceField } from './sync-model.js';
7
8
  import { MongooseDataStore } from './mongoose-data-store.js';
8
9
  import { getPersistedModels, } from './persistence-runtime.registry.js';
9
10
  import { sqlDialect } from './sql/sql-dialect.js';
@@ -140,10 +141,11 @@ export class DataSourceRuntime {
140
141
  return source ? this.resolved.sources[source] : undefined;
141
142
  }
142
143
  async openStore(source, name) {
143
- const model = getModelDescriptor(name);
144
- if (!model) {
144
+ const declared = getModelDescriptor(name);
145
+ if (!declared) {
145
146
  throw new Error(`No descriptor for model "${name}" — register it with CorePersistenceModule.forFeature()`);
146
147
  }
148
+ const model = withSequenceField(declared);
147
149
  const app = await this.executor(source);
148
150
  const dialect = this.dialect(source);
149
151
  let changes;