@lenne.tech/nest-server 12.1.0 → 12.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/architecture.md +1 -0
- package/.claude/rules/better-auth.md +18 -6
- package/.claude/rules/configurable-features.md +5 -4
- package/.claude/rules/package-management.md +16 -1
- package/.claude/rules/persistence.md +26 -0
- package/.claude/rules/testing.md +102 -28
- package/CLAUDE.md +3 -2
- package/FRAMEWORK-API.md +17 -2
- package/README.md +24 -3
- package/dist/core/common/helpers/file.helper.js +3 -4
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
- 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 +14 -19
- 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/persistence-runtime.registry.d.ts +5 -1
- package/dist/core/common/persistence/persistence-runtime.registry.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.d.ts +2 -0
- package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +145 -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-dialect.d.ts +2 -0
- package/dist/core/common/persistence/sql/sql-dialect.js +14 -2
- package/dist/core/common/persistence/sql/sql-dialect.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/core-cron-jobs.service.d.ts +27 -0
- package/dist/core/common/services/core-cron-jobs.service.js +269 -13
- package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
- package/dist/core/common/services/core-redis-pubsub.js +5 -5
- package/dist/core/common/services/core-redis-pubsub.js.map +1 -1
- package/dist/core/common/services/core-redis.service.d.ts +6 -0
- package/dist/core/common/services/core-redis.service.js +57 -0
- package/dist/core/common/services/core-redis.service.js.map +1 -1
- 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/better-auth/better-auth-sql.helper.d.ts +1 -0
- package/dist/core/modules/better-auth/better-auth-sql.helper.js +41 -14
- package/dist/core/modules/better-auth/better-auth-sql.helper.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-sql.store.d.ts +4 -7
- package/dist/core/modules/better-auth/core-better-auth-sql.store.js +23 -16
- package/dist/core/modules/better-auth/core-better-auth-sql.store.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +12 -2
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +82 -5
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.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/hub/hub-buffer.js +3 -4
- package/dist/core/modules/hub/hub-buffer.js.map +1 -1
- package/dist/core/modules/migrate/index.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +6 -0
- package/dist/core/modules/migrate/migration-runner.js +28 -2
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/core/modules/migrate/mongo-state-store.d.ts +0 -1
- package/dist/core/modules/migrate/mongo-state-store.js +0 -5
- package/dist/core/modules/migrate/mongo-state-store.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 +8 -3
- 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 +3 -2
- package/docs/choosing-a-database.md +7 -4
- package/docs/offline-sync-protocol.md +410 -0
- package/docs/security-overrides.md +14 -0
- package/migration-guides/12.0.x-to-12.1.x.md +9 -4
- package/migration-guides/12.1.0-to-12.1.1.md +125 -0
- package/migration-guides/12.1.x-to-12.2.x.md +154 -0
- package/package.json +10 -8
- package/src/core/common/helpers/file.helper.ts +7 -5
- package/src/core/common/helpers/graphql-ws-connection.helper.ts +3 -2
- package/src/core/common/interfaces/cron-job-config.interface.ts +7 -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 +22 -23
- 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/persistence-runtime.registry.ts +16 -5
- 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 +194 -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-dialect.ts +38 -1
- package/src/core/common/persistence/sql/sql-errors.ts +5 -1
- package/src/core/common/persistence/sql/sql-executor.interface.ts +4 -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/core-cron-jobs.service.ts +441 -27
- package/src/core/common/services/core-redis-pubsub.ts +8 -6
- package/src/core/common/services/core-redis.service.ts +99 -0
- package/src/core/common/services/crud.service.ts +257 -19
- package/src/core/modules/better-auth/README.md +25 -1
- package/src/core/modules/better-auth/better-auth-sql.helper.ts +56 -24
- package/src/core/modules/better-auth/better-auth.config.ts +4 -3
- package/src/core/modules/better-auth/core-better-auth-sql.store.ts +28 -21
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +122 -6
- package/src/core/modules/better-auth/core-better-auth.module.ts +2 -2
- package/src/core/modules/error-code/error-codes.ts +59 -0
- package/src/core/modules/hub/README.md +5 -2
- package/src/core/modules/hub/hub-buffer.ts +13 -7
- package/src/core/modules/migrate/README.md +20 -11
- package/src/core/modules/migrate/index.ts +1 -0
- package/src/core/modules/migrate/migration-runner.ts +68 -3
- package/src/core/modules/migrate/mongo-state-store.ts +7 -30
- 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/README.md +1 -0
- package/src/core/modules/tenant/core-tenant.guard.ts +16 -3
- 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
|
@@ -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)",
|
|
@@ -136,7 +137,7 @@
|
|
|
136
137
|
"lodash": "4.18.1",
|
|
137
138
|
"multer": "2.5.0",
|
|
138
139
|
"node-mailjet": "6.0.11",
|
|
139
|
-
"nodemailer": "10.
|
|
140
|
+
"nodemailer": "10.3.0",
|
|
140
141
|
"passport": "0.7.0",
|
|
141
142
|
"passport-jwt": "4.0.1",
|
|
142
143
|
"reflect-metadata": "0.2.2",
|
|
@@ -225,9 +226,9 @@
|
|
|
225
226
|
}
|
|
226
227
|
},
|
|
227
228
|
"devDependencies": {
|
|
228
|
-
"@aws-sdk/client-s3": "3.
|
|
229
|
-
"@aws-sdk/lib-storage": "3.
|
|
230
|
-
"@aws-sdk/s3-request-presigner": "3.
|
|
229
|
+
"@aws-sdk/client-s3": "3.1159.0",
|
|
230
|
+
"@aws-sdk/lib-storage": "3.1159.0",
|
|
231
|
+
"@aws-sdk/s3-request-presigner": "3.1159.0",
|
|
231
232
|
"@better-auth/core": "1.7.14",
|
|
232
233
|
"@better-auth/passkey": "1.7.14",
|
|
233
234
|
"@compodoc/compodoc": "2.0.0",
|
|
@@ -244,12 +245,12 @@
|
|
|
244
245
|
"@types/express": "5.0.6",
|
|
245
246
|
"@types/lodash": "4.17.27",
|
|
246
247
|
"@types/multer": "2.3.0",
|
|
247
|
-
"@types/node": "26.6.
|
|
248
|
+
"@types/node": "26.6.6",
|
|
248
249
|
"@types/passport": "1.0.17",
|
|
249
250
|
"@types/pg": "8.23.1",
|
|
250
251
|
"@vitest/coverage-v8": "5.0.5",
|
|
251
252
|
"better-auth": "1.7.14",
|
|
252
|
-
"bullmq": "6.5.
|
|
253
|
+
"bullmq": "6.5.1",
|
|
253
254
|
"cross-env": "10.1.0",
|
|
254
255
|
"drizzle-orm": "1.0.0-rc.5",
|
|
255
256
|
"ioredis": "6.0.0",
|
|
@@ -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",
|
|
@@ -281,15 +281,17 @@ export function multerFileToUpload(file: {
|
|
|
281
281
|
|
|
282
282
|
/**
|
|
283
283
|
* Get function to generate a random file name for multer with a certain
|
|
284
|
-
* number of digits
|
|
284
|
+
* number of hexadecimal digits
|
|
285
|
+
*
|
|
286
|
+
* The digits come from `randomBytes()`: where the upload directory is served,
|
|
287
|
+
* a name from `Math.random()` could be predicted (DEV-3897).
|
|
285
288
|
*/
|
|
286
289
|
export function multerRandomFileName(numberOfDigits = 32) {
|
|
287
290
|
return (req, file, cb) => {
|
|
288
291
|
// Generating a random string
|
|
289
|
-
const randomName =
|
|
290
|
-
.
|
|
291
|
-
.
|
|
292
|
-
.join('');
|
|
292
|
+
const randomName = randomBytes(Math.ceil(numberOfDigits / 2))
|
|
293
|
+
.toString('hex')
|
|
294
|
+
.slice(0, numberOfDigits);
|
|
293
295
|
|
|
294
296
|
// Calling the callback passing the random name generated with the
|
|
295
297
|
// original extension name
|
|
@@ -355,8 +355,9 @@ function markStale(connection: WsConnection): void {
|
|
|
355
355
|
|
|
356
356
|
/**
|
|
357
357
|
* Re-check, at once, every open connection of a user — after a membership, role or credential change.
|
|
358
|
-
* Answers the number of connections affected. Process-local: the
|
|
359
|
-
*
|
|
358
|
+
* Answers the number of connections affected. Process-local: the Redis broadcasts of the tenant guard
|
|
359
|
+
* (memberships) and of the Better-Auth user mapper (roles, verification) carry an invalidation to the
|
|
360
|
+
* other replicas.
|
|
360
361
|
*/
|
|
361
362
|
export function revalidateWsConnectionsOf(identity: IWsConnectionIdentity): number {
|
|
362
363
|
const userId = identity?.userId !== undefined && identity?.userId !== null ? String(identity.userId) : undefined;
|
|
@@ -27,7 +27,8 @@ export interface CronJobConfig<OC extends CronOnCompleteCommand | null = null, C
|
|
|
27
27
|
* Whether the job is deduplicated across replicas in multi-replica deployments.
|
|
28
28
|
*
|
|
29
29
|
* When enabled, only ONE replica executes each tick: via BullMQ job schedulers when
|
|
30
|
-
* Redis is configured, otherwise via a
|
|
30
|
+
* Redis is configured, otherwise via a lease per tick (Redis, the SQL default source, or
|
|
31
|
+
* MongoDB), keyed on the tick's SCHEDULED instant. The lease is BEST EFFORT —
|
|
31
32
|
* an unreachable lease store runs the tick everywhere rather than stopping all scheduled
|
|
32
33
|
* work, because a silent fleet-wide outage of every cron job is the worse failure.
|
|
33
34
|
*
|
|
@@ -52,8 +53,12 @@ export interface CronJobConfig<OC extends CronOnCompleteCommand | null = null, C
|
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* Depending on how long the execution of a job takes, it may happen that several executions take place at the
|
|
55
|
-
* same time. This can be prevented with `runParallel = false`.This option is set to `true` by default.
|
|
56
|
+
* same time. This can be prevented with `runParallel = false`. This option is set to `true` by default.
|
|
56
57
|
* If a number is specified, it is used as the number of maximum parallel executions.
|
|
58
|
+
*
|
|
59
|
+
* For a `distributed` job, `false` (or `1`) holds across replicas too: the run holds a lease, renewed while it
|
|
60
|
+
* runs and released at its end, so another replica skips its tick instead of starting the job in parallel
|
|
61
|
+
* (12.1.1+). A larger number limits each process on its own.
|
|
57
62
|
*/
|
|
58
63
|
runParallel?: boolean | number;
|
|
59
64
|
|
|
@@ -3509,6 +3509,16 @@ export interface IServerOptions {
|
|
|
3509
3509
|
path?: string;
|
|
3510
3510
|
};
|
|
3511
3511
|
|
|
3512
|
+
/**
|
|
3513
|
+
* Offline sync (`docs/offline-sync-protocol.md`): `GET/POST /sync/{model}` for the models whose service carries
|
|
3514
|
+
* `@SyncService()` and whose model is declared with `@PersistedModel({ sync })`. Presence implies enabled — `true`
|
|
3515
|
+
* or `{}` enables it with the defaults, `{ enabled: false }` pre-configures without enabling. Absent: no route, no
|
|
3516
|
+
* store, no code path.
|
|
3517
|
+
*
|
|
3518
|
+
* @since 12.2.0
|
|
3519
|
+
*/
|
|
3520
|
+
sync?: boolean | ISync;
|
|
3521
|
+
|
|
3512
3522
|
/**
|
|
3513
3523
|
* System setup configuration for initial admin creation.
|
|
3514
3524
|
*
|
|
@@ -3831,6 +3841,125 @@ export interface IS3Config {
|
|
|
3831
3841
|
stagingBucket?: string;
|
|
3832
3842
|
}
|
|
3833
3843
|
|
|
3844
|
+
/**
|
|
3845
|
+
* Offline sync (`sync`, presence implies enabled). See `src/core/modules/sync/README.md`.
|
|
3846
|
+
*
|
|
3847
|
+
* @since 12.2.0
|
|
3848
|
+
*/
|
|
3849
|
+
export interface ISync {
|
|
3850
|
+
/**
|
|
3851
|
+
* Secret the pull cursors are signed with (HMAC; the key is derived from it). Falls back to the
|
|
3852
|
+
* `SECRETS_ENCRYPTION_KEY` environment variable. REQUIRED in `production` / `staging` — the boot fails without it;
|
|
3853
|
+
* elsewhere an insecure development default is used with a warning. Changing it answers every client's next pull
|
|
3854
|
+
* with a full resync (`410 invalid`).
|
|
3855
|
+
*/
|
|
3856
|
+
cursorSecret?: string;
|
|
3857
|
+
|
|
3858
|
+
/**
|
|
3859
|
+
* Pre-configure without enabling.
|
|
3860
|
+
* @default true (when the object is present)
|
|
3861
|
+
*/
|
|
3862
|
+
enabled?: boolean;
|
|
3863
|
+
|
|
3864
|
+
/**
|
|
3865
|
+
* Hints that a synchronized model changed (protocol §9): SSE `GET /sync/hints` and the GraphQL subscription
|
|
3866
|
+
* `syncHint`, carrying `{ model, at }` only, to callers with the model's pull roles and — on a tenant-scoped model —
|
|
3867
|
+
* for changes in their own tenants. A hint only means "pull now": a client works without them. `false` switches both
|
|
3868
|
+
* channels off; with `redis` a hint reaches the subscribers of every replica.
|
|
3869
|
+
* @default on (both channels)
|
|
3870
|
+
*/
|
|
3871
|
+
hints?:
|
|
3872
|
+
| boolean
|
|
3873
|
+
| {
|
|
3874
|
+
/**
|
|
3875
|
+
* At most one hint per model and tenant within this many milliseconds — a burst of writes is one hint.
|
|
3876
|
+
* @default 250
|
|
3877
|
+
*/
|
|
3878
|
+
debounceMs?: number;
|
|
3879
|
+
/** @default true (when the object is present) */
|
|
3880
|
+
enabled?: boolean;
|
|
3881
|
+
/**
|
|
3882
|
+
* The GraphQL subscription `syncHint` (only where GraphQL is enabled).
|
|
3883
|
+
* @default true
|
|
3884
|
+
*/
|
|
3885
|
+
graphql?: boolean;
|
|
3886
|
+
/**
|
|
3887
|
+
* The SSE stream `GET /sync/hints`.
|
|
3888
|
+
* @default true
|
|
3889
|
+
*/
|
|
3890
|
+
sse?: boolean;
|
|
3891
|
+
};
|
|
3892
|
+
|
|
3893
|
+
/**
|
|
3894
|
+
* Route prefix of the sync endpoints.
|
|
3895
|
+
* @default 'sync'
|
|
3896
|
+
*/
|
|
3897
|
+
path?: string;
|
|
3898
|
+
|
|
3899
|
+
pull?: {
|
|
3900
|
+
/**
|
|
3901
|
+
* Records per page when the client sends no `limit`.
|
|
3902
|
+
* @default 200
|
|
3903
|
+
*/
|
|
3904
|
+
defaultLimit?: number;
|
|
3905
|
+
/**
|
|
3906
|
+
* Upper bound of the serialized size of one page; a page ends before it would exceed it (it always holds at least
|
|
3907
|
+
* one item when one is pending).
|
|
3908
|
+
* @default 1048576 (1 MiB)
|
|
3909
|
+
*/
|
|
3910
|
+
maxBytes?: number;
|
|
3911
|
+
/**
|
|
3912
|
+
* Highest `limit` a client may ask for (at most 1000).
|
|
3913
|
+
* @default 1000
|
|
3914
|
+
*/
|
|
3915
|
+
maxLimit?: number;
|
|
3916
|
+
/**
|
|
3917
|
+
* MongoDB only: the window W a pull run re-reads below its previous start (plan §3.4). Changes stamped within W
|
|
3918
|
+
* before a run started may be repeated; a write that takes longer than W from its stamp until it is visible would
|
|
3919
|
+
* be missed, so W must cover the longest such write.
|
|
3920
|
+
* @default replica set / sharded: the server's `transactionLifetimeLimitSeconds` (60 when unreadable) + 30;
|
|
3921
|
+
* standalone: 30
|
|
3922
|
+
*/
|
|
3923
|
+
windowSeconds?: number;
|
|
3924
|
+
};
|
|
3925
|
+
|
|
3926
|
+
push?: {
|
|
3927
|
+
/**
|
|
3928
|
+
* Items per push request; more answer 400.
|
|
3929
|
+
* @default 100
|
|
3930
|
+
*/
|
|
3931
|
+
maxItems?: number;
|
|
3932
|
+
};
|
|
3933
|
+
|
|
3934
|
+
/**
|
|
3935
|
+
* Per-user rate limits of pull and push (fixed window; exact across replicas when `redis` is configured). `false`
|
|
3936
|
+
* switches both off, `{ pull: false }` one of them.
|
|
3937
|
+
* @default pull 120 / 60 s, push 60 / 60 s
|
|
3938
|
+
*/
|
|
3939
|
+
rateLimit?:
|
|
3940
|
+
| false
|
|
3941
|
+
| {
|
|
3942
|
+
pull?: false | { max?: number; windowSeconds?: number };
|
|
3943
|
+
push?: false | { max?: number; windowSeconds?: number };
|
|
3944
|
+
};
|
|
3945
|
+
|
|
3946
|
+
retention?: {
|
|
3947
|
+
/**
|
|
3948
|
+
* How long the outcome of a pushed mutation is kept, so a repeat is answered with it (protocol §6.5).
|
|
3949
|
+
* @default 30
|
|
3950
|
+
*/
|
|
3951
|
+
mutationsDays?: number;
|
|
3952
|
+
/**
|
|
3953
|
+
* How long a tombstone is kept. Every hour (on one replica, under a lease) the purge first moves the model's
|
|
3954
|
+
* horizon to this age, then deletes the older tombstones for good; a client whose cursor is older than the horizon
|
|
3955
|
+
* answers its next pull with a full resync (`410 horizon`). `false` keeps tombstones for ever; an invalid value
|
|
3956
|
+
* (zero, negative, not a number) falls back to the default — never to "purge everything".
|
|
3957
|
+
* @default 90
|
|
3958
|
+
*/
|
|
3959
|
+
tombstonesDays?: false | number;
|
|
3960
|
+
};
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3834
3963
|
export interface ISystemSetup {
|
|
3835
3964
|
/**
|
|
3836
3965
|
* Whether system setup is enabled.
|
|
@@ -4831,6 +4960,26 @@ export interface ICoreModuleOverrides {
|
|
|
4831
4960
|
service?: Type<any>;
|
|
4832
4961
|
};
|
|
4833
4962
|
|
|
4963
|
+
/**
|
|
4964
|
+
* Override the offline sync endpoints, the hint subscription and their service (`sync` config).
|
|
4965
|
+
*
|
|
4966
|
+
* - `controller` must extend `CoreSyncController` (e.g. to release a sync scope to API tokens)
|
|
4967
|
+
* - `hintsController` must extend `CoreSyncHintsController` (`GET /sync/hints`, e.g. for API tokens there too)
|
|
4968
|
+
* - `resolver` must extend `CoreSyncResolver` (the `syncHint` subscription)
|
|
4969
|
+
* - `service` must extend `CoreSyncService`
|
|
4970
|
+
*
|
|
4971
|
+
* @example
|
|
4972
|
+
* ```typescript
|
|
4973
|
+
* { sync: { controller: SyncController } }
|
|
4974
|
+
* ```
|
|
4975
|
+
*/
|
|
4976
|
+
sync?: {
|
|
4977
|
+
controller?: Type<any>;
|
|
4978
|
+
hintsController?: Type<any>;
|
|
4979
|
+
resolver?: Type<any>;
|
|
4980
|
+
service?: Type<any>;
|
|
4981
|
+
};
|
|
4982
|
+
|
|
4834
4983
|
/**
|
|
4835
4984
|
* Override AI module collaborators with project-specific subclasses.
|
|
4836
4985
|
*
|
|
@@ -32,6 +32,11 @@ export interface ServiceOptions {
|
|
|
32
32
|
// If truly: create mode is activated
|
|
33
33
|
create?: boolean;
|
|
34
34
|
|
|
35
|
+
// The id `create()` gives the new record: on a synchronized model the id a client chose offline, in the model's
|
|
36
|
+
// format (refused otherwise), on any other model a 24-character hex id. Passed beside the input because an input
|
|
37
|
+
// class keeps only the fields it declares: an `id` inside the input is lost the moment `inputType` maps it
|
|
38
|
+
createId?: string;
|
|
39
|
+
|
|
35
40
|
// Current user to set ownership, check rights and other things
|
|
36
41
|
currentUser?: {
|
|
37
42
|
[key: string]: any;
|
|
@@ -42,9 +47,19 @@ export interface ServiceOptions {
|
|
|
42
47
|
// Field selection for results (will be overwritten by populate if populate was set)
|
|
43
48
|
fieldSelection?: FieldSelection;
|
|
44
49
|
|
|
50
|
+
// Conditional change of a synchronized model (`@PersistedModel({ sync })`): `update()` and `delete()` apply only
|
|
51
|
+
// when the stored record still has this version, in one atomic statement, and throw a 409 (`VersionConflictError`)
|
|
52
|
+
// otherwise. Refused on a model that is not synchronized
|
|
53
|
+
expectedVersion?: number;
|
|
54
|
+
|
|
45
55
|
// Determines whether all restrictions are ignored
|
|
46
56
|
force?: boolean;
|
|
47
57
|
|
|
58
|
+
// Read a synchronized model's tombstones too — its deleted records, which every other read leaves out (get, find,
|
|
59
|
+
// findOne, findAndCount, distinct). Reads only: update() and delete() never touch a tombstone and answer 404 for one.
|
|
60
|
+
// Applies to the main model only: a populated reference to a tombstone stays empty
|
|
61
|
+
includeTombstones?: boolean;
|
|
62
|
+
|
|
48
63
|
// Overwrites type of input (array items)
|
|
49
64
|
inputType?: new (...params: any[]) => any;
|
|
50
65
|
|
|
@@ -4,8 +4,15 @@ import { getConnectionToken, getModelToken } from './mongo/mongoose-compat.js';
|
|
|
4
4
|
import type { Model } from 'mongoose';
|
|
5
5
|
|
|
6
6
|
import { registerModelSchema } from './model-metadata.registry.js';
|
|
7
|
+
import { isDeclaredSchema } from './mongo/model-declarations.js';
|
|
8
|
+
import { applySyncSchema, assertSyncCollectionReady } from './mongo/sync-schema.plugin.js';
|
|
7
9
|
import { MongooseDataStore } from './mongoose-data-store.js';
|
|
8
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
getPersistedModelOptions,
|
|
12
|
+
getPersistedModelOptionsByName,
|
|
13
|
+
getPersistedSyncByName,
|
|
14
|
+
} from './persisted-model.decorator.js';
|
|
15
|
+
import { resolveSyncOptions } from './sync-model.js';
|
|
9
16
|
import {
|
|
10
17
|
bindStoreToModel,
|
|
11
18
|
getPersistenceRuntime,
|
|
@@ -46,7 +53,15 @@ export interface PersistenceModelDefinition extends ModelDefinition {
|
|
|
46
53
|
export class CorePersistenceModule {
|
|
47
54
|
static forFeature(models: PersistenceModelDefinition[]): DynamicModule {
|
|
48
55
|
for (const model of models) {
|
|
49
|
-
|
|
56
|
+
const sync = resolveSyncOptions(
|
|
57
|
+
model.model ? getPersistedModelOptions(model.model)?.sync : getPersistedSyncByName(model.name),
|
|
58
|
+
model.name,
|
|
59
|
+
);
|
|
60
|
+
// Described as declared first, then prepared: the descriptor must not see the Mongoose sync paths
|
|
61
|
+
registerModelSchema(model.name, model.schema, sync);
|
|
62
|
+
if (sync && !isDeclaredSchema(model.schema)) {
|
|
63
|
+
applySyncSchema(model.schema, sync);
|
|
64
|
+
}
|
|
50
65
|
registerPersistedModel({
|
|
51
66
|
declaredSource:
|
|
52
67
|
model.dataSource ??
|
|
@@ -88,6 +103,10 @@ export class CorePersistenceModule {
|
|
|
88
103
|
? // No SQL source and no MongoDB connection: the project runs without a database (`database: false`)
|
|
89
104
|
unavailableStore(model.name, 'the project runs without a database (`database: false`)')
|
|
90
105
|
: new MongooseDataStore(mongooseModel);
|
|
106
|
+
// A synchronized model on MongoDB: the collection must hold nothing from before it was synchronized
|
|
107
|
+
if (store.capabilities.mongoosePopulate && store.capabilities.changeOrder) {
|
|
108
|
+
await assertSyncCollectionReady(mongooseModel);
|
|
109
|
+
}
|
|
91
110
|
registerDataStore(model.name, store);
|
|
92
111
|
bindStoreToModel(mongooseModel, store);
|
|
93
112
|
// A stand-in already refuses, naming the cause; a real Mongoose model of a model routed elsewhere is made to
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from './data-source.config.js';
|
|
14
14
|
import type { ModelDescriptor } from './model-descriptor.interface.js';
|
|
15
15
|
import { getModelDescriptor } from './model-metadata.registry.js';
|
|
16
|
+
import { withSequenceField } from './sync-model.js';
|
|
16
17
|
import { MongooseDataStore } from './mongoose-data-store.js';
|
|
17
18
|
import {
|
|
18
19
|
getPersistedModels,
|
|
@@ -20,8 +21,6 @@ import {
|
|
|
20
21
|
type PersistenceOverview,
|
|
21
22
|
type PersistenceRuntime,
|
|
22
23
|
} from './persistence-runtime.registry.js';
|
|
23
|
-
import { grantStatement } from './sql/postgres/postgres-schema.compiler.js';
|
|
24
|
-
import { assertSafeApplicationRole, SCHEMA_LOCK_SQL } from './sql/postgres/postgres-schema.migrator.js';
|
|
25
24
|
import { type SqlDialect, sqlDialect } from './sql/sql-dialect.js';
|
|
26
25
|
import { createSqlExecutor } from './sql/sql-executor.factory.js';
|
|
27
26
|
import type { SqlExecutor } from './sql/sql-executor.interface.js';
|
|
@@ -120,19 +119,16 @@ export class DataSourceRuntime implements PersistenceRuntime {
|
|
|
120
119
|
if (!registration || !source || source.driver === 'mongodb') {
|
|
121
120
|
return undefined;
|
|
122
121
|
}
|
|
123
|
-
if (source.driver !== 'postgres') {
|
|
124
|
-
throw new Error(
|
|
125
|
-
`Better-Auth cannot run on ${this.dialect(source).label} yet: the user model (table ${USERS_TABLE}) is routed to ` +
|
|
126
|
-
`the source "${source.name}". Keep the user model on PostgreSQL or MongoDB (database.models), or switch Better-Auth off.`,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
122
|
// Opening the store brings `users` up to date first: Better-Auth only adds its columns to the table the user model
|
|
130
|
-
// defines, never the other way round
|
|
123
|
+
// defines, never the other way round — and on MySQL / MariaDB that run also grants the application account its
|
|
124
|
+
// rights on the database, which then cover Better-Auth's tables
|
|
131
125
|
const users = await this.resolveStore(registration.name, undefined);
|
|
132
126
|
const app = await this.executor(source);
|
|
133
127
|
return {
|
|
134
128
|
applySchema: (migrate, tables) => this.applyForeignSchema(source, app, migrate, tables),
|
|
135
|
-
|
|
129
|
+
driver: source.driver as IamSqlStorage['driver'],
|
|
130
|
+
pool: this.dialect(source).foreignPool(app),
|
|
131
|
+
query: queryOn(app),
|
|
136
132
|
source: source.name,
|
|
137
133
|
users,
|
|
138
134
|
};
|
|
@@ -195,10 +191,12 @@ export class DataSourceRuntime implements PersistenceRuntime {
|
|
|
195
191
|
}
|
|
196
192
|
|
|
197
193
|
private async openStore(source: ResolvedDataSource, name: string): Promise<DataStore<any>> {
|
|
198
|
-
const
|
|
199
|
-
if (!
|
|
194
|
+
const declared = getModelDescriptor(name);
|
|
195
|
+
if (!declared) {
|
|
200
196
|
throw new Error(`No descriptor for model "${name}" — register it with CorePersistenceModule.forFeature()`);
|
|
201
197
|
}
|
|
198
|
+
// A synchronized model's table also carries its commit-ordered change position (offline-sync plan §3.4)
|
|
199
|
+
const model = withSequenceField(declared);
|
|
202
200
|
const app = await this.executor(source);
|
|
203
201
|
const dialect = this.dialect(source);
|
|
204
202
|
let changes: SchemaChange[];
|
|
@@ -270,11 +268,12 @@ export class DataSourceRuntime implements PersistenceRuntime {
|
|
|
270
268
|
private async applyForeignSchema(
|
|
271
269
|
source: ResolvedDataSource,
|
|
272
270
|
app: SqlExecutor,
|
|
273
|
-
migrate:
|
|
271
|
+
migrate: Parameters<IamSqlStorage['applySchema']>[0],
|
|
274
272
|
tables: string[],
|
|
275
273
|
): Promise<void> {
|
|
274
|
+
const dialect = this.dialect(source);
|
|
276
275
|
if (!source.ownerUrl) {
|
|
277
|
-
await migrate(app
|
|
276
|
+
await migrate(dialect.foreignPool(app), false, queryOn(app));
|
|
278
277
|
} else {
|
|
279
278
|
const connect = (maxConnections: number) =>
|
|
280
279
|
createSqlExecutor({
|
|
@@ -284,20 +283,15 @@ export class DataSourceRuntime implements PersistenceRuntime {
|
|
|
284
283
|
url: source.ownerUrl,
|
|
285
284
|
});
|
|
286
285
|
const [lock, migrator] = await Promise.all([connect(1), connect(2)]);
|
|
287
|
-
const appRole = decodeURIComponent(new URL(source.url).username);
|
|
288
286
|
try {
|
|
289
|
-
await
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
for (const table of tables) {
|
|
293
|
-
await tx.run({ params: [], text: grantStatement(table, appRole) }, SYSTEM);
|
|
294
|
-
}
|
|
295
|
-
});
|
|
287
|
+
await dialect.withForeignSchemaLock(lock, tables, () =>
|
|
288
|
+
migrate(dialect.foreignPool(migrator), true, queryOn(migrator)),
|
|
289
|
+
);
|
|
296
290
|
} finally {
|
|
297
291
|
await Promise.all([lock.close(), migrator.close()]);
|
|
298
292
|
}
|
|
299
293
|
}
|
|
300
|
-
await
|
|
294
|
+
await dialect.assertSafeApplication(app, tables);
|
|
301
295
|
}
|
|
302
296
|
|
|
303
297
|
private dialect(source: ResolvedDataSource): SqlDialect {
|
|
@@ -346,6 +340,11 @@ const USERS_TABLE = 'users';
|
|
|
346
340
|
|
|
347
341
|
const SYSTEM = { kind: 'system' } as const;
|
|
348
342
|
|
|
343
|
+
/** One statement on an executor as system work, `$1`-numbered parameters, its rows back */
|
|
344
|
+
function queryOn(executor: SqlExecutor): IamSqlStorage['query'] {
|
|
345
|
+
return async (text, params = []) => ({ rows: (await executor.run({ params, text }, SYSTEM)).rows });
|
|
346
|
+
}
|
|
347
|
+
|
|
349
348
|
async function closeExecutor(pending: Promise<SqlExecutor>): Promise<void> {
|
|
350
349
|
await (await pending.catch(() => undefined))?.close();
|
|
351
350
|
}
|