@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
|
@@ -44,6 +44,12 @@ export class CoreRedisService implements OnApplicationShutdown, OnModuleInit {
|
|
|
44
44
|
/** Dedicated subscriber connection (a subscribing client cannot run commands) */
|
|
45
45
|
protected subscriberClient?: Redis;
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Channels the shared subscriber must stay subscribed to, with the number of holders of each. Kept so every `ready`
|
|
49
|
+
* of the subscriber connection can subscribe them again — see subscribeChannel().
|
|
50
|
+
*/
|
|
51
|
+
protected readonly subscribedChannels = new Map<string, number>();
|
|
52
|
+
|
|
47
53
|
constructor(protected readonly configService: ConfigService) {
|
|
48
54
|
const raw = this.configService.getFastButReadOnly<boolean | IRedisConfig | undefined>('redis');
|
|
49
55
|
if (raw === undefined || raw === null || raw === false) {
|
|
@@ -77,6 +83,14 @@ export class CoreRedisService implements OnApplicationShutdown, OnModuleInit {
|
|
|
77
83
|
return !!this.config;
|
|
78
84
|
}
|
|
79
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Whether the shared connection is up right now. `enabled` only says Redis is configured; a consumer that wants to
|
|
88
|
+
* know whether a command can succeed asks this. `false` without Redis.
|
|
89
|
+
*/
|
|
90
|
+
isReady(): boolean {
|
|
91
|
+
return this.sharedClient?.status === 'ready';
|
|
92
|
+
}
|
|
93
|
+
|
|
80
94
|
/**
|
|
81
95
|
* Normalized Redis configuration, or undefined when disabled
|
|
82
96
|
*/
|
|
@@ -113,6 +127,45 @@ export class CoreRedisService implements OnApplicationShutdown, OnModuleInit {
|
|
|
113
127
|
}
|
|
114
128
|
this.sharedClient = this.newConnection('shared');
|
|
115
129
|
this.logActivationSummary();
|
|
130
|
+
await this.checkReachableAtBoot();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Ask Redis once at boot and say so when it does not answer.
|
|
135
|
+
*
|
|
136
|
+
* The boot continues either way: failing it would take the whole fleet down with Redis, and most consumers degrade
|
|
137
|
+
* (rate limits, cron leases, the MCP session registry and the tenant broadcast fall back to process-local behaviour).
|
|
138
|
+
* GraphQL subscriptions and TUS upload locks have no fallback and fail until Redis answers — which is why the outage
|
|
139
|
+
* deserves a line here; before, it showed up only as each user's own timeout. Bounded, so an unreachable Redis
|
|
140
|
+
* delays the boot by at most a few seconds.
|
|
141
|
+
*/
|
|
142
|
+
protected async checkReachableAtBoot(timeoutMs = 3000): Promise<void> {
|
|
143
|
+
const client = this.sharedClient;
|
|
144
|
+
if (!client) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const ping = client.ping();
|
|
148
|
+
// The ping may settle after the timeout; its rejection must not surface as unhandled.
|
|
149
|
+
ping.catch(() => undefined);
|
|
150
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
151
|
+
try {
|
|
152
|
+
await Promise.race([
|
|
153
|
+
ping,
|
|
154
|
+
new Promise((_resolve, reject) => {
|
|
155
|
+
timer = setTimeout(() => reject(new Error(`no answer within ${timeoutMs} ms`)), timeoutMs);
|
|
156
|
+
timer.unref?.();
|
|
157
|
+
}),
|
|
158
|
+
]);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.logger.warn(
|
|
161
|
+
`Redis is configured but not reachable at boot (${error instanceof Error ? error.message : String(error)}). ` +
|
|
162
|
+
'The application starts anyway. Rate limits, cron leases and the tenant broadcast fall back to ' +
|
|
163
|
+
'process-local behaviour; GraphQL subscriptions and resumable uploads (TUS) fail until Redis answers. ' +
|
|
164
|
+
'Subscriptions are established once it does.',
|
|
165
|
+
);
|
|
166
|
+
} finally {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
}
|
|
116
169
|
}
|
|
117
170
|
|
|
118
171
|
/**
|
|
@@ -183,10 +236,55 @@ export class CoreRedisService implements OnApplicationShutdown, OnModuleInit {
|
|
|
183
236
|
this.getClient();
|
|
184
237
|
if (!this.subscriberClient) {
|
|
185
238
|
this.subscriberClient = this.newConnection('subscriber');
|
|
239
|
+
// ioredis re-subscribes on reconnect only the channels whose SUBSCRIBE succeeded. One that failed while Redis was
|
|
240
|
+
// down (at boot, typically) is lost for good, so every `ready` subscribes the registered channels again.
|
|
241
|
+
this.subscriberClient.on?.('ready', this.onSubscriberReady);
|
|
186
242
|
}
|
|
187
243
|
return this.subscriberClient;
|
|
188
244
|
}
|
|
189
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Subscribe the shared subscriber connection to `channel` and keep it subscribed.
|
|
248
|
+
*
|
|
249
|
+
* The channel stays registered until releaseChannel() and is subscribed again on every `ready` of the subscriber
|
|
250
|
+
* connection (Redis SUBSCRIBE is idempotent). Rejects when Redis does not answer; the channel stays registered then,
|
|
251
|
+
* so the next `ready` subscribes it. Several holders of one channel are counted.
|
|
252
|
+
*/
|
|
253
|
+
async subscribeChannel(channel: string): Promise<void> {
|
|
254
|
+
const subscriber = this.getSubscriber();
|
|
255
|
+
this.subscribedChannels.set(channel, (this.subscribedChannels.get(channel) ?? 0) + 1);
|
|
256
|
+
await subscriber.subscribe(channel);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Release one hold on `channel`; the last holder unsubscribes it
|
|
261
|
+
*/
|
|
262
|
+
async releaseChannel(channel: string): Promise<void> {
|
|
263
|
+
const holders = (this.subscribedChannels.get(channel) ?? 0) - 1;
|
|
264
|
+
if (holders > 0) {
|
|
265
|
+
this.subscribedChannels.set(channel, holders);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
this.subscribedChannels.delete(channel);
|
|
269
|
+
await this.subscriberClient?.unsubscribe(channel);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Subscribe every registered channel again. An arrow function, so it keeps `this` as an event listener; `void` is
|
|
274
|
+
* safe because the failure is caught here.
|
|
275
|
+
*/
|
|
276
|
+
protected readonly onSubscriberReady = (): void => {
|
|
277
|
+
const channels = [...this.subscribedChannels.keys()];
|
|
278
|
+
if (!channels.length || !this.subscriberClient) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
void this.subscriberClient.subscribe(...channels).catch((error: unknown) => {
|
|
282
|
+
this.logger.warn(
|
|
283
|
+
`Re-subscribing ${channels.length} Redis channel(s) failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
};
|
|
287
|
+
|
|
190
288
|
/**
|
|
191
289
|
* Create a NEW dedicated connection (e.g. for BullMQ, blocking commands).
|
|
192
290
|
* The connection is tracked and closed on application shutdown.
|
|
@@ -215,6 +313,7 @@ export class CoreRedisService implements OnApplicationShutdown, OnModuleInit {
|
|
|
215
313
|
this.clients = [];
|
|
216
314
|
this.sharedClient = undefined;
|
|
217
315
|
this.subscriberClient = undefined;
|
|
316
|
+
this.subscribedChannels.clear();
|
|
218
317
|
}
|
|
219
318
|
|
|
220
319
|
/**
|
|
@@ -9,7 +9,19 @@ import { convertFilterArgsToQuery } from '../helpers/filter.helper.js';
|
|
|
9
9
|
import { mergePlain, prepareServiceOptionsForCreate } from '../helpers/input.helper.js';
|
|
10
10
|
import { ServiceOptions } from '../interfaces/service-options.interface.js';
|
|
11
11
|
import type { FindOptions, Where } from '../persistence/data-store.interface.js';
|
|
12
|
+
import { getModelDescriptor } from '../persistence/model-metadata.registry.js';
|
|
13
|
+
import { getSyncAccessListener } from '../persistence/sync-access.registry.js';
|
|
12
14
|
import { UnsupportedCapabilityError } from '../persistence/unsupported-capability.error.js';
|
|
15
|
+
import { isDuplicateIdError } from '../persistence/mongoose-data-store.js';
|
|
16
|
+
import { includeTombstones, syncOfSchema } from '../persistence/mongo/sync-schema.plugin.js';
|
|
17
|
+
import type { SyncDescriptor } from '../persistence/model-descriptor.interface.js';
|
|
18
|
+
import {
|
|
19
|
+
assertClientId,
|
|
20
|
+
assertExpectedVersion,
|
|
21
|
+
insertWithServerId,
|
|
22
|
+
suppliedId,
|
|
23
|
+
VersionConflictError,
|
|
24
|
+
} from '../persistence/policies/sync.policy.js';
|
|
13
25
|
import { CoreModel } from '../models/core-model.model.js';
|
|
14
26
|
import { PaginationInfo } from '../models/pagination-info.model.js';
|
|
15
27
|
import { PlainObject } from '../types/plain-object.type.js';
|
|
@@ -74,14 +86,36 @@ export abstract class CrudService<
|
|
|
74
86
|
return this.process(
|
|
75
87
|
async (data) => {
|
|
76
88
|
const currentUserId = serviceOptions?.currentUser?.id;
|
|
89
|
+
const createId = serviceOptions?.createId;
|
|
77
90
|
if (this.usesNeutralStore) {
|
|
78
|
-
return this.
|
|
91
|
+
return this.syncChanged(
|
|
92
|
+
await this.dataStore.insert({
|
|
93
|
+
...data.input,
|
|
94
|
+
...(createId === undefined || createId === null ? {} : { id: createId }),
|
|
95
|
+
createdBy: currentUserId,
|
|
96
|
+
updatedBy: currentUserId,
|
|
97
|
+
} as any),
|
|
98
|
+
);
|
|
79
99
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
100
|
+
const build = (id?: string) =>
|
|
101
|
+
new (this.mainDbModel as MongooseModel<Document & Model>)({
|
|
102
|
+
...data.input,
|
|
103
|
+
...(id === undefined ? {} : { _id: id }),
|
|
104
|
+
createdBy: currentUserId,
|
|
105
|
+
updatedBy: currentUserId,
|
|
106
|
+
});
|
|
107
|
+
const sync = this.mongoSync;
|
|
108
|
+
if (!sync) {
|
|
109
|
+
return build(createId ?? undefined).save();
|
|
110
|
+
}
|
|
111
|
+
// A synchronized model keeps a client's id in its format and replaces a taken server id (plan §3.3, E6)
|
|
112
|
+
const supplied = createId ?? suppliedId(data.input ?? {});
|
|
113
|
+
if (supplied !== undefined && supplied !== null) {
|
|
114
|
+
return this.syncChanged(
|
|
115
|
+
await build(assertClientId(supplied, sync.idFormat, this.mainDbModel.modelName)).save(),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return this.syncChanged(await insertWithServerId(sync.idFormat, (id) => build(id).save(), isDuplicateIdError));
|
|
85
119
|
},
|
|
86
120
|
{ input, serviceOptions },
|
|
87
121
|
);
|
|
@@ -110,8 +144,8 @@ export abstract class CrudService<
|
|
|
110
144
|
/**
|
|
111
145
|
* Get distinct values of a property
|
|
112
146
|
*/
|
|
113
|
-
distinct(property: string): Promise<string[]> {
|
|
114
|
-
return this.dataStore.distinct(property) as Promise<string[]>;
|
|
147
|
+
distinct(property: string, serviceOptions?: ServiceOptions): Promise<string[]> {
|
|
148
|
+
return this.dataStore.distinct(property, undefined, tombstoneOption(serviceOptions)) as Promise<string[]>;
|
|
115
149
|
}
|
|
116
150
|
|
|
117
151
|
/**
|
|
@@ -119,13 +153,16 @@ export abstract class CrudService<
|
|
|
119
153
|
*/
|
|
120
154
|
async get(id: string, serviceOptions?: ServiceOptions): Promise<Model> {
|
|
121
155
|
if (this.usesNeutralStore) {
|
|
122
|
-
const record = await this.dataStore.findById(id,
|
|
156
|
+
const record = await this.dataStore.findById(id, {
|
|
157
|
+
...neutralSelect(serviceOptions?.select),
|
|
158
|
+
...tombstoneOption(serviceOptions),
|
|
159
|
+
});
|
|
123
160
|
if (!record) {
|
|
124
161
|
throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
|
|
125
162
|
}
|
|
126
163
|
return this.process(async () => record, { dbObject: record, serviceOptions });
|
|
127
164
|
}
|
|
128
|
-
const query = this.mainDbModel.findById(id);
|
|
165
|
+
const query = this.withTombstones(this.mainDbModel.findById(id), serviceOptions);
|
|
129
166
|
if (serviceOptions?.select) {
|
|
130
167
|
query.select(serviceOptions.select);
|
|
131
168
|
}
|
|
@@ -190,7 +227,10 @@ export abstract class CrudService<
|
|
|
190
227
|
}
|
|
191
228
|
|
|
192
229
|
// Find in DB
|
|
193
|
-
let find = this.
|
|
230
|
+
let find = this.withTombstones(
|
|
231
|
+
this.mainDbModel.find(filterQuery.filterQuery, null, filterQuery.queryOptions),
|
|
232
|
+
serviceOptions,
|
|
233
|
+
);
|
|
194
234
|
const collation = serviceOptions?.collation || ConfigService.get('mongoose.collation');
|
|
195
235
|
if (collation) {
|
|
196
236
|
find = find.collation(collation);
|
|
@@ -261,7 +301,7 @@ export abstract class CrudService<
|
|
|
261
301
|
const { options, where } = neutralQuery(filterQuery, serviceOptions);
|
|
262
302
|
const [items, totalCount] = await Promise.all([
|
|
263
303
|
this.dataStore.find(where, filter?.samples ? { ...options, sample: filter.samples } : options),
|
|
264
|
-
this.dataStore.count(where),
|
|
304
|
+
this.dataStore.count(where, tombstoneOption(serviceOptions)),
|
|
265
305
|
]);
|
|
266
306
|
const queryOptions = filterQuery.queryOptions || {};
|
|
267
307
|
return {
|
|
@@ -323,7 +363,12 @@ export abstract class CrudService<
|
|
|
323
363
|
// Find and process db items
|
|
324
364
|
const collation = serviceOptions?.collation || ConfigService.get('mongoose.collation');
|
|
325
365
|
const dbResult =
|
|
326
|
-
(
|
|
366
|
+
(
|
|
367
|
+
await this.withTombstones(
|
|
368
|
+
this.mainDbModel.aggregate(aggregation, collation ? { collation } : {}),
|
|
369
|
+
serviceOptions,
|
|
370
|
+
).exec()
|
|
371
|
+
)[0] || {};
|
|
327
372
|
dbResult.totalCount = dbResult.totalCount?.[0]?.total || 0;
|
|
328
373
|
dbResult.items = dbResult.items?.map((item) => this.mainDbModel.hydrate(item)) || [];
|
|
329
374
|
|
|
@@ -443,13 +488,17 @@ export abstract class CrudService<
|
|
|
443
488
|
return this.dataStore.findOne(where, {
|
|
444
489
|
collation: options.collation,
|
|
445
490
|
exclude: options.exclude,
|
|
491
|
+
includeTombstones: options.includeTombstones,
|
|
446
492
|
select: options.select,
|
|
447
493
|
sort: options.sort,
|
|
448
494
|
});
|
|
449
495
|
}
|
|
450
496
|
|
|
451
497
|
// Find in DB
|
|
452
|
-
let find = this.
|
|
498
|
+
let find = this.withTombstones(
|
|
499
|
+
this.mainDbModel.findOne(filterQuery.filterQuery, null, filterQuery.queryOptions),
|
|
500
|
+
serviceOptions,
|
|
501
|
+
);
|
|
453
502
|
const collation = serviceOptions?.collation || ConfigService.get('mongoose.collation');
|
|
454
503
|
if (collation) {
|
|
455
504
|
find = find.collation(collation);
|
|
@@ -657,18 +706,31 @@ export abstract class CrudService<
|
|
|
657
706
|
* Update item via ID
|
|
658
707
|
*/
|
|
659
708
|
async update(id: string, input: PlainObject<UpdateInput>, serviceOptions?: ServiceOptions): Promise<Model> {
|
|
709
|
+
const expectedVersion = this.expectedVersionOf(serviceOptions);
|
|
660
710
|
if (this.usesNeutralStore) {
|
|
661
711
|
const stored = await this.dataStore.findById(id);
|
|
662
712
|
if (!stored) {
|
|
663
713
|
throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
|
|
664
714
|
}
|
|
715
|
+
this.assertVersion(id, stored, expectedVersion);
|
|
665
716
|
return this.process(
|
|
666
717
|
async (data) => {
|
|
667
718
|
// The same write as on MongoDB: the stored record merged with the input, written back whole — minus
|
|
668
719
|
// the id, which never changes, and the update time, which the store stamps
|
|
720
|
+
const before = this.syncAccessSnapshot(stored);
|
|
669
721
|
const merged = mergePlain(stored, data.input, { updatedBy: serviceOptions?.currentUser?.id });
|
|
670
722
|
const { _id: _mongoId, id: _id, updatedAt: _updatedAt, ...patch } = merged as Record<string, any>;
|
|
671
|
-
|
|
723
|
+
const updated =
|
|
724
|
+
expectedVersion === undefined
|
|
725
|
+
? await this.dataStore.updateById(id, patch as any)
|
|
726
|
+
: // One statement with the version as its condition: of two changes made on the same version, one applies
|
|
727
|
+
((await this.dataStore.updateOne({ id, version: expectedVersion } as Where, patch as any)) ??
|
|
728
|
+
this.versionConflict(id));
|
|
729
|
+
if (updated) {
|
|
730
|
+
await this.reportSyncAccessMove(before, merged);
|
|
731
|
+
this.reportSyncChange(before, merged);
|
|
732
|
+
}
|
|
733
|
+
return updated;
|
|
672
734
|
},
|
|
673
735
|
{ dbObject: stored, input, serviceOptions },
|
|
674
736
|
);
|
|
@@ -677,15 +739,28 @@ export abstract class CrudService<
|
|
|
677
739
|
if (!dbObject) {
|
|
678
740
|
throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
|
|
679
741
|
}
|
|
742
|
+
this.assertVersion(id, dbObject, expectedVersion);
|
|
680
743
|
return this.process(
|
|
681
744
|
async (data) => {
|
|
682
745
|
const currentUserId = serviceOptions?.currentUser?.id;
|
|
746
|
+
const before = this.syncAccessSnapshot(dbObject);
|
|
683
747
|
const merged = mergePlain(dbObject, data.input, { updatedBy: currentUserId });
|
|
684
|
-
const query =
|
|
748
|
+
const query =
|
|
749
|
+
expectedVersion === undefined
|
|
750
|
+
? this.mainDbModel.findByIdAndUpdate(id, merged, { returnDocument: 'after' })
|
|
751
|
+
: this.mainDbModel.findOneAndUpdate({ _id: id, version: expectedVersion } as any, merged, {
|
|
752
|
+
returnDocument: 'after',
|
|
753
|
+
});
|
|
685
754
|
if (serviceOptions?.select) {
|
|
686
755
|
query.select(serviceOptions.select);
|
|
687
756
|
}
|
|
688
|
-
|
|
757
|
+
const updated = await query.exec();
|
|
758
|
+
if (!updated) {
|
|
759
|
+
return expectedVersion === undefined ? updated : this.versionConflict(id);
|
|
760
|
+
}
|
|
761
|
+
await this.reportSyncAccessMove(before, merged);
|
|
762
|
+
this.reportSyncChange(before, merged);
|
|
763
|
+
return updated;
|
|
689
764
|
},
|
|
690
765
|
{ dbObject, input, serviceOptions },
|
|
691
766
|
);
|
|
@@ -715,14 +790,17 @@ export abstract class CrudService<
|
|
|
715
790
|
* Delete item via ID
|
|
716
791
|
*/
|
|
717
792
|
async delete(id: string, serviceOptions?: ServiceOptions): Promise<Model> {
|
|
793
|
+
const expectedVersion = this.expectedVersionOf(serviceOptions);
|
|
718
794
|
if (this.usesNeutralStore) {
|
|
719
795
|
const stored = await this.dataStore.findById(id, neutralSelect(serviceOptions?.select));
|
|
720
796
|
if (!stored) {
|
|
721
797
|
throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
|
|
722
798
|
}
|
|
799
|
+
this.assertVersion(id, stored, expectedVersion);
|
|
723
800
|
return this.process(
|
|
724
801
|
async () => {
|
|
725
|
-
await this.
|
|
802
|
+
await this.removeOrTombstone(id, expectedVersion);
|
|
803
|
+
this.reportSyncChange(stored);
|
|
726
804
|
return stored;
|
|
727
805
|
},
|
|
728
806
|
{ dbObject: stored, serviceOptions },
|
|
@@ -736,15 +814,169 @@ export abstract class CrudService<
|
|
|
736
814
|
if (!dbObject) {
|
|
737
815
|
throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
|
|
738
816
|
}
|
|
817
|
+
this.assertVersion(id, dbObject, expectedVersion);
|
|
739
818
|
return this.process(
|
|
740
819
|
async () => {
|
|
741
|
-
|
|
820
|
+
if (this.mongoSync) {
|
|
821
|
+
// A synchronized model's delete leaves a tombstone, which is how clients learn of it
|
|
822
|
+
await this.removeOrTombstone(id, expectedVersion);
|
|
823
|
+
this.reportSyncChange(dbObject);
|
|
824
|
+
} else {
|
|
825
|
+
await this.mainDbModel.findByIdAndDelete(id).exec();
|
|
826
|
+
}
|
|
742
827
|
return dbObject;
|
|
743
828
|
},
|
|
744
829
|
{ dbObject, serviceOptions },
|
|
745
830
|
);
|
|
746
831
|
}
|
|
747
832
|
|
|
833
|
+
// -- synchronized models (`@PersistedModel({ sync })`) ------------------------------------------------------
|
|
834
|
+
|
|
835
|
+
/** The sync declaration of the main model on its MongoDB path, read from the Mongoose schema */
|
|
836
|
+
protected get mongoSync(): SyncDescriptor | undefined {
|
|
837
|
+
return this.usesNeutralStore ? undefined : syncOfSchema(this.mainDbModel?.schema);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Whether the main model is synchronized, on whichever database it lives */
|
|
841
|
+
protected get isSynchronized(): boolean {
|
|
842
|
+
return !!this.dataStore?.capabilities.changeOrder;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** A Mongoose query or aggregation that also sees tombstones when the caller asked (`includeTombstones`) */
|
|
846
|
+
protected withTombstones<Q extends object>(query: Q, serviceOptions?: ServiceOptions): Q {
|
|
847
|
+
return serviceOptions?.includeTombstones && this.mongoSync ? includeTombstones(query) : query;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** `serviceOptions.expectedVersion`, checked; only a synchronized model has a version */
|
|
851
|
+
protected expectedVersionOf(serviceOptions?: ServiceOptions): number | undefined {
|
|
852
|
+
if (serviceOptions?.expectedVersion === undefined || serviceOptions.expectedVersion === null) {
|
|
853
|
+
return undefined;
|
|
854
|
+
}
|
|
855
|
+
if (!this.isSynchronized) {
|
|
856
|
+
throw new UnsupportedCapabilityError(
|
|
857
|
+
`expectedVersion: ${this.mainModelConstructor?.name ?? this.dataStore?.modelName} is not a synchronized model ` +
|
|
858
|
+
'(@PersistedModel({ sync }))',
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
return assertExpectedVersion(serviceOptions.expectedVersion);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** The early answer to a conditional change on a record that already has another version */
|
|
865
|
+
protected assertVersion(id: string, stored: any, expectedVersion: number | undefined): void {
|
|
866
|
+
if (expectedVersion !== undefined && stored?.version !== expectedVersion) {
|
|
867
|
+
this.versionConflict(id);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
protected versionConflict(id: string): never {
|
|
872
|
+
throw new VersionConflictError(this.mainModelConstructor?.name ?? this.dataStore.modelName, id);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* The tenant and scope fields of a stored record as they are BEFORE a write — taken before `mergePlain()`, which
|
|
877
|
+
* merges the input INTO the stored object. `undefined` when nobody listens or the model is not synchronized.
|
|
878
|
+
*/
|
|
879
|
+
protected syncAccessSnapshot(stored: Record<string, any> | undefined): Record<string, any> | undefined {
|
|
880
|
+
if (!stored || !getSyncAccessListener() || !this.isSynchronized) {
|
|
881
|
+
return undefined;
|
|
882
|
+
}
|
|
883
|
+
const scope = getModelDescriptor(this.dataStore.modelName)?.sync?.scope ?? [];
|
|
884
|
+
return Object.fromEntries(
|
|
885
|
+
['tenantId', ...scope].map((field) => [field, Array.isArray(stored[field]) ? [...stored[field]] : stored[field]]),
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Tell the sync module that this write took a synchronized record away from users who could see it: moved to
|
|
891
|
+
* another tenant, or a scope field (`sync.scope`, e.g. `ownerId`) changed away from a user (offline-sync plan §3.8).
|
|
892
|
+
* Their clients then answer the next pull with a full resync and lose the local copy. A no-op without the module.
|
|
893
|
+
* A failure is logged, never thrown: the write it follows has happened.
|
|
894
|
+
*/
|
|
895
|
+
protected async reportSyncAccessMove(
|
|
896
|
+
stored: Record<string, any> | undefined,
|
|
897
|
+
written: Record<string, any>,
|
|
898
|
+
): Promise<void> {
|
|
899
|
+
const listener = getSyncAccessListener();
|
|
900
|
+
if (!listener || !stored) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const model = this.dataStore.modelName;
|
|
904
|
+
const ids = (value: unknown): string[] =>
|
|
905
|
+
value === undefined || value === null
|
|
906
|
+
? []
|
|
907
|
+
: (Array.isArray(value) ? value : [value]).flatMap((item) =>
|
|
908
|
+
item === undefined || item === null ? [] : [String(getStringIds(item))],
|
|
909
|
+
);
|
|
910
|
+
const after = (field: string) => (field in written ? written[field] : stored[field]);
|
|
911
|
+
const [tenantBefore] = ids(stored.tenantId);
|
|
912
|
+
const tenantChanged = !!tenantBefore && ids(after('tenantId'))[0] !== tenantBefore;
|
|
913
|
+
const usersLost = (getModelDescriptor(model)?.sync?.scope ?? []).flatMap((field) => {
|
|
914
|
+
const now = new Set(ids(after(field)));
|
|
915
|
+
return ids(stored[field]).filter((user) => !now.has(user));
|
|
916
|
+
});
|
|
917
|
+
if (!tenantChanged && !usersLost.length) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
try {
|
|
921
|
+
await listener.recordMoved({ model, tenantChanged, tenantId: tenantBefore, usersLost: [...new Set(usersLost)] });
|
|
922
|
+
} catch (error) {
|
|
923
|
+
Logger.error(
|
|
924
|
+
`Sync access epoch for a moved ${model} record not bumped — affected clients keep their copy until their next ` +
|
|
925
|
+
`full resync: ${(error as Error)?.message ?? error}`,
|
|
926
|
+
this.constructor.name,
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Tell the sync module that a write of a synchronized record succeeded, so the subscribers of its hints learn of it
|
|
933
|
+
* (offline-sync plan §3.9) — with the tenants the record was in before and after the write. A no-op without the
|
|
934
|
+
* module and on any other model; a failure is logged, never thrown: the write has happened.
|
|
935
|
+
*/
|
|
936
|
+
protected reportSyncChange(...records: (Record<string, any> | null | undefined)[]): void {
|
|
937
|
+
const listener = getSyncAccessListener();
|
|
938
|
+
if (!listener?.recordChanged || !this.isSynchronized) {
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const tenantIds = new Set<string>();
|
|
942
|
+
for (const record of records) {
|
|
943
|
+
const tenantId = record?.tenantId;
|
|
944
|
+
if (tenantId !== undefined && tenantId !== null && tenantId !== '') {
|
|
945
|
+
tenantIds.add(String(getStringIds(tenantId)));
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
listener.recordChanged({ model: this.dataStore.modelName, tenantIds: [...tenantIds] });
|
|
950
|
+
} catch (error) {
|
|
951
|
+
Logger.warn(
|
|
952
|
+
`Sync hint for a ${this.dataStore.modelName} write not sent — clients learn of it at their next pull: ` +
|
|
953
|
+
`${(error as Error)?.message ?? error}`,
|
|
954
|
+
this.constructor.name,
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** `reportSyncChange()` for a created record, which it answers */
|
|
960
|
+
protected syncChanged<R>(created: R): R {
|
|
961
|
+
this.reportSyncChange(created as any);
|
|
962
|
+
return created;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Delete through the store: a synchronized model's record becomes a tombstone (conditional on `expectedVersion`),
|
|
967
|
+
* any other model's is removed
|
|
968
|
+
*/
|
|
969
|
+
protected async removeOrTombstone(id: string, expectedVersion: number | undefined): Promise<void> {
|
|
970
|
+
if (expectedVersion === undefined) {
|
|
971
|
+
await this.dataStore.deleteById(id);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const removed = await this.dataStore.deleteOne({ id, version: expectedVersion } as Where);
|
|
975
|
+
if (!removed) {
|
|
976
|
+
this.versionConflict(id);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
748
980
|
/**
|
|
749
981
|
* Delete item via ID without checks or restrictions
|
|
750
982
|
* Warning: Disables the handling of rights and restrictions!
|
|
@@ -897,6 +1129,11 @@ function neutralSelect(select: unknown): Pick<FindOptions, 'exclude' | 'select'>
|
|
|
897
1129
|
};
|
|
898
1130
|
}
|
|
899
1131
|
|
|
1132
|
+
/** `includeTombstones` of the service options as a store option */
|
|
1133
|
+
function tombstoneOption(serviceOptions?: ServiceOptions): { includeTombstones?: boolean } {
|
|
1134
|
+
return serviceOptions?.includeTombstones ? { includeTombstones: true } : {};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
900
1137
|
/** The filter and query options of a CrudService call as the neutral filter and find options */
|
|
901
1138
|
function neutralQuery(
|
|
902
1139
|
query: { filterQuery?: unknown; queryOptions?: QueryOptions },
|
|
@@ -906,6 +1143,7 @@ function neutralQuery(
|
|
|
906
1143
|
const options: FindOptions = {
|
|
907
1144
|
collation: (serviceOptions?.collation || ConfigService.get('mongoose.collation')) as Record<string, unknown>,
|
|
908
1145
|
...neutralSelect(serviceOptions?.select),
|
|
1146
|
+
...tombstoneOption(serviceOptions),
|
|
909
1147
|
};
|
|
910
1148
|
const skip = queryOptions.skip ?? queryOptions.offset;
|
|
911
1149
|
const limit = queryOptions.limit ?? queryOptions.take;
|
|
@@ -1090,7 +1090,9 @@ built-in Kysely adapter on that source's application pool and shares ONE `users`
|
|
|
1090
1090
|
Its tables are snake_case (`session`, `account`, `verification`, `jwks`, `two_factor`, `passkey`), ids keep the
|
|
1091
1091
|
ObjectId format, and its migrations run at boot as the owner (`database.ownerUrl`) — without an owner connection the
|
|
1092
1092
|
boot stops while any are pending. The two MongoDB steps above are skipped there. Nothing is copied from MongoDB.
|
|
1093
|
-
|
|
1093
|
+
This holds on PostgreSQL and, since 12.1.1, on MySQL 8.4 and MariaDB 11.8: there Better-Auth's tables are created in the
|
|
1094
|
+
framework's collation `utf8mb4_bin` whatever the database's default, and Better-Auth's own column types apply
|
|
1095
|
+
(`timestamp(3)`, `varchar(36)` ids). Details: `.claude/rules/better-auth.md` §11.
|
|
1094
1096
|
|
|
1095
1097
|
---
|
|
1096
1098
|
|
|
@@ -1596,6 +1598,28 @@ export class MyService {
|
|
|
1596
1598
|
| `hasRole(roles)` | function | Check if user has any of the specified roles |
|
|
1597
1599
|
| `_authenticatedViaBetterAuth` | true | Marker for Better-Auth authenticated users |
|
|
1598
1600
|
|
|
1601
|
+
### Role and Verification Cache
|
|
1602
|
+
|
|
1603
|
+
`mapSessionUser()` reads `roles` and `verified` from the user record on every request, and keeps the answer for
|
|
1604
|
+
**15 seconds** per user (at most 500 users, about 100 bytes each). Under test runners and `lt dev` the cache is off,
|
|
1605
|
+
so a test that changes a user's roles directly in the database sees the change at once.
|
|
1606
|
+
|
|
1607
|
+
A change must not wait for that bound, so `invalidateUserCache(iamId)` drops the entry. `CoreUserService.setRoles()`
|
|
1608
|
+
and `CoreUserService.update()` (when the input touches `roles`, `verified` or `emailVerified`) call it themselves.
|
|
1609
|
+
Call it yourself when your project changes these fields in another way.
|
|
1610
|
+
|
|
1611
|
+
| Configuration | What `invalidateUserCache()` reaches |
|
|
1612
|
+
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1613
|
+
| without `redis` | the cache of this process |
|
|
1614
|
+
| with `redis` | the cache of **every replica**: the invalidation is broadcast on `<keyPrefix>:iam-user-cache:invalidate`, and each replica that receives it also re-checks the user's open GraphQL WebSockets at once |
|
|
1615
|
+
|
|
1616
|
+
Without the broadcast, a withdrawn admin role kept working for up to 15 seconds on every replica except the one that
|
|
1617
|
+
withdrew it, and an open subscription on another replica until its next periodic re-check
|
|
1618
|
+
(`graphQl.subscriptionRevalidationMs`). Each replica subscribes again after a Redis restart. A broadcast that cannot
|
|
1619
|
+
be sent is logged as a warning; the other replicas then follow within the 15 seconds.
|
|
1620
|
+
|
|
1621
|
+
A subclass may change the duration through the `protected` field `userCacheTtlMs` (`0` switches the cache off).
|
|
1622
|
+
|
|
1599
1623
|
## CoreModule.forRoot() Signatures
|
|
1600
1624
|
|
|
1601
1625
|
Two signatures are available for different use cases:
|