@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,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire format of the sync protocol (`docs/offline-sync-protocol.md`) and the hook of the `custom` strategy.
|
|
3
|
+
* Types only.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** A pushed change (protocol §6.2) */
|
|
7
|
+
export interface ISyncPushItem {
|
|
8
|
+
/** The version the change is based on; absent only on the item that creates the record */
|
|
9
|
+
baseVersion?: number;
|
|
10
|
+
/** The fields to set (`upsert`); validated against the model's input like any create/update */
|
|
11
|
+
data?: Record<string, unknown>;
|
|
12
|
+
/** For `merge`: the field names the client changed. Without it, every key in `data` counts as changed */
|
|
13
|
+
fields?: string[];
|
|
14
|
+
/** The record's id, in the model's format */
|
|
15
|
+
id: string;
|
|
16
|
+
/** Unique per change, generated by the client; the same change sent again carries the same id */
|
|
17
|
+
mutationId: string;
|
|
18
|
+
op: SyncPushOp;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SyncPushOp = 'delete' | 'upsert';
|
|
22
|
+
|
|
23
|
+
/** Protocol §7 */
|
|
24
|
+
export type SyncItemStatus = 'applied' | 'conflict' | 'merged' | 'not_found' | 'rejected' | 'retry';
|
|
25
|
+
|
|
26
|
+
/** Protocol §7 — the reason of a `rejected` item */
|
|
27
|
+
export type SyncRejectCode = 'CUSTOM_REFUSED' | 'FORBIDDEN' | 'ID_UNAVAILABLE' | 'INVALID_ID' | 'VALIDATION_FAILED';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A record as a pull delivers it (protocol §5.2): a live record carries its content in `data`, a tombstone carries
|
|
31
|
+
* no `data` — only its id, version, times, `tenantId` and the model's scope fields (protocol §2).
|
|
32
|
+
*/
|
|
33
|
+
export interface ISyncRecord {
|
|
34
|
+
[scopeField: string]: unknown;
|
|
35
|
+
data?: Record<string, unknown>;
|
|
36
|
+
deletedAt: Date | null;
|
|
37
|
+
id: string;
|
|
38
|
+
updatedAt?: Date;
|
|
39
|
+
version: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Protocol §6.6 */
|
|
43
|
+
export interface ISyncPushResult {
|
|
44
|
+
code?: SyncRejectCode;
|
|
45
|
+
details?: unknown;
|
|
46
|
+
id: string;
|
|
47
|
+
mutationId: string;
|
|
48
|
+
/** The server's record (`conflict`, `merged`) */
|
|
49
|
+
record?: ISyncRecord;
|
|
50
|
+
status: SyncItemStatus;
|
|
51
|
+
version?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Protocol §5.2 */
|
|
55
|
+
export interface ISyncPullResult {
|
|
56
|
+
cursor: string;
|
|
57
|
+
hasMore: boolean;
|
|
58
|
+
items: ISyncRecord[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Protocol §8 */
|
|
62
|
+
export type SyncResyncReason = 'access' | 'horizon' | 'invalid';
|
|
63
|
+
|
|
64
|
+
/** What `resolveSyncConflict()` receives under the `custom` strategy */
|
|
65
|
+
export interface ISyncConflictContext {
|
|
66
|
+
/** What the client sent */
|
|
67
|
+
change: { baseVersion?: number; data?: Record<string, unknown>; fields?: string[]; op: SyncPushOp };
|
|
68
|
+
/** The authenticated caller, as the controller received it */
|
|
69
|
+
currentUser: any;
|
|
70
|
+
id: string;
|
|
71
|
+
model: string;
|
|
72
|
+
/** The record as the caller may see it now, a live record (a tombstone always conflicts) */
|
|
73
|
+
server: ISyncRecord;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The answer of `resolveSyncConflict()`: the fields to write on top of the server's record, a deletion, or a refusal
|
|
78
|
+
* (`rejected` / `CUSTOM_REFUSED`). The write runs through the same pipeline as every push.
|
|
79
|
+
*/
|
|
80
|
+
export type SyncConflictResolution = { data: Record<string, unknown> } | { delete: true } | { refuse: true };
|
|
81
|
+
|
|
82
|
+
/** A model service whose model declares `strategy: 'custom'` implements this */
|
|
83
|
+
export interface SyncConflictResolver {
|
|
84
|
+
resolveSyncConflict(context: ISyncConflictContext): Promise<SyncConflictResolution> | SyncConflictResolution;
|
|
85
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Field, ObjectType } from '@nestjs/graphql';
|
|
2
|
+
|
|
3
|
+
import { Restricted } from '../../../common/decorators/restricted.decorator.js';
|
|
4
|
+
import { RoleEnum } from '../../../common/enums/role.enum.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A change hint of offline sync (protocol §9): the model changed at this time — nothing else, no record, no id, no
|
|
8
|
+
* tenant. A client answers it with a pull. Who receives it was decided before it was sent.
|
|
9
|
+
*/
|
|
10
|
+
@ObjectType('SyncHint', { description: 'A synchronized model changed: pull it' })
|
|
11
|
+
@Restricted(RoleEnum.S_EVERYONE)
|
|
12
|
+
export class CoreSyncHintModel {
|
|
13
|
+
@Field(() => Date, { description: 'When the change was sent' })
|
|
14
|
+
at: Date;
|
|
15
|
+
|
|
16
|
+
@Field(() => String, { description: 'The changed model, as in GET /sync/{model}' })
|
|
17
|
+
model: string;
|
|
18
|
+
}
|
|
@@ -387,6 +387,7 @@ Mechanism, for anyone extending the guard (`core-tenant.guard.ts`):
|
|
|
387
387
|
- The channel is namespaced with the framework's Redis key prefix, so two applications sharing one Redis instance do not clear each other's caches. Two **stages** of the same application do, unless you set `redis.keyPrefix` or `redis.db`.
|
|
388
388
|
- The message is `{ scope: 'user', userId }`, `{ scope: 'tenant', tenantId }` (from `invalidateTenant()`, 11.42.0+) or `{ scope: 'all' }`, JSON-encoded.
|
|
389
389
|
- Subscription uses the shared subscriber connection from `CoreRedisService.getSubscriber()`; the listener is held in a field and detached on destroy, because that connection is shared.
|
|
390
|
+
- **The channel is registered with `CoreRedisService.subscribeChannel()` (12.1.1+)**, which subscribes it again on every `ready` of the subscriber connection. Before, a subscribe that failed because Redis was down at boot was logged on `debug` and never repeated: that replica received no invalidation for its whole life, so a removed membership or a deactivated tenant stayed valid there until `cacheTtlMs`. A failed subscribe is now a warning, and the subscription is established as soon as Redis answers.
|
|
390
391
|
- **A received broadcast clears locally and does NOT re-publish** (`applyInvalidation()` → `clearUser()` / `clearAll()`, the no-broadcast variants). Re-publishing would bounce the message around the cluster forever.
|
|
391
392
|
- **Publishing is fire-and-forget and failures are debug-level only.** A missed broadcast is not an error: it merely leaves the other replicas' caches stale until their TTL — exactly the no-Redis behaviour. Nothing about a request path depends on the broadcast succeeding.
|
|
392
393
|
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { DataSourceConfigError } from '../../common/persistence/data-source.config.js';
|
|
27
27
|
import type { DataStore } from '../../common/persistence/data-store.interface.js';
|
|
28
28
|
import { getStoreToken } from '../../common/persistence/persistence.tokens.js';
|
|
29
|
+
import { getSyncAccessListener } from '../../common/persistence/sync-access.registry.js';
|
|
29
30
|
import { ConfigService } from '../../common/services/config.service.js';
|
|
30
31
|
import { ResolvedTenantContext, setTenantContextResolver } from '../../common/services/core-tenant-context.registry.js';
|
|
31
32
|
import { CoreRedisService } from '../../common/services/core-redis.service.js';
|
|
@@ -339,9 +340,14 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
339
340
|
}
|
|
340
341
|
};
|
|
341
342
|
subscriber.on('message', this.invalidationListener);
|
|
342
|
-
|
|
343
|
+
// Registered with the Redis service, so the subscription is established again after every reconnect — also when
|
|
344
|
+
// this first attempt fails because Redis is down at boot.
|
|
345
|
+
await this.redisService.subscribeChannel(channel);
|
|
343
346
|
} catch (error) {
|
|
344
|
-
this.logger.
|
|
347
|
+
this.logger.warn(
|
|
348
|
+
`Tenant cache invalidation subscribe failed: ${(error as Error).message}. Retried when the Redis connection ` +
|
|
349
|
+
'is up; until then this replica learns of a removed membership or a deactivated tenant only after cacheTtlMs.',
|
|
350
|
+
);
|
|
345
351
|
}
|
|
346
352
|
}
|
|
347
353
|
|
|
@@ -353,7 +359,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
353
359
|
const subscriber = this.redisService?.getSubscriber();
|
|
354
360
|
subscriber?.off('message', this.invalidationListener);
|
|
355
361
|
if (this.invalidationChannel) {
|
|
356
|
-
void
|
|
362
|
+
void this.redisService?.releaseChannel(this.invalidationChannel).catch(() => undefined);
|
|
357
363
|
}
|
|
358
364
|
} catch {
|
|
359
365
|
// Redis already gone — nothing to detach from
|
|
@@ -378,6 +384,13 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
378
384
|
invalidateTenant(tenantId: string): void {
|
|
379
385
|
this.clearTenant(tenantId);
|
|
380
386
|
this.publishInvalidation({ scope: 'tenant', tenantId });
|
|
387
|
+
// Offline sync: the tenant's members resync on their next pull (a no-op without the sync module). Once, here —
|
|
388
|
+
// the epoch lives in the database, so a replica receiving the broadcast must not bump it again
|
|
389
|
+
void getSyncAccessListener()
|
|
390
|
+
?.tenantInvalidated(tenantId)
|
|
391
|
+
.catch((error) =>
|
|
392
|
+
this.logger.warn(`Sync access epoch of tenant ${tenantId} not bumped: ${(error as Error)?.message ?? error}`),
|
|
393
|
+
);
|
|
381
394
|
}
|
|
382
395
|
|
|
383
396
|
/**
|
package/src/core.module.ts
CHANGED
|
@@ -84,6 +84,7 @@ import { isHubEnabled, isHubQueriesEnabled } from './core/modules/hub/hub-config
|
|
|
84
84
|
import { CorePermissionsModule } from './core/modules/permissions/core-permissions.module.js';
|
|
85
85
|
import { CoreSystemSetupModule } from './core/modules/system-setup/core-system-setup.module.js';
|
|
86
86
|
import { CoreApiTokenModule } from './core/modules/api-token/core-api-token.module.js';
|
|
87
|
+
import { CoreSyncModule } from './core/modules/sync/core-sync.module.js';
|
|
87
88
|
import { CoreTenantModule } from './core/modules/tenant/core-tenant.module.js';
|
|
88
89
|
|
|
89
90
|
/** Configuration warnings of `forRoot()`, through Nest's logger so log levels and a project's logger apply. */
|
|
@@ -676,6 +677,30 @@ export class CoreModule implements NestModule {
|
|
|
676
677
|
imports.push(CoreApiTokenModule.forRoot({ ...overrides?.apiToken }));
|
|
677
678
|
}
|
|
678
679
|
|
|
680
|
+
// Add CoreSyncModule when sync is configured (boolean shorthand, presence implies enabled). Without it nothing of
|
|
681
|
+
// offline sync is registered: no route, no infrastructure model, no listener on the write paths.
|
|
682
|
+
const syncConfig = config.sync;
|
|
683
|
+
if (
|
|
684
|
+
syncConfig === true ||
|
|
685
|
+
(typeof syncConfig === 'object' && syncConfig !== null && syncConfig.enabled !== false)
|
|
686
|
+
) {
|
|
687
|
+
imports.push(
|
|
688
|
+
CoreSyncModule.forRoot({
|
|
689
|
+
...overrides?.sync,
|
|
690
|
+
// The subscription needs GraphQL, and is switched off with its channel or with all hints
|
|
691
|
+
graphQlHints:
|
|
692
|
+
config.graphQl !== false &&
|
|
693
|
+
!(
|
|
694
|
+
typeof syncConfig === 'object' &&
|
|
695
|
+
(syncConfig.hints === false ||
|
|
696
|
+
(typeof syncConfig.hints === 'object' &&
|
|
697
|
+
(syncConfig.hints.graphql === false || syncConfig.hints.enabled === false)))
|
|
698
|
+
),
|
|
699
|
+
path: typeof syncConfig === 'object' ? syncConfig.path : undefined,
|
|
700
|
+
}),
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
679
704
|
// Set exports
|
|
680
705
|
const exports: any[] = [
|
|
681
706
|
ConfigService,
|
package/src/index.ts
CHANGED
|
@@ -98,6 +98,8 @@ export * from './core/common/persistence/mongoose-data-store.js';
|
|
|
98
98
|
export * from './core/common/persistence/persisted-model.decorator.js';
|
|
99
99
|
export * from './core/common/persistence/persistence.tokens.js';
|
|
100
100
|
export * from './core/common/persistence/persistence-runtime.registry.js';
|
|
101
|
+
export { VersionConflictError } from './core/common/persistence/policies/sync.policy.js';
|
|
102
|
+
export * from './core/common/persistence/sync-model.js';
|
|
101
103
|
export * from './core/common/persistence/unsupported-capability.error.js';
|
|
102
104
|
export * from './core/common/persistence/sql/mysql/mysql-data-store.js';
|
|
103
105
|
export * from './core/common/persistence/sql/mysql/mysql-lock-store.js';
|
|
@@ -276,6 +278,25 @@ export * from './core/modules/system-setup/core-system-setup.controller.js';
|
|
|
276
278
|
export * from './core/modules/system-setup/core-system-setup.module.js';
|
|
277
279
|
export * from './core/modules/system-setup/core-system-setup.service.js';
|
|
278
280
|
|
|
281
|
+
// =====================================================================================================================
|
|
282
|
+
// Core - Modules - Sync (offline sync)
|
|
283
|
+
// =====================================================================================================================
|
|
284
|
+
|
|
285
|
+
export * from './core/modules/sync/core-sync-base.controller.js';
|
|
286
|
+
export * from './core/modules/sync/core-sync.constants.js';
|
|
287
|
+
export * from './core/modules/sync/core-sync.controller.js';
|
|
288
|
+
export * from './core/modules/sync/core-sync.decorators.js';
|
|
289
|
+
export * from './core/modules/sync/core-sync-hints.controller.js';
|
|
290
|
+
export * from './core/modules/sync/core-sync-hints.service.js';
|
|
291
|
+
export * from './core/modules/sync/core-sync-infrastructure.models.js';
|
|
292
|
+
export * from './core/modules/sync/core-sync.module.js';
|
|
293
|
+
export * from './core/modules/sync/core-sync-mutation.store.js';
|
|
294
|
+
export * from './core/modules/sync/core-sync.resolver.js';
|
|
295
|
+
export * from './core/modules/sync/core-sync.service.js';
|
|
296
|
+
export * from './core/modules/sync/core-sync-state.store.js';
|
|
297
|
+
export * from './core/modules/sync/interfaces/core-sync.interfaces.js';
|
|
298
|
+
export * from './core/modules/sync/models/core-sync-hint.model.js';
|
|
299
|
+
|
|
279
300
|
// =====================================================================================================================
|
|
280
301
|
// Core - Modules - Tus
|
|
281
302
|
// =====================================================================================================================
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { InputType } from '@nestjs/graphql';
|
|
2
|
+
|
|
3
|
+
import { Restricted } from '../../../../core/common/decorators/restricted.decorator.js';
|
|
4
|
+
import { UnifiedField } from '../../../../core/common/decorators/unified-field.decorator.js';
|
|
5
|
+
import { RoleEnum } from '../../../../core/common/enums/role.enum.js';
|
|
6
|
+
import { SyncNoteInput } from './sync-note.input.js';
|
|
7
|
+
|
|
8
|
+
/** Input of a pushed note that creates it; the client chose its id (protocol §3) */
|
|
9
|
+
@InputType({ description: 'Input to create a synchronized note' })
|
|
10
|
+
@Restricted(RoleEnum.ADMIN)
|
|
11
|
+
export class SyncNoteCreateInput extends SyncNoteInput {
|
|
12
|
+
@UnifiedField({ description: 'Title of the note', roles: RoleEnum.S_EVERYONE })
|
|
13
|
+
override title: string = undefined;
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { InputType } from '@nestjs/graphql';
|
|
2
|
+
|
|
3
|
+
import { Restricted } from '../../../../core/common/decorators/restricted.decorator.js';
|
|
4
|
+
import { UnifiedField } from '../../../../core/common/decorators/unified-field.decorator.js';
|
|
5
|
+
import { RoleEnum } from '../../../../core/common/enums/role.enum.js';
|
|
6
|
+
import { CoreInput } from '../../../../core/common/inputs/core-input.input.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Input of a pushed change of a note — validated and whitelisted like a request body, so a client can set only what
|
|
10
|
+
* is declared here (never `createdBy`, `version` or `deletedAt`)
|
|
11
|
+
*/
|
|
12
|
+
@InputType({ description: 'Input to change a synchronized note' })
|
|
13
|
+
@Restricted(RoleEnum.ADMIN)
|
|
14
|
+
export class SyncNoteInput extends CoreInput {
|
|
15
|
+
@UnifiedField({ description: 'Text of the note', isOptional: true, roles: RoleEnum.S_EVERYONE })
|
|
16
|
+
body?: string = undefined;
|
|
17
|
+
|
|
18
|
+
@UnifiedField({ description: 'Priority of the note', isOptional: true, roles: RoleEnum.S_EVERYONE })
|
|
19
|
+
priority?: number = undefined;
|
|
20
|
+
|
|
21
|
+
@UnifiedField({ description: 'Title of the note', isOptional: true, roles: RoleEnum.S_EVERYONE })
|
|
22
|
+
title?: string = undefined;
|
|
23
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ObjectType } from '@nestjs/graphql';
|
|
2
|
+
import type { Types } from 'mongoose';
|
|
3
|
+
|
|
4
|
+
import { Restricted } from '../../../core/common/decorators/restricted.decorator.js';
|
|
5
|
+
import { UnifiedField } from '../../../core/common/decorators/unified-field.decorator.js';
|
|
6
|
+
import { RoleEnum } from '../../../core/common/enums/role.enum.js';
|
|
7
|
+
import { equalIds } from '../../../core/common/helpers/id.helper.js';
|
|
8
|
+
import { CorePersistenceModel } from '../../../core/common/models/core-persistence.model.js';
|
|
9
|
+
import {
|
|
10
|
+
objectIdType,
|
|
11
|
+
Schema as MongooseSchema,
|
|
12
|
+
SchemaFactory,
|
|
13
|
+
} from '../../../core/common/persistence/mongo/mongoose-compat.js';
|
|
14
|
+
import { PersistedModel } from '../../../core/common/persistence/persisted-model.decorator.js';
|
|
15
|
+
import { SyncField } from '../../../core/modules/sync/core-sync.decorators.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A note offered to offline clients — the reference implementation of a synchronized model
|
|
19
|
+
* (`src/core/modules/sync/INTEGRATION-CHECKLIST.md`).
|
|
20
|
+
*
|
|
21
|
+
* Not part of the reference server: `ServerModule` does not import it, so the framework's own suite runs without sync.
|
|
22
|
+
* The sync contract suite (`tests/sync-contract.e2e-spec.ts`) runs on exactly these files.
|
|
23
|
+
*
|
|
24
|
+
* - `sync.roles`: who may pull, create, change and delete through `/sync`. The roles of a project's own routes do not
|
|
25
|
+
* reach the sync routes, so they are declared here; `S_CREATOR` is decided against the stored record.
|
|
26
|
+
* - `sync.strategy: 'merge'`: a change based on an older version keeps the fields the client changed — unless one of
|
|
27
|
+
* them is `@SyncField('reject')`, which still conflicts.
|
|
28
|
+
* - `securityCheck()`: every user sees their own notes. A pull runs every record through it, a push decides with it
|
|
29
|
+
* whether an id belongs to a record the caller may see.
|
|
30
|
+
*/
|
|
31
|
+
@PersistedModel({
|
|
32
|
+
sync: {
|
|
33
|
+
roles: {
|
|
34
|
+
create: [RoleEnum.S_USER],
|
|
35
|
+
delete: [RoleEnum.ADMIN, RoleEnum.S_CREATOR],
|
|
36
|
+
pull: [RoleEnum.S_USER],
|
|
37
|
+
update: [RoleEnum.ADMIN, RoleEnum.S_CREATOR],
|
|
38
|
+
},
|
|
39
|
+
strategy: 'merge',
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
@MongooseSchema({ timestamps: true })
|
|
43
|
+
@ObjectType({ description: 'Note synchronized to offline clients' })
|
|
44
|
+
@Restricted(RoleEnum.ADMIN)
|
|
45
|
+
export class SyncNote extends CorePersistenceModel {
|
|
46
|
+
@UnifiedField({ description: 'Text of the note', isOptional: true, mongoose: true, roles: RoleEnum.S_EVERYONE })
|
|
47
|
+
body?: string = undefined;
|
|
48
|
+
|
|
49
|
+
/** Set by `CrudService.create()`; what `S_CREATOR` and `securityCheck()` decide on */
|
|
50
|
+
@UnifiedField({
|
|
51
|
+
description: 'ID of the user who created the note',
|
|
52
|
+
isOptional: true,
|
|
53
|
+
mongoose: { type: objectIdType() },
|
|
54
|
+
roles: RoleEnum.S_EVERYONE,
|
|
55
|
+
swaggerApiOptions: { type: String },
|
|
56
|
+
type: () => String,
|
|
57
|
+
})
|
|
58
|
+
createdBy?: string | Types.ObjectId = undefined;
|
|
59
|
+
|
|
60
|
+
/** A concurrent change of the priority is never merged silently: it answers `conflict` */
|
|
61
|
+
@SyncField('reject')
|
|
62
|
+
@UnifiedField({ description: 'Priority of the note', isOptional: true, mongoose: true, roles: RoleEnum.S_EVERYONE })
|
|
63
|
+
priority?: number = undefined;
|
|
64
|
+
|
|
65
|
+
@UnifiedField({ description: 'Title of the note', isOptional: true, mongoose: true, roles: RoleEnum.S_EVERYONE })
|
|
66
|
+
title?: string = undefined;
|
|
67
|
+
|
|
68
|
+
/** Every user sees the notes they created; administrators see all */
|
|
69
|
+
override securityCheck(user: any, force?: boolean) {
|
|
70
|
+
if (force || user?.hasRole?.([RoleEnum.ADMIN]) || (user?.id && equalIds(this.createdBy, user))) {
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const SyncNoteSchema = SchemaFactory.createForClass(SyncNote);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { CorePersistenceModule } from '../../../core/common/persistence/core-persistence.module.js';
|
|
4
|
+
import { SyncNote, SyncNoteSchema } from './sync-note.model.js';
|
|
5
|
+
import { SyncNoteService } from './sync-note.service.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The synchronized note model and its service. Registered with `CorePersistenceModule.forFeature()`, so the model
|
|
9
|
+
* follows `database` like every other — and only a model registered this way can be synchronized.
|
|
10
|
+
*/
|
|
11
|
+
@Module({
|
|
12
|
+
exports: [SyncNoteService],
|
|
13
|
+
imports: [CorePersistenceModule.forFeature([{ model: SyncNote, name: SyncNote.name, schema: SyncNoteSchema }])],
|
|
14
|
+
providers: [SyncNoteService],
|
|
15
|
+
})
|
|
16
|
+
export class SyncNoteModule {}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import type { DataStore } from '../../../core/common/persistence/data-store.interface.js';
|
|
4
|
+
import { InjectStore } from '../../../core/common/persistence/inject-store.decorator.js';
|
|
5
|
+
import { ConfigService } from '../../../core/common/services/config.service.js';
|
|
6
|
+
import { CrudService } from '../../../core/common/services/crud.service.js';
|
|
7
|
+
import { SyncService } from '../../../core/modules/sync/core-sync.decorators.js';
|
|
8
|
+
import { SyncNoteCreateInput } from './inputs/sync-note-create.input.js';
|
|
9
|
+
import { SyncNoteInput } from './inputs/sync-note.input.js';
|
|
10
|
+
import { SyncNote } from './sync-note.model.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The note service, offered to offline clients: `@SyncService()` puts the model under `/sync/SyncNote`, and every pull
|
|
14
|
+
* and push of it runs through this service — its `process()` pipeline, `securityCheck()`, the tenant policy.
|
|
15
|
+
*/
|
|
16
|
+
@SyncService({ createInput: SyncNoteCreateInput, model: SyncNote, updateInput: SyncNoteInput })
|
|
17
|
+
@Injectable()
|
|
18
|
+
export class SyncNoteService extends CrudService<SyncNote, SyncNoteCreateInput, SyncNoteInput> {
|
|
19
|
+
constructor(
|
|
20
|
+
protected override readonly configService: ConfigService,
|
|
21
|
+
@InjectStore(SyncNote) store: DataStore<SyncNote>,
|
|
22
|
+
) {
|
|
23
|
+
super({ configService, dataStore: store, mainModelConstructor: SyncNote });
|
|
24
|
+
}
|
|
25
|
+
}
|