@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,1728 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BadRequestException,
|
|
3
|
+
ForbiddenException,
|
|
4
|
+
HttpException,
|
|
5
|
+
HttpStatus,
|
|
6
|
+
Injectable,
|
|
7
|
+
Logger,
|
|
8
|
+
NotFoundException,
|
|
9
|
+
OnApplicationBootstrap,
|
|
10
|
+
OnModuleDestroy,
|
|
11
|
+
Optional,
|
|
12
|
+
ServiceUnavailableException,
|
|
13
|
+
Type,
|
|
14
|
+
UnauthorizedException,
|
|
15
|
+
} from '@nestjs/common';
|
|
16
|
+
import { DiscoveryService, ModuleRef } from '@nestjs/core';
|
|
17
|
+
|
|
18
|
+
import { RoleEnum } from '../../common/enums/role.enum.js';
|
|
19
|
+
import { getStringIds } from '../../common/helpers/db.helper.js';
|
|
20
|
+
import {
|
|
21
|
+
ensureWsConnectionCurrent,
|
|
22
|
+
getWsConnection,
|
|
23
|
+
noteWsConnectionTenant,
|
|
24
|
+
registerWsConnection,
|
|
25
|
+
} from '../../common/helpers/graphql-ws-connection.helper.js';
|
|
26
|
+
import {
|
|
27
|
+
buildCorsConfig,
|
|
28
|
+
isCookiesEnabled,
|
|
29
|
+
isProductionLikeEnv,
|
|
30
|
+
resolveServerUrls,
|
|
31
|
+
} from '../../common/helpers/cookies.helper.js';
|
|
32
|
+
import type { ISync } from '../../common/interfaces/server-options.interface.js';
|
|
33
|
+
import type { DataStore } from '../../common/persistence/data-store.interface.js';
|
|
34
|
+
import { InjectStore } from '../../common/persistence/inject-store.decorator.js';
|
|
35
|
+
import { getModelDescriptor } from '../../common/persistence/model-metadata.registry.js';
|
|
36
|
+
import { isDuplicateIdError } from '../../common/persistence/mongoose-data-store.js';
|
|
37
|
+
import { getPersistedModelOptions } from '../../common/persistence/persisted-model.decorator.js';
|
|
38
|
+
import {
|
|
39
|
+
CRON_LOCK_COLLECTION,
|
|
40
|
+
cronMongoLocks,
|
|
41
|
+
defaultSqlLockStore,
|
|
42
|
+
} from '../../common/persistence/infrastructure-locks.js';
|
|
43
|
+
import type { LockStore } from '../../common/persistence/lock-store.interface.js';
|
|
44
|
+
import { getStoreToken } from '../../common/persistence/persistence.tokens.js';
|
|
45
|
+
import { isSyncId, VersionConflictError } from '../../common/persistence/policies/sync.policy.js';
|
|
46
|
+
import { allowedTenantIds, resolveTenantScope } from '../../common/persistence/policies/tenant.policy.js';
|
|
47
|
+
import { DuplicateKeyError, QueryCastError, UnsupportedQueryError } from '../../common/persistence/sql/sql-errors.js';
|
|
48
|
+
import {
|
|
49
|
+
getSyncAccessListener,
|
|
50
|
+
setSyncAccessListener,
|
|
51
|
+
type SyncAccessListener,
|
|
52
|
+
type SyncRecordChange,
|
|
53
|
+
type SyncRecordMove,
|
|
54
|
+
} from '../../common/persistence/sync-access.registry.js';
|
|
55
|
+
import {
|
|
56
|
+
type SyncConflictStrategy,
|
|
57
|
+
type SyncIdFormat,
|
|
58
|
+
type SyncOperationRoles,
|
|
59
|
+
SYNC_CLOCK_FIELD,
|
|
60
|
+
SYNC_SEQUENCE_FIELD,
|
|
61
|
+
} from '../../common/persistence/sync-model.js';
|
|
62
|
+
import { MapAndValidatePipe } from '../../common/pipes/map-and-validate.pipe.js';
|
|
63
|
+
import { ConfigService } from '../../common/services/config.service.js';
|
|
64
|
+
import { getCronJobsInfrastructure } from '../../common/services/core-cron-jobs.registry.js';
|
|
65
|
+
import { CoreRedisService } from '../../common/services/core-redis.service.js';
|
|
66
|
+
import { CrudService } from '../../common/services/crud.service.js';
|
|
67
|
+
import { InMemoryRateLimitStore, RateLimitStore, RedisRateLimitStore } from '../../common/services/rate-limit-store.js';
|
|
68
|
+
import { RequestContext } from '../../common/services/request-context.service.js';
|
|
69
|
+
import { ErrorCode } from '../error-code/error-codes.js';
|
|
70
|
+
import {
|
|
71
|
+
SYNC_CLAIM_TIMEOUT_MS,
|
|
72
|
+
SYNC_DEFAULTS,
|
|
73
|
+
SYNC_HINT_KEEPALIVE_MS,
|
|
74
|
+
SYNC_MUTATION_MODEL,
|
|
75
|
+
SYNC_PULL_LIMIT_CEILING,
|
|
76
|
+
SYNC_PURGE_BATCH,
|
|
77
|
+
SYNC_PURGE_INTERVAL_MS,
|
|
78
|
+
SYNC_REAPPLY_ATTEMPTS,
|
|
79
|
+
SYNC_STATE_MODEL,
|
|
80
|
+
} from './core-sync.constants.js';
|
|
81
|
+
import { CoreSyncHintService, type SyncHintEvent } from './core-sync-hints.service.js';
|
|
82
|
+
import {
|
|
83
|
+
decodeSyncCursor,
|
|
84
|
+
deriveCursorKey,
|
|
85
|
+
encodeSyncCursor,
|
|
86
|
+
stableHash,
|
|
87
|
+
type SyncCursorPosition,
|
|
88
|
+
type SyncCursorState,
|
|
89
|
+
} from './core-sync-cursor.js';
|
|
90
|
+
import { getSyncFieldStrategies, getSyncServiceOptions, type SyncFieldStrategy } from './core-sync.decorators.js';
|
|
91
|
+
import { CoreSyncMutationStore } from './core-sync-mutation.store.js';
|
|
92
|
+
import { CoreSyncStateStore } from './core-sync-state.store.js';
|
|
93
|
+
import type {
|
|
94
|
+
ISyncConflictContext,
|
|
95
|
+
ISyncPullResult,
|
|
96
|
+
ISyncPushItem,
|
|
97
|
+
ISyncPushResult,
|
|
98
|
+
ISyncRecord,
|
|
99
|
+
SyncConflictResolution,
|
|
100
|
+
SyncRejectCode,
|
|
101
|
+
SyncResyncReason,
|
|
102
|
+
} from './interfaces/core-sync.interfaces.js';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 410 Gone with `{ code: 'SYNC_RESYNC_REQUIRED', reason }` (protocol §8). `CoreSyncController` writes this body itself,
|
|
106
|
+
* so it reaches the client in the protocol's shape whatever exception filter the project registered.
|
|
107
|
+
*/
|
|
108
|
+
export class SyncResyncRequiredException extends HttpException {
|
|
109
|
+
constructor(readonly reason: SyncResyncReason) {
|
|
110
|
+
super(
|
|
111
|
+
{ code: 'SYNC_RESYNC_REQUIRED', message: ErrorCode.SYNC_RESYNC_REQUIRED, reason, statusCode: HttpStatus.GONE },
|
|
112
|
+
HttpStatus.GONE,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A model offered under `/sync`, resolved at bootstrap from its `@SyncService()` and `@PersistedModel({ sync })` */
|
|
118
|
+
export interface SyncModelRegistration {
|
|
119
|
+
createInput: Type<any>;
|
|
120
|
+
fieldStrategies: Readonly<Record<string, SyncFieldStrategy>>;
|
|
121
|
+
idFormat: SyncIdFormat;
|
|
122
|
+
model: Type<any>;
|
|
123
|
+
/** The registered model name — also its name in the path */
|
|
124
|
+
name: string;
|
|
125
|
+
order: 'clock' | 'seq';
|
|
126
|
+
/** Operation roles; an operation the model declares none for is open to ADMIN only */
|
|
127
|
+
roles: Readonly<Required<SyncOperationRoles>>;
|
|
128
|
+
scope: readonly string[];
|
|
129
|
+
service: CrudService<any>;
|
|
130
|
+
store: DataStore<any>;
|
|
131
|
+
strategy: SyncConflictStrategy;
|
|
132
|
+
tenantScoped: boolean;
|
|
133
|
+
updateInput: Type<any>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What one pull or push sees of the caller's access: compared with what the cursor was issued under */
|
|
137
|
+
interface SyncAccessState {
|
|
138
|
+
epochs: string;
|
|
139
|
+
fingerprint: string;
|
|
140
|
+
user: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const ADMIN_ONLY: readonly string[] = Object.freeze([RoleEnum.ADMIN]);
|
|
144
|
+
const OPERATIONS = ['create', 'delete', 'pull', 'update'] as const;
|
|
145
|
+
const MAX_MUTATION_ID_LENGTH = 128;
|
|
146
|
+
const MAX_ID_LENGTH = 64;
|
|
147
|
+
/** Fields of a stored record that are never part of `data` in a pull (protocol §5.2) */
|
|
148
|
+
const SYSTEM_RECORD_FIELDS = new Set([
|
|
149
|
+
'__v',
|
|
150
|
+
'_id',
|
|
151
|
+
'deletedAt',
|
|
152
|
+
'id',
|
|
153
|
+
'updatedAt',
|
|
154
|
+
'version',
|
|
155
|
+
SYNC_CLOCK_FIELD,
|
|
156
|
+
SYNC_SEQUENCE_FIELD,
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Offline sync (protocol `docs/offline-sync-protocol.md`, plan `.claude/plans/offline-sync/PLAN.md`): pull in pages
|
|
161
|
+
* along the store's change order, push item by item with idempotency, conflict strategies and client ids, resync when a
|
|
162
|
+
* cursor cannot be continued consistently.
|
|
163
|
+
*
|
|
164
|
+
* Every read and write goes through the model's own CrudService — its `process()` pipeline (operation roles, input
|
|
165
|
+
* `@Restricted`, output filtering), `securityCheck()` and the tenant policy, with row-level security on PostgreSQL.
|
|
166
|
+
* There is no lean path: the module never touches `native()` or `raw()` and never asks for the database's name; what
|
|
167
|
+
* differs between databases it reads from `store.capabilities`.
|
|
168
|
+
*
|
|
169
|
+
* Extend it (`CoreModule.forRoot(env, { sync: { service } })`) to change a decision — every step is `protected`.
|
|
170
|
+
*/
|
|
171
|
+
@Injectable()
|
|
172
|
+
export class CoreSyncService implements OnApplicationBootstrap, OnModuleDestroy, SyncAccessListener {
|
|
173
|
+
protected readonly logger = new Logger(CoreSyncService.name);
|
|
174
|
+
protected readonly models = new Map<string, SyncModelRegistration>();
|
|
175
|
+
protected readonly mutationStore: CoreSyncMutationStore;
|
|
176
|
+
protected readonly stateStore: CoreSyncStateStore;
|
|
177
|
+
protected cursorKey?: Buffer;
|
|
178
|
+
protected rateLimitStore?: RateLimitStore;
|
|
179
|
+
/** The purge timer (plan §3.7, E8); `undefined` while none is scheduled */
|
|
180
|
+
protected purgeTimer?: ReturnType<typeof setInterval>;
|
|
181
|
+
protected purgeRunning?: Promise<unknown>;
|
|
182
|
+
/** The owner of this replica's purge lease */
|
|
183
|
+
protected readonly purgeOwner = `sync-purge:${process.pid}:${Math.random().toString(36).slice(2)}`;
|
|
184
|
+
/** How often the purge runs; a subclass may change it */
|
|
185
|
+
protected purgeIntervalMs = SYNC_PURGE_INTERVAL_MS;
|
|
186
|
+
|
|
187
|
+
constructor(
|
|
188
|
+
@InjectStore(SYNC_MUTATION_MODEL) mutations: DataStore<any>,
|
|
189
|
+
@InjectStore(SYNC_STATE_MODEL) states: DataStore<any>,
|
|
190
|
+
protected readonly discoveryService: DiscoveryService,
|
|
191
|
+
protected readonly moduleRef: ModuleRef,
|
|
192
|
+
@Optional() protected readonly redisService?: CoreRedisService,
|
|
193
|
+
@Optional() protected readonly hintService?: CoreSyncHintService,
|
|
194
|
+
) {
|
|
195
|
+
const days = positiveNumber(this.config.retention?.mutationsDays, SYNC_DEFAULTS.mutationsDays);
|
|
196
|
+
this.mutationStore = new CoreSyncMutationStore(mutations, {
|
|
197
|
+
claimTimeoutMs: SYNC_CLAIM_TIMEOUT_MS,
|
|
198
|
+
retentionMs: days * 86_400_000,
|
|
199
|
+
});
|
|
200
|
+
this.stateStore = new CoreSyncStateStore(states);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The `sync` configuration (an object; `true` reads as `{}`) */
|
|
204
|
+
protected get config(): ISync {
|
|
205
|
+
const value = ConfigService.configFastButReadOnly?.sync;
|
|
206
|
+
return value && typeof value === 'object' ? value : {};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ===================================================================================================================
|
|
210
|
+
// Bootstrap
|
|
211
|
+
// ===================================================================================================================
|
|
212
|
+
|
|
213
|
+
async onApplicationBootstrap(): Promise<void> {
|
|
214
|
+
this.cursorKey = deriveCursorKey(this.resolveCursorSecret());
|
|
215
|
+
for (const registration of this.discoverModels()) {
|
|
216
|
+
if (this.models.has(registration.name)) {
|
|
217
|
+
throw new Error(`Sync: two services declare @SyncService() for the model "${registration.name}"`);
|
|
218
|
+
}
|
|
219
|
+
this.models.set(registration.name, registration);
|
|
220
|
+
}
|
|
221
|
+
setSyncAccessListener(this);
|
|
222
|
+
if (this.models.size) {
|
|
223
|
+
this.logger.log(`Offline sync for ${[...this.models.keys()].join(', ')}`);
|
|
224
|
+
this.schedulePurge();
|
|
225
|
+
} else {
|
|
226
|
+
this.logger.warn('Offline sync is configured, but no service declares @SyncService() — no model is offered');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async onModuleDestroy(): Promise<void> {
|
|
231
|
+
if (getSyncAccessListener() === this) {
|
|
232
|
+
setSyncAccessListener(undefined);
|
|
233
|
+
}
|
|
234
|
+
if (this.purgeTimer) {
|
|
235
|
+
clearInterval(this.purgeTimer);
|
|
236
|
+
this.purgeTimer = undefined;
|
|
237
|
+
}
|
|
238
|
+
await this.purgeRunning?.catch(() => undefined);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The registered models, by name */
|
|
242
|
+
getModels(): ReadonlyMap<string, SyncModelRegistration> {
|
|
243
|
+
return this.models;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The cursor secret: `sync.cursorSecret`, else `SECRETS_ENCRYPTION_KEY`. Refuses to start without one in production
|
|
248
|
+
* and staging — a known key would let anybody forge cursors; elsewhere a development default is used, with a warning.
|
|
249
|
+
*/
|
|
250
|
+
protected resolveCursorSecret(): string {
|
|
251
|
+
const secret = this.config.cursorSecret || process.env.SECRETS_ENCRYPTION_KEY;
|
|
252
|
+
if (secret) {
|
|
253
|
+
return secret;
|
|
254
|
+
}
|
|
255
|
+
const env = ConfigService.configFastButReadOnly?.env;
|
|
256
|
+
if (isProductionLikeEnv(env)) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`Sync: no sync.cursorSecret / SECRETS_ENCRYPTION_KEY set in "${env}" — pull cursors would be signed with a ` +
|
|
259
|
+
'known key',
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
this.logger.warn(
|
|
263
|
+
'No sync.cursorSecret / SECRETS_ENCRYPTION_KEY set — pull cursors are signed with an insecure development ' +
|
|
264
|
+
'default. Never run this in production (the boot refuses it there).',
|
|
265
|
+
);
|
|
266
|
+
return 'lt-nest-server-sync-cursor-dev-only-insecure-default';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Every provider carrying `@SyncService()`, checked and resolved */
|
|
270
|
+
protected discoverModels(): SyncModelRegistration[] {
|
|
271
|
+
const seen = new Set<unknown>();
|
|
272
|
+
const registrations: SyncModelRegistration[] = [];
|
|
273
|
+
for (const wrapper of this.discoveryService.getProviders()) {
|
|
274
|
+
const instance = wrapper.instance;
|
|
275
|
+
const options = wrapper.metatype ? getSyncServiceOptions(wrapper.metatype as Function) : undefined;
|
|
276
|
+
if (!options || !instance || seen.has(instance)) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
seen.add(instance);
|
|
280
|
+
registrations.push(this.resolveRegistration(instance, options));
|
|
281
|
+
}
|
|
282
|
+
return registrations;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
protected resolveRegistration(
|
|
286
|
+
service: unknown,
|
|
287
|
+
options: NonNullable<ReturnType<typeof getSyncServiceOptions>>,
|
|
288
|
+
): SyncModelRegistration {
|
|
289
|
+
const name = options.modelName ?? options.model?.name;
|
|
290
|
+
const label = `@SyncService() on ${(service as object)?.constructor?.name}`;
|
|
291
|
+
if (!(service instanceof CrudService)) {
|
|
292
|
+
throw new Error(`Sync: ${label} — the service must extend CrudService`);
|
|
293
|
+
}
|
|
294
|
+
if (!name || !options.createInput || !options.updateInput) {
|
|
295
|
+
throw new Error(`Sync: ${label} needs model, createInput and updateInput`);
|
|
296
|
+
}
|
|
297
|
+
let store: DataStore<any>;
|
|
298
|
+
try {
|
|
299
|
+
store = this.moduleRef.get(getStoreToken(name), { strict: false });
|
|
300
|
+
} catch {
|
|
301
|
+
throw new Error(`Sync: ${label} — the model "${name}" is not registered with CorePersistenceModule.forFeature()`);
|
|
302
|
+
}
|
|
303
|
+
const order = store?.capabilities?.changeOrder;
|
|
304
|
+
if (!order) {
|
|
305
|
+
throw new Error(`Sync: ${label} — the model "${name}" is not synchronized (@PersistedModel({ sync }))`);
|
|
306
|
+
}
|
|
307
|
+
if (order === 'clock' && typeof store.changeClock !== 'function') {
|
|
308
|
+
throw new Error(`Sync: the store of "${name}" orders changes by clock but offers no changeClock()`);
|
|
309
|
+
}
|
|
310
|
+
const declared = getPersistedModelOptions(options.model)?.sync;
|
|
311
|
+
const declaration = declared && typeof declared === 'object' ? declared : {};
|
|
312
|
+
const strategy = declaration.strategy ?? 'reject';
|
|
313
|
+
if (strategy === 'custom' && typeof (service as any).resolveSyncConflict !== 'function') {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`Sync: "${name}" declares strategy 'custom', but ${service.constructor.name} has no resolveSyncConflict()`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const missing = OPERATIONS.filter((operation) => !declaration.roles?.[operation]?.length);
|
|
319
|
+
if (missing.length) {
|
|
320
|
+
this.logger.warn(
|
|
321
|
+
`Sync: "${name}" declares no sync roles for ${missing.join(', ')} — open to ADMIN only. Declare them in ` +
|
|
322
|
+
'@PersistedModel({ sync: { roles } })',
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const roles = Object.freeze(
|
|
326
|
+
Object.fromEntries(
|
|
327
|
+
OPERATIONS.map((operation) => [operation, Object.freeze([...(declaration.roles?.[operation] ?? ADMIN_ONLY)])]),
|
|
328
|
+
),
|
|
329
|
+
) as unknown as Readonly<Required<SyncOperationRoles>>;
|
|
330
|
+
const descriptor = getModelDescriptor(name);
|
|
331
|
+
return {
|
|
332
|
+
createInput: options.createInput,
|
|
333
|
+
fieldStrategies: Object.freeze(getSyncFieldStrategies(options.model)),
|
|
334
|
+
idFormat: store.capabilities.idType,
|
|
335
|
+
model: options.model,
|
|
336
|
+
name,
|
|
337
|
+
order,
|
|
338
|
+
roles,
|
|
339
|
+
scope: descriptor?.sync?.scope ?? [],
|
|
340
|
+
service,
|
|
341
|
+
store,
|
|
342
|
+
strategy,
|
|
343
|
+
tenantScoped: !!descriptor?.tenantScoped,
|
|
344
|
+
updateInput: options.updateInput,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The registration of a model, or 404 — an unknown name and a model not offered for sync answer the same */
|
|
349
|
+
protected registration(model: string): SyncModelRegistration {
|
|
350
|
+
const registration = typeof model === 'string' ? this.models.get(model) : undefined;
|
|
351
|
+
if (!registration) {
|
|
352
|
+
throw new NotFoundException(ErrorCode.SYNC_MODEL_NOT_FOUND);
|
|
353
|
+
}
|
|
354
|
+
return registration;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ===================================================================================================================
|
|
358
|
+
// Pull (protocol §5)
|
|
359
|
+
// ===================================================================================================================
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* One page of changes after the cursor, in the store's change order — tombstones included, through the model's
|
|
363
|
+
* CrudService with the pull roles, every record through `securityCheck()`. A record the caller may not see still moves
|
|
364
|
+
* the cursor on.
|
|
365
|
+
*/
|
|
366
|
+
async pull(model: string, query: { cursor?: unknown; limit?: unknown }, currentUser: any): Promise<ISyncPullResult> {
|
|
367
|
+
const registration = this.registration(model);
|
|
368
|
+
await registration.service.checkRights(undefined, currentUser, { roles: [...registration.roles.pull] });
|
|
369
|
+
const limit = this.pullLimit(query.limit);
|
|
370
|
+
const access = await this.accessState(registration, currentUser);
|
|
371
|
+
const state =
|
|
372
|
+
query.cursor === undefined || query.cursor === ''
|
|
373
|
+
? undefined
|
|
374
|
+
: await this.continuedCursor(registration, query.cursor, access);
|
|
375
|
+
const clock = await this.readClock(registration);
|
|
376
|
+
|
|
377
|
+
const runStart = state?.runStart ?? clock.now;
|
|
378
|
+
const since = state ? state.since : runStart;
|
|
379
|
+
const position = state?.position ?? initialPosition(registration.order);
|
|
380
|
+
const rows = await this.readPositions(registration, position, limit + 1);
|
|
381
|
+
|
|
382
|
+
const page = rows.slice(0, limit);
|
|
383
|
+
const contents = await this.readContents(
|
|
384
|
+
registration,
|
|
385
|
+
page.map((row) => String(row.id)),
|
|
386
|
+
currentUser,
|
|
387
|
+
);
|
|
388
|
+
const maxBytes = positiveNumber(this.config.pull?.maxBytes, SYNC_DEFAULTS.maxBytes);
|
|
389
|
+
const items: ISyncRecord[] = [];
|
|
390
|
+
let bytes = 2;
|
|
391
|
+
let consumed = 0;
|
|
392
|
+
let last = position;
|
|
393
|
+
for (const raw of page) {
|
|
394
|
+
const record = contents.get(String(raw.id));
|
|
395
|
+
// Not in the content read: hidden from the caller (roles, securityCheck), or removed in between
|
|
396
|
+
const item = record ? this.toSyncRecord(registration, record, raw, currentUser) : undefined;
|
|
397
|
+
if (item) {
|
|
398
|
+
const size = Buffer.byteLength(JSON.stringify(item)) + 1;
|
|
399
|
+
if (items.length && bytes + size > maxBytes) {
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
items.push(item);
|
|
403
|
+
bytes += size;
|
|
404
|
+
}
|
|
405
|
+
consumed++;
|
|
406
|
+
last = this.positionOf(registration, raw);
|
|
407
|
+
}
|
|
408
|
+
const hasMore = consumed < rows.length;
|
|
409
|
+
const next: Omit<SyncCursorState, 'epochs' | 'fingerprint' | 'model' | 'user'> = hasMore
|
|
410
|
+
? { position: last, runStart, since }
|
|
411
|
+
: // The run ended: the next one starts from here (SQL), or from W below this run's start (clock, plan §3.4)
|
|
412
|
+
{
|
|
413
|
+
position: registration.order === 'seq' ? last : { at: runStart - clock.windowMs, id: '' },
|
|
414
|
+
since: runStart,
|
|
415
|
+
};
|
|
416
|
+
const cursor = encodeSyncCursor(
|
|
417
|
+
{ ...next, epochs: access.epochs, fingerprint: access.fingerprint, model: registration.name, user: access.user },
|
|
418
|
+
this.cursorKey,
|
|
419
|
+
);
|
|
420
|
+
return { cursor, hasMore, items };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The cursor's state, checked against the model, the caller, the access and the horizon — or a 410 resync */
|
|
424
|
+
protected async continuedCursor(
|
|
425
|
+
registration: SyncModelRegistration,
|
|
426
|
+
cursor: unknown,
|
|
427
|
+
access: SyncAccessState,
|
|
428
|
+
): Promise<SyncCursorState> {
|
|
429
|
+
const state = decodeSyncCursor(cursor, this.cursorKey);
|
|
430
|
+
if (
|
|
431
|
+
!state ||
|
|
432
|
+
state.model !== registration.name ||
|
|
433
|
+
state.user !== access.user ||
|
|
434
|
+
'seq' in state.position !== (registration.order === 'seq')
|
|
435
|
+
) {
|
|
436
|
+
// Another model, another user, another change order (a model moved between databases), tampered with
|
|
437
|
+
this.resync('invalid');
|
|
438
|
+
}
|
|
439
|
+
if (state.fingerprint !== access.fingerprint || state.epochs !== access.epochs) {
|
|
440
|
+
this.resync('access');
|
|
441
|
+
}
|
|
442
|
+
const horizon = await this.stateStore.horizon(registration.name);
|
|
443
|
+
if (horizon && state.since < horizon.getTime()) {
|
|
444
|
+
this.resync('horizon');
|
|
445
|
+
}
|
|
446
|
+
return state;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The pull's `limit`: absent → the default; otherwise an integer from 1 to the configured maximum */
|
|
450
|
+
protected pullLimit(value: unknown): number {
|
|
451
|
+
const max = Math.min(positiveNumber(this.config.pull?.maxLimit, SYNC_DEFAULTS.maxLimit), SYNC_PULL_LIMIT_CEILING);
|
|
452
|
+
const fallback = Math.min(positiveNumber(this.config.pull?.defaultLimit, SYNC_DEFAULTS.pullLimit), max);
|
|
453
|
+
if (value === undefined || value === '') {
|
|
454
|
+
return fallback;
|
|
455
|
+
}
|
|
456
|
+
const limit =
|
|
457
|
+
typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN;
|
|
458
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > max) {
|
|
459
|
+
throw new BadRequestException(`${ErrorCode.SYNC_INVALID_REQUEST}: limit must be an integer from 1 to ${max}`);
|
|
460
|
+
}
|
|
461
|
+
return limit;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The database's clock and W (clock order), or this process's clock (sequence order: only the horizon reads it) */
|
|
465
|
+
protected async readClock(registration: SyncModelRegistration): Promise<{ now: number; windowMs: number }> {
|
|
466
|
+
if (registration.order !== 'clock') {
|
|
467
|
+
return { now: Date.now(), windowMs: 0 };
|
|
468
|
+
}
|
|
469
|
+
const clock = await this.storeCall(() => registration.store.changeClock());
|
|
470
|
+
const configured = this.config.pull?.windowSeconds;
|
|
471
|
+
const windowMs = typeof configured === 'number' && configured >= 0 ? configured * 1000 : clock.windowMs;
|
|
472
|
+
return { now: clock.now.getTime(), windowMs };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The next records after `position` in change order, tombstones included — their system fields only (id, version,
|
|
477
|
+
* deletion, update time, change position, tenant), read through the store: the same tenant policy and row-level
|
|
478
|
+
* security as every read. This read decides the page and the cursor; the content follows in a second read
|
|
479
|
+
* (`readContents()`). In that order a version or position is never newer than the content it labels — a record changed
|
|
480
|
+
* between the two reads is labelled with its older version and comes again at its new position, where reading the
|
|
481
|
+
* positions second let the cursor jump past the records after it.
|
|
482
|
+
*
|
|
483
|
+
* Without `X-Tenant-Id` a tenant-scoped model is read once per tenant of the caller, with the tenant as an equality,
|
|
484
|
+
* and the pages are merged by position: the change-order index is led by the tenant, and only an equality lets it
|
|
485
|
+
* deliver the order — a membership list (`tenantId IN (…)`) leaves every page to read and sort the whole remaining
|
|
486
|
+
* scope on PostgreSQL, MySQL and MariaDB.
|
|
487
|
+
*/
|
|
488
|
+
protected async readPositions(
|
|
489
|
+
registration: SyncModelRegistration,
|
|
490
|
+
position: SyncCursorPosition,
|
|
491
|
+
count: number,
|
|
492
|
+
): Promise<any[]> {
|
|
493
|
+
let where: Record<string, unknown>;
|
|
494
|
+
let sort: Record<string, 1>;
|
|
495
|
+
if ('seq' in position) {
|
|
496
|
+
where = { [SYNC_SEQUENCE_FIELD]: { $gt: position.seq } };
|
|
497
|
+
sort = { [SYNC_SEQUENCE_FIELD]: 1 };
|
|
498
|
+
} else {
|
|
499
|
+
const at = new Date(position.at);
|
|
500
|
+
where = position.id
|
|
501
|
+
? { $or: [{ [SYNC_CLOCK_FIELD]: { $gt: at } }, { id: { $gt: position.id }, [SYNC_CLOCK_FIELD]: at }] }
|
|
502
|
+
: { [SYNC_CLOCK_FIELD]: { $gte: at } };
|
|
503
|
+
sort = { [SYNC_CLOCK_FIELD]: 1, id: 1 };
|
|
504
|
+
}
|
|
505
|
+
const options = { includeTombstones: true, limit: count, select: this.systemFields(registration), sort };
|
|
506
|
+
const scope = registration.tenantScoped ? resolveTenantScope() : undefined;
|
|
507
|
+
const tenants = scope?.kind === 'tenants' ? [...new Set(scope.tenantIds.map(String))] : [];
|
|
508
|
+
if (!tenants.length) {
|
|
509
|
+
return this.storeCall(() => registration.store.find(where, options));
|
|
510
|
+
}
|
|
511
|
+
const pages = await Promise.all(
|
|
512
|
+
// The equality inside `$and`: MongoDB's tenant plugin writes its own `tenantId` condition onto the same key with
|
|
513
|
+
// `where()`, which replaces a top-level one — every per-tenant read would return every tenant
|
|
514
|
+
tenants.map((tenantId) =>
|
|
515
|
+
this.storeCall(() => registration.store.find({ $and: [where, { tenantId }] }, options)),
|
|
516
|
+
),
|
|
517
|
+
);
|
|
518
|
+
return pages
|
|
519
|
+
.flat()
|
|
520
|
+
.sort((a, b) => this.comparePositions(registration, a, b))
|
|
521
|
+
.slice(0, count);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** The change order of two records' system fields: the sequence, or the server stamp and then the id */
|
|
525
|
+
protected comparePositions(registration: SyncModelRegistration, a: any, b: any): number {
|
|
526
|
+
if (registration.order === 'seq') {
|
|
527
|
+
return Number(a?.[SYNC_SEQUENCE_FIELD]) - Number(b?.[SYNC_SEQUENCE_FIELD]);
|
|
528
|
+
}
|
|
529
|
+
const at = new Date(a?.[SYNC_CLOCK_FIELD]).getTime() - new Date(b?.[SYNC_CLOCK_FIELD]).getTime();
|
|
530
|
+
if (at) {
|
|
531
|
+
return at;
|
|
532
|
+
}
|
|
533
|
+
// ObjectId hex and canonical UUID strings sort like their bytes, as MongoDB sorts `_id`
|
|
534
|
+
const left = String(a?.id);
|
|
535
|
+
const right = String(b?.id);
|
|
536
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* The content of the given records as the caller may see it, through the model's CrudService with the pull roles
|
|
541
|
+
* (`process()`, field restrictions, tenant policy, RLS); keyed by id. A record the caller may not see is not in it.
|
|
542
|
+
*/
|
|
543
|
+
protected async readContents(
|
|
544
|
+
registration: SyncModelRegistration,
|
|
545
|
+
ids: string[],
|
|
546
|
+
currentUser: any,
|
|
547
|
+
): Promise<Map<string, any>> {
|
|
548
|
+
const result = new Map<string, any>();
|
|
549
|
+
if (!ids.length) {
|
|
550
|
+
return result;
|
|
551
|
+
}
|
|
552
|
+
const records = await this.storeCall(() =>
|
|
553
|
+
registration.service.find(
|
|
554
|
+
{ filterQuery: { _id: { $in: ids } } },
|
|
555
|
+
{ currentUser, includeTombstones: true, roles: [...registration.roles.pull] },
|
|
556
|
+
),
|
|
557
|
+
);
|
|
558
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
559
|
+
result.set(String(recordId(record)), record);
|
|
560
|
+
}
|
|
561
|
+
return result;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The system fields of records — version, deletion, update time, change position, tenant — read through the store
|
|
566
|
+
* (the same tenant policy and row-level security as the CrudService read). They come from here rather than from the
|
|
567
|
+
* CrudService output, because a model restricted at class level (`@Restricted(RoleEnum.ADMIN)`, the usual pattern)
|
|
568
|
+
* strips every field it does not declare from that output, and no model declares these: they are the store's.
|
|
569
|
+
*/
|
|
570
|
+
protected async readSystemFields(registration: SyncModelRegistration, ids: string[]): Promise<Map<string, any>> {
|
|
571
|
+
const result = new Map<string, any>();
|
|
572
|
+
if (!ids.length) {
|
|
573
|
+
return result;
|
|
574
|
+
}
|
|
575
|
+
const rows = await this.storeCall(() =>
|
|
576
|
+
registration.store.find(
|
|
577
|
+
{ id: { $in: ids } },
|
|
578
|
+
{ includeTombstones: true, select: this.systemFields(registration) },
|
|
579
|
+
),
|
|
580
|
+
);
|
|
581
|
+
for (const row of rows) {
|
|
582
|
+
result.set(String(row.id), row);
|
|
583
|
+
}
|
|
584
|
+
return result;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The system fields a sync record is built from; the id is always returned */
|
|
588
|
+
protected systemFields(registration: SyncModelRegistration): string[] {
|
|
589
|
+
const fields = getModelDescriptor(registration.name)?.fields ?? {};
|
|
590
|
+
return [
|
|
591
|
+
'deletedAt',
|
|
592
|
+
'version',
|
|
593
|
+
registration.order === 'seq' ? SYNC_SEQUENCE_FIELD : SYNC_CLOCK_FIELD,
|
|
594
|
+
...['tenantId', 'updatedAt'].filter((field) => field in fields),
|
|
595
|
+
];
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Where a record sits in the change order (from its system fields, which `securityCheck()` cannot clear) */
|
|
599
|
+
protected positionOf(registration: SyncModelRegistration, raw: any): SyncCursorPosition {
|
|
600
|
+
if (registration.order === 'seq') {
|
|
601
|
+
return { seq: Number(raw?.[SYNC_SEQUENCE_FIELD]) };
|
|
602
|
+
}
|
|
603
|
+
return { at: new Date(raw?.[SYNC_CLOCK_FIELD]).getTime(), id: String(raw?.id) };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* A record as the protocol carries it, after the model's `securityCheck()` — `undefined` when the check hides it.
|
|
608
|
+
* `record` is the CrudService output (the content the caller may see), `raw` its system fields. A tombstone keeps
|
|
609
|
+
* only its id, version, times, `tenantId` and the scope fields the caller may see (protocol §2).
|
|
610
|
+
*/
|
|
611
|
+
protected toSyncRecord(
|
|
612
|
+
registration: SyncModelRegistration,
|
|
613
|
+
record: any,
|
|
614
|
+
raw: any,
|
|
615
|
+
currentUser: any,
|
|
616
|
+
): ISyncRecord | undefined {
|
|
617
|
+
if (!record || !raw) {
|
|
618
|
+
return undefined;
|
|
619
|
+
}
|
|
620
|
+
const checked = typeof record.securityCheck === 'function' ? record.securityCheck(currentUser, false) : record;
|
|
621
|
+
if (!checked) {
|
|
622
|
+
return undefined;
|
|
623
|
+
}
|
|
624
|
+
const plain: Record<string, any> = { ...checked };
|
|
625
|
+
const base: ISyncRecord = {
|
|
626
|
+
deletedAt: raw.deletedAt ? new Date(raw.deletedAt) : null,
|
|
627
|
+
id: String(raw.id),
|
|
628
|
+
...(raw.updatedAt ? { updatedAt: new Date(raw.updatedAt) } : {}),
|
|
629
|
+
version: Number(raw.version),
|
|
630
|
+
};
|
|
631
|
+
if (base.deletedAt) {
|
|
632
|
+
if (registration.tenantScoped && raw.tenantId !== undefined && raw.tenantId !== null) {
|
|
633
|
+
base.tenantId = String(getStringIds(raw.tenantId));
|
|
634
|
+
}
|
|
635
|
+
for (const field of registration.scope) {
|
|
636
|
+
if (plain[field] !== undefined) {
|
|
637
|
+
base[field] = getStringIds(plain[field]);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return base;
|
|
641
|
+
}
|
|
642
|
+
const data: Record<string, unknown> = {};
|
|
643
|
+
for (const [key, value] of Object.entries(plain)) {
|
|
644
|
+
if (!SYSTEM_RECORD_FIELDS.has(key) && value !== undefined && typeof value !== 'function') {
|
|
645
|
+
data[key] = value;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return { ...base, data };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* What the caller's access looks like now: a fingerprint of everything that decides which records the caller sees
|
|
653
|
+
* (user, tenant scope, global roles, membership role, verification state) and a hash of the access epochs of that
|
|
654
|
+
* scope. A cursor issued under other values answers 410 `access` (plan §3.8).
|
|
655
|
+
*/
|
|
656
|
+
protected async accessState(registration: SyncModelRegistration, currentUser: any): Promise<SyncAccessState> {
|
|
657
|
+
const context = RequestContext.get();
|
|
658
|
+
const user = String(getStringIds(currentUser?.id));
|
|
659
|
+
const scope = context?.tenantId
|
|
660
|
+
? [context.tenantId]
|
|
661
|
+
: context?.tenantIds
|
|
662
|
+
? [...context.tenantIds].map(String).sort()
|
|
663
|
+
: context?.isAdminBypass
|
|
664
|
+
? ['*']
|
|
665
|
+
: [];
|
|
666
|
+
const fingerprint = stableHash([
|
|
667
|
+
user,
|
|
668
|
+
scope,
|
|
669
|
+
[...(Array.isArray(currentUser?.roles) ? currentUser.roles : [])].map(String).sort(),
|
|
670
|
+
context?.tenantRole ?? null,
|
|
671
|
+
!!(currentUser?.verified || currentUser?.verifiedAt || currentUser?.emailVerified),
|
|
672
|
+
]);
|
|
673
|
+
const tenants = registration.tenantScoped && scope.length ? scope : ['-'];
|
|
674
|
+
const keys = tenants.flatMap((tenant) => [
|
|
675
|
+
CoreSyncStateStore.tenantEpochKey(registration.name, tenant),
|
|
676
|
+
...(registration.scope.length ? [CoreSyncStateStore.userEpochKey(registration.name, tenant, user)] : []),
|
|
677
|
+
]);
|
|
678
|
+
const epochs = await this.storeCall(() => this.stateStore.epochs(keys));
|
|
679
|
+
return { epochs: stableHash([...epochs.entries()].sort(([a], [b]) => a.localeCompare(b))), fingerprint, user };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ===================================================================================================================
|
|
683
|
+
// Push (protocol §6)
|
|
684
|
+
// ===================================================================================================================
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Apply the pushed items in the order sent, each on its own (protocol §6.3) — one result per item. A malformed body
|
|
688
|
+
* is refused as a whole before any item is applied.
|
|
689
|
+
*/
|
|
690
|
+
async push(model: string, body: unknown, currentUser: any): Promise<{ results: ISyncPushResult[] }> {
|
|
691
|
+
const registration = this.registration(model);
|
|
692
|
+
const items = this.pushItems(body);
|
|
693
|
+
const results: ISyncPushResult[] = [];
|
|
694
|
+
for (const item of items) {
|
|
695
|
+
results.push(await this.pushItem(registration, item, currentUser));
|
|
696
|
+
}
|
|
697
|
+
return { results };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** The items of a push body, checked for shape; anything malformed answers 400 for the whole request */
|
|
701
|
+
protected pushItems(body: unknown): ISyncPushItem[] {
|
|
702
|
+
const invalid = (reason: string) => new BadRequestException(`${ErrorCode.SYNC_INVALID_REQUEST}: ${reason}`);
|
|
703
|
+
const items = (body as { items?: unknown })?.items;
|
|
704
|
+
if (!Array.isArray(items)) {
|
|
705
|
+
throw invalid('the body must be { "items": [ … ] }');
|
|
706
|
+
}
|
|
707
|
+
const max = positiveNumber(this.config.push?.maxItems, SYNC_DEFAULTS.maxItems);
|
|
708
|
+
if (items.length > max) {
|
|
709
|
+
throw invalid(`at most ${max} items per push`);
|
|
710
|
+
}
|
|
711
|
+
return items.map((item, index) => {
|
|
712
|
+
const at = `items[${index}]`;
|
|
713
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
714
|
+
throw invalid(`${at} must be an object`);
|
|
715
|
+
}
|
|
716
|
+
const { baseVersion, data, fields, id, mutationId, op } = item as Record<string, unknown>;
|
|
717
|
+
if (typeof mutationId !== 'string' || !mutationId || mutationId.length > MAX_MUTATION_ID_LENGTH) {
|
|
718
|
+
throw invalid(`${at}.mutationId must be a string of 1 to ${MAX_MUTATION_ID_LENGTH} characters`);
|
|
719
|
+
}
|
|
720
|
+
if (op !== 'upsert' && op !== 'delete') {
|
|
721
|
+
throw invalid(`${at}.op must be "upsert" or "delete"`);
|
|
722
|
+
}
|
|
723
|
+
if (typeof id !== 'string' || !id || id.length > MAX_ID_LENGTH) {
|
|
724
|
+
throw invalid(`${at}.id must be a string of 1 to ${MAX_ID_LENGTH} characters`);
|
|
725
|
+
}
|
|
726
|
+
if (baseVersion !== undefined && (!Number.isInteger(baseVersion) || (baseVersion as number) < 1)) {
|
|
727
|
+
throw invalid(`${at}.baseVersion must be a positive integer`);
|
|
728
|
+
}
|
|
729
|
+
if (op === 'upsert' && (!data || typeof data !== 'object' || Array.isArray(data))) {
|
|
730
|
+
throw invalid(`${at}.data must be an object`);
|
|
731
|
+
}
|
|
732
|
+
if (fields !== undefined && (!Array.isArray(fields) || fields.some((field) => typeof field !== 'string'))) {
|
|
733
|
+
throw invalid(`${at}.fields must be a list of field names`);
|
|
734
|
+
}
|
|
735
|
+
return {
|
|
736
|
+
...(baseVersion === undefined ? {} : { baseVersion: baseVersion as number }),
|
|
737
|
+
...(op === 'upsert' ? { data: data as Record<string, unknown> } : {}),
|
|
738
|
+
...(fields === undefined ? {} : { fields: fields as string[] }),
|
|
739
|
+
id,
|
|
740
|
+
mutationId,
|
|
741
|
+
op,
|
|
742
|
+
};
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* One item: claim its mutation, apply it, record the outcome. A repeat is answered with the recorded outcome, a
|
|
748
|
+
* claim still being written with `retry`; a transient failure releases the claim so the client's retry is written.
|
|
749
|
+
*/
|
|
750
|
+
protected async pushItem(
|
|
751
|
+
registration: SyncModelRegistration,
|
|
752
|
+
item: ISyncPushItem,
|
|
753
|
+
currentUser: any,
|
|
754
|
+
): Promise<ISyncPushResult> {
|
|
755
|
+
if (!isSyncId(item.id, registration.idFormat)) {
|
|
756
|
+
return reply(item, 'rejected', { code: 'INVALID_ID' });
|
|
757
|
+
}
|
|
758
|
+
const key = CoreSyncMutationStore.key(
|
|
759
|
+
registration.name,
|
|
760
|
+
String(getStringIds(currentUser?.id)),
|
|
761
|
+
item.id,
|
|
762
|
+
item.mutationId,
|
|
763
|
+
);
|
|
764
|
+
let claim: Awaited<ReturnType<CoreSyncMutationStore['claim']>>;
|
|
765
|
+
try {
|
|
766
|
+
claim = await this.mutationStore.claim(key);
|
|
767
|
+
} catch (error) {
|
|
768
|
+
this.logger.warn(`Sync: mutation store unavailable (${(error as Error)?.message ?? error})`);
|
|
769
|
+
return reply(item, 'retry');
|
|
770
|
+
}
|
|
771
|
+
if (claim.kind === 'busy') {
|
|
772
|
+
return reply(item, 'retry');
|
|
773
|
+
}
|
|
774
|
+
if (claim.kind === 'done') {
|
|
775
|
+
return this.repeatedResult(registration, item, claim.outcome, currentUser);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
let result: ISyncPushResult;
|
|
779
|
+
try {
|
|
780
|
+
result = await this.applyItem(registration, item, currentUser);
|
|
781
|
+
} catch (error) {
|
|
782
|
+
this.logger.warn(
|
|
783
|
+
`Sync: ${registration.name} ${item.id} not applied, client retries (${(error as Error)?.message ?? error})`,
|
|
784
|
+
);
|
|
785
|
+
result = reply(item, 'retry');
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
if (result.status === 'retry') {
|
|
789
|
+
await this.mutationStore.release(key);
|
|
790
|
+
} else {
|
|
791
|
+
await this.mutationStore.complete(key, { code: result.code, status: result.status, version: result.version });
|
|
792
|
+
}
|
|
793
|
+
} catch (error) {
|
|
794
|
+
// The write happened; a repeat finds the claim and, after the claim timeout, meets its own effect
|
|
795
|
+
this.logger.warn(`Sync: outcome of a mutation not recorded (${(error as Error)?.message ?? error})`);
|
|
796
|
+
}
|
|
797
|
+
return result;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** A recognised repeat: the recorded status, with the record as the caller may see it now where the status has one */
|
|
801
|
+
protected async repeatedResult(
|
|
802
|
+
registration: SyncModelRegistration,
|
|
803
|
+
item: ISyncPushItem,
|
|
804
|
+
outcome: { code?: string; status: string; version?: number },
|
|
805
|
+
currentUser: any,
|
|
806
|
+
): Promise<ISyncPushResult> {
|
|
807
|
+
const result = reply(item, outcome.status as ISyncPushResult['status'], {
|
|
808
|
+
...(outcome.code ? { code: outcome.code as SyncRejectCode } : {}),
|
|
809
|
+
...(outcome.version === undefined ? {} : { version: outcome.version }),
|
|
810
|
+
});
|
|
811
|
+
if (outcome.status === 'conflict' || outcome.status === 'merged') {
|
|
812
|
+
const record = await this.findVisible(registration, item.id, currentUser).catch(() => undefined);
|
|
813
|
+
if (record) {
|
|
814
|
+
result.record = record;
|
|
815
|
+
result.version = record.version;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return result;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
protected async applyItem(
|
|
822
|
+
registration: SyncModelRegistration,
|
|
823
|
+
item: ISyncPushItem,
|
|
824
|
+
currentUser: any,
|
|
825
|
+
): Promise<ISyncPushResult> {
|
|
826
|
+
const current = await this.findVisible(registration, item.id, currentUser);
|
|
827
|
+
if (item.op === 'delete') {
|
|
828
|
+
return this.applyDelete(registration, item, current, currentUser);
|
|
829
|
+
}
|
|
830
|
+
if (item.baseVersion === undefined) {
|
|
831
|
+
// A create: an id the caller may see is answered with that record — never compared with `data`, so the answer
|
|
832
|
+
// cannot test guesses for fields hidden from the caller (protocol §3)
|
|
833
|
+
return current ? conflict(item, current) : this.applyCreate(registration, item, currentUser);
|
|
834
|
+
}
|
|
835
|
+
if (!current) {
|
|
836
|
+
return reply(item, 'not_found');
|
|
837
|
+
}
|
|
838
|
+
if (current.deletedAt) {
|
|
839
|
+
// Never a silent resurrection
|
|
840
|
+
return conflict(item, current);
|
|
841
|
+
}
|
|
842
|
+
return this.applyUpdate(registration, item, currentUser);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
protected async applyCreate(
|
|
846
|
+
registration: SyncModelRegistration,
|
|
847
|
+
item: ISyncPushItem,
|
|
848
|
+
currentUser: any,
|
|
849
|
+
): Promise<ISyncPushResult> {
|
|
850
|
+
let input: Record<string, unknown>;
|
|
851
|
+
try {
|
|
852
|
+
input = await this.validateInput(registration.createInput, item.data);
|
|
853
|
+
} catch (error) {
|
|
854
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
855
|
+
}
|
|
856
|
+
if (registration.tenantScoped && resolveTenantScope().kind === 'tenants') {
|
|
857
|
+
// A create needs the one tenant it is created in (`X-Tenant-Id`). With the caller's membership list nothing can
|
|
858
|
+
// stamp it: MongoDB would store the record without a tenant, invisible to everyone after, and SQL refuses it
|
|
859
|
+
// (row-level security, the row check) — answered alike on every database
|
|
860
|
+
return reply(item, 'rejected', { code: 'FORBIDDEN' });
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
await registration.service.create(input as any, {
|
|
864
|
+
createId: item.id,
|
|
865
|
+
currentUser,
|
|
866
|
+
inputType: registration.createInput,
|
|
867
|
+
roles: [...registration.roles.create],
|
|
868
|
+
});
|
|
869
|
+
// The store writes version 1 on every insert of a synchronized model
|
|
870
|
+
return reply(item, 'applied', { version: 1 });
|
|
871
|
+
} catch (error) {
|
|
872
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
protected async applyUpdate(
|
|
877
|
+
registration: SyncModelRegistration,
|
|
878
|
+
item: ISyncPushItem,
|
|
879
|
+
currentUser: any,
|
|
880
|
+
): Promise<ISyncPushResult> {
|
|
881
|
+
let input: Record<string, unknown>;
|
|
882
|
+
try {
|
|
883
|
+
input = await this.validateInput(registration.updateInput, item.data);
|
|
884
|
+
} catch (error) {
|
|
885
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
886
|
+
}
|
|
887
|
+
try {
|
|
888
|
+
// Conditional on the base version, so the change's version is exactly the next one
|
|
889
|
+
await this.update(registration, item.id, input, item.baseVersion, currentUser);
|
|
890
|
+
return reply(item, 'applied', { version: item.baseVersion + 1 });
|
|
891
|
+
} catch (error) {
|
|
892
|
+
if (error instanceof VersionConflictError) {
|
|
893
|
+
return this.resolveUpdateConflict(registration, item, input, currentUser);
|
|
894
|
+
}
|
|
895
|
+
if (error instanceof NotFoundException) {
|
|
896
|
+
// Deleted between the lookup and the write
|
|
897
|
+
const now = await this.findVisible(registration, item.id, currentUser);
|
|
898
|
+
return now ? conflict(item, now) : reply(item, 'not_found');
|
|
899
|
+
}
|
|
900
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** A change based on an older version, decided by the model's strategy (protocol §6.4) */
|
|
905
|
+
protected async resolveUpdateConflict(
|
|
906
|
+
registration: SyncModelRegistration,
|
|
907
|
+
item: ISyncPushItem,
|
|
908
|
+
input: Record<string, unknown>,
|
|
909
|
+
currentUser: any,
|
|
910
|
+
): Promise<ISyncPushResult> {
|
|
911
|
+
switch (registration.strategy) {
|
|
912
|
+
case 'lww':
|
|
913
|
+
return this.reapply(registration, item, input, 'applied', currentUser);
|
|
914
|
+
case 'merge': {
|
|
915
|
+
const changed = (item.fields ?? Object.keys(item.data ?? {})).filter((field) => field in input);
|
|
916
|
+
if (changed.some((field) => registration.fieldStrategies[field] === 'reject')) {
|
|
917
|
+
return this.currentConflict(registration, item, currentUser);
|
|
918
|
+
}
|
|
919
|
+
return this.reapply(registration, item, pick(input, changed), 'merged', currentUser);
|
|
920
|
+
}
|
|
921
|
+
case 'custom':
|
|
922
|
+
return this.applyCustom(registration, item, currentUser);
|
|
923
|
+
default:
|
|
924
|
+
return this.currentConflict(registration, item, currentUser);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
protected async applyDelete(
|
|
929
|
+
registration: SyncModelRegistration,
|
|
930
|
+
item: ISyncPushItem,
|
|
931
|
+
current: ISyncRecord | undefined,
|
|
932
|
+
currentUser: any,
|
|
933
|
+
): Promise<ISyncPushResult> {
|
|
934
|
+
if (item.baseVersion === undefined) {
|
|
935
|
+
return reply(item, 'rejected', {
|
|
936
|
+
code: 'VALIDATION_FAILED',
|
|
937
|
+
details: [{ constraints: { isDefined: 'baseVersion is required for a delete' }, property: 'baseVersion' }],
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
if (!current) {
|
|
941
|
+
return reply(item, 'not_found');
|
|
942
|
+
}
|
|
943
|
+
if (current.deletedAt) {
|
|
944
|
+
// Deleted already: what the client wanted is what the server has
|
|
945
|
+
return reply(item, 'applied', { version: current.version });
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
await this.remove(registration, item.id, item.baseVersion, currentUser);
|
|
949
|
+
return reply(item, 'applied', { version: item.baseVersion + 1 });
|
|
950
|
+
} catch (error) {
|
|
951
|
+
if (error instanceof VersionConflictError) {
|
|
952
|
+
switch (registration.strategy) {
|
|
953
|
+
case 'lww':
|
|
954
|
+
return this.reapplyDelete(registration, item, currentUser);
|
|
955
|
+
case 'custom':
|
|
956
|
+
return this.applyCustom(registration, item, currentUser);
|
|
957
|
+
default:
|
|
958
|
+
// `reject`, and `merge`: a deletion cannot be merged field by field with somebody else's change
|
|
959
|
+
return this.currentConflict(registration, item, currentUser);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (error instanceof NotFoundException) {
|
|
963
|
+
const now = await this.findVisible(registration, item.id, currentUser);
|
|
964
|
+
return now?.deletedAt ? reply(item, 'applied', { version: now.version }) : reply(item, 'not_found');
|
|
965
|
+
}
|
|
966
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** The `custom` strategy: the model service decides; its answer is written through the same pipeline */
|
|
971
|
+
protected async applyCustom(
|
|
972
|
+
registration: SyncModelRegistration,
|
|
973
|
+
item: ISyncPushItem,
|
|
974
|
+
currentUser: any,
|
|
975
|
+
): Promise<ISyncPushResult> {
|
|
976
|
+
const server = await this.findVisible(registration, item.id, currentUser);
|
|
977
|
+
if (!server) {
|
|
978
|
+
return reply(item, 'not_found');
|
|
979
|
+
}
|
|
980
|
+
if (server.deletedAt) {
|
|
981
|
+
return conflict(item, server);
|
|
982
|
+
}
|
|
983
|
+
const context: ISyncConflictContext = {
|
|
984
|
+
change: { baseVersion: item.baseVersion, data: item.data, fields: item.fields, op: item.op },
|
|
985
|
+
currentUser,
|
|
986
|
+
id: item.id,
|
|
987
|
+
model: registration.name,
|
|
988
|
+
server,
|
|
989
|
+
};
|
|
990
|
+
const resolution: SyncConflictResolution = await (registration.service as any).resolveSyncConflict(context);
|
|
991
|
+
if (!resolution || 'refuse' in resolution) {
|
|
992
|
+
return reply(item, 'rejected', { code: 'CUSTOM_REFUSED' });
|
|
993
|
+
}
|
|
994
|
+
if ('delete' in resolution) {
|
|
995
|
+
return this.reapplyDelete(registration, item, currentUser);
|
|
996
|
+
}
|
|
997
|
+
let input: Record<string, unknown>;
|
|
998
|
+
try {
|
|
999
|
+
input = await this.validateInput(registration.updateInput, resolution.data);
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
1002
|
+
}
|
|
1003
|
+
return this.reapply(registration, item, input, 'merged', currentUser);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Write `input` on top of the CURRENT version — still one conditional statement, so a change arriving meanwhile is
|
|
1008
|
+
* never overwritten unseen; tried again a few times, then `retry`
|
|
1009
|
+
*/
|
|
1010
|
+
protected async reapply(
|
|
1011
|
+
registration: SyncModelRegistration,
|
|
1012
|
+
item: ISyncPushItem,
|
|
1013
|
+
input: Record<string, unknown>,
|
|
1014
|
+
status: 'applied' | 'merged',
|
|
1015
|
+
currentUser: any,
|
|
1016
|
+
): Promise<ISyncPushResult> {
|
|
1017
|
+
for (let attempt = 0; attempt < SYNC_REAPPLY_ATTEMPTS; attempt++) {
|
|
1018
|
+
const current = await this.findVisible(registration, item.id, currentUser);
|
|
1019
|
+
if (!current) {
|
|
1020
|
+
return reply(item, 'not_found');
|
|
1021
|
+
}
|
|
1022
|
+
if (current.deletedAt) {
|
|
1023
|
+
return conflict(item, current);
|
|
1024
|
+
}
|
|
1025
|
+
try {
|
|
1026
|
+
await this.update(registration, item.id, input, current.version, currentUser);
|
|
1027
|
+
if (status === 'applied') {
|
|
1028
|
+
return reply(item, 'applied', { version: current.version + 1 });
|
|
1029
|
+
}
|
|
1030
|
+
const record = await this.findVisible(registration, item.id, currentUser);
|
|
1031
|
+
return reply(item, 'merged', { ...(record ? { record } : {}), version: current.version + 1 });
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
if (!(error instanceof VersionConflictError) && !(error instanceof NotFoundException)) {
|
|
1034
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return reply(item, 'retry');
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
protected async reapplyDelete(
|
|
1042
|
+
registration: SyncModelRegistration,
|
|
1043
|
+
item: ISyncPushItem,
|
|
1044
|
+
currentUser: any,
|
|
1045
|
+
): Promise<ISyncPushResult> {
|
|
1046
|
+
for (let attempt = 0; attempt < SYNC_REAPPLY_ATTEMPTS; attempt++) {
|
|
1047
|
+
const current = await this.findVisible(registration, item.id, currentUser);
|
|
1048
|
+
if (!current) {
|
|
1049
|
+
return reply(item, 'not_found');
|
|
1050
|
+
}
|
|
1051
|
+
if (current.deletedAt) {
|
|
1052
|
+
return reply(item, 'applied', { version: current.version });
|
|
1053
|
+
}
|
|
1054
|
+
try {
|
|
1055
|
+
await this.remove(registration, item.id, current.version, currentUser);
|
|
1056
|
+
return reply(item, 'applied', { version: current.version + 1 });
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
if (!(error instanceof VersionConflictError) && !(error instanceof NotFoundException)) {
|
|
1059
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return reply(item, 'retry');
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
protected async currentConflict(
|
|
1067
|
+
registration: SyncModelRegistration,
|
|
1068
|
+
item: ISyncPushItem,
|
|
1069
|
+
currentUser: any,
|
|
1070
|
+
): Promise<ISyncPushResult> {
|
|
1071
|
+
const current = await this.findVisible(registration, item.id, currentUser);
|
|
1072
|
+
return current ? conflict(item, current) : reply(item, 'not_found');
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** The conditional update through the model's CrudService, with the update roles */
|
|
1076
|
+
protected update(
|
|
1077
|
+
registration: SyncModelRegistration,
|
|
1078
|
+
id: string,
|
|
1079
|
+
input: Record<string, unknown>,
|
|
1080
|
+
expectedVersion: number,
|
|
1081
|
+
currentUser: any,
|
|
1082
|
+
): Promise<unknown> {
|
|
1083
|
+
return registration.service.update(id, input as any, {
|
|
1084
|
+
currentUser,
|
|
1085
|
+
expectedVersion,
|
|
1086
|
+
inputType: registration.updateInput,
|
|
1087
|
+
roles: [...registration.roles.update],
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* The conditional delete through the model's CrudService, with the DELETE roles — a synchronized model's delete leaves
|
|
1093
|
+
* a tombstone on every path, so a hard delete is not reachable from here
|
|
1094
|
+
*/
|
|
1095
|
+
protected remove(
|
|
1096
|
+
registration: SyncModelRegistration,
|
|
1097
|
+
id: string,
|
|
1098
|
+
expectedVersion: number,
|
|
1099
|
+
currentUser: any,
|
|
1100
|
+
): Promise<unknown> {
|
|
1101
|
+
return registration.service.delete(id, { currentUser, expectedVersion, roles: [...registration.roles.delete] });
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* The record as the caller may see it now — tombstones included — or `undefined`: missing, in another tenant, without
|
|
1106
|
+
* the model's pull roles, or hidden by `securityCheck()` all look alike
|
|
1107
|
+
*/
|
|
1108
|
+
protected async findVisible(
|
|
1109
|
+
registration: SyncModelRegistration,
|
|
1110
|
+
id: string,
|
|
1111
|
+
currentUser: any,
|
|
1112
|
+
): Promise<ISyncRecord | undefined> {
|
|
1113
|
+
// The system fields first, the content second, as in a pull: a version is never newer than the content it labels,
|
|
1114
|
+
// so a client resolving a conflict against this record cannot silently overwrite a change it was not shown
|
|
1115
|
+
const [raw] = (await this.readSystemFields(registration, [id])).values();
|
|
1116
|
+
if (!raw) {
|
|
1117
|
+
return undefined;
|
|
1118
|
+
}
|
|
1119
|
+
try {
|
|
1120
|
+
// With the pull roles: a push answers with a record only to a caller who may pull it (protocol §1.3)
|
|
1121
|
+
const record = await registration.service.get(String(raw.id), {
|
|
1122
|
+
currentUser,
|
|
1123
|
+
includeTombstones: true,
|
|
1124
|
+
roles: [...registration.roles.pull],
|
|
1125
|
+
});
|
|
1126
|
+
return this.toSyncRecord(registration, record, raw, currentUser);
|
|
1127
|
+
} catch (error) {
|
|
1128
|
+
// Not there, or not the caller's to see: answered exactly like an unknown id
|
|
1129
|
+
if (
|
|
1130
|
+
error instanceof NotFoundException ||
|
|
1131
|
+
error instanceof ForbiddenException ||
|
|
1132
|
+
error instanceof UnauthorizedException
|
|
1133
|
+
) {
|
|
1134
|
+
return undefined;
|
|
1135
|
+
}
|
|
1136
|
+
throw error;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/** `data` validated and whitelisted against the model's input class, exactly like a request body */
|
|
1141
|
+
protected async validateInput(inputType: Type<any>, data: unknown): Promise<Record<string, unknown>> {
|
|
1142
|
+
const value = await new MapAndValidatePipe().transform(
|
|
1143
|
+
data && typeof data === 'object' ? { ...(data as object) } : {},
|
|
1144
|
+
{ metatype: inputType, type: 'body' },
|
|
1145
|
+
);
|
|
1146
|
+
return { ...(value as Record<string, unknown>) };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* A write the pipeline refused, as an item status — or rethrown when it is transient (the caller answers `retry`).
|
|
1151
|
+
* A create on an id that is taken: the caller's own record (two creates of one id at once) is a conflict, anything
|
|
1152
|
+
* else `ID_UNAVAILABLE` — the same answer for every reason an id cannot be used (protocol §3).
|
|
1153
|
+
*/
|
|
1154
|
+
protected async writeFailure(
|
|
1155
|
+
registration: SyncModelRegistration,
|
|
1156
|
+
item: ISyncPushItem,
|
|
1157
|
+
error: unknown,
|
|
1158
|
+
currentUser: any,
|
|
1159
|
+
): Promise<ISyncPushResult> {
|
|
1160
|
+
if ((error instanceof DuplicateKeyError && error.primaryKey) || isDuplicateIdError(error)) {
|
|
1161
|
+
const now = await this.findVisible(registration, item.id, currentUser);
|
|
1162
|
+
return now ? conflict(item, now) : reply(item, 'rejected', { code: 'ID_UNAVAILABLE' });
|
|
1163
|
+
}
|
|
1164
|
+
if (error instanceof ForbiddenException || error instanceof UnauthorizedException) {
|
|
1165
|
+
return reply(item, 'rejected', { code: 'FORBIDDEN' });
|
|
1166
|
+
}
|
|
1167
|
+
if (error instanceof BadRequestException) {
|
|
1168
|
+
const response = error.getResponse();
|
|
1169
|
+
const details =
|
|
1170
|
+
error instanceof DuplicateKeyError
|
|
1171
|
+
? undefined
|
|
1172
|
+
: response && typeof response === 'object'
|
|
1173
|
+
? omit(response as Record<string, unknown>, ['error', 'statusCode'])
|
|
1174
|
+
: undefined;
|
|
1175
|
+
return reply(item, 'rejected', { code: 'VALIDATION_FAILED', ...(details ? { details } : {}) });
|
|
1176
|
+
}
|
|
1177
|
+
if (error instanceof QueryCastError || error instanceof UnsupportedQueryError || isMongoDuplicate(error)) {
|
|
1178
|
+
return reply(item, 'rejected', { code: 'VALIDATION_FAILED' });
|
|
1179
|
+
}
|
|
1180
|
+
throw error;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// ===================================================================================================================
|
|
1184
|
+
// Requests: rate limits and the origin of a push (protocol §4)
|
|
1185
|
+
// ===================================================================================================================
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Count one pull or push of the caller; the seconds until the window resets when the limit is exceeded, `undefined`
|
|
1189
|
+
* while within it (or when the limit is switched off)
|
|
1190
|
+
*/
|
|
1191
|
+
async rateLimit(kind: 'pull' | 'push', currentUser: any): Promise<number | undefined> {
|
|
1192
|
+
const all = this.config.rateLimit;
|
|
1193
|
+
const own = all === false ? false : all?.[kind];
|
|
1194
|
+
if (own === false) {
|
|
1195
|
+
return undefined;
|
|
1196
|
+
}
|
|
1197
|
+
const defaults = kind === 'pull' ? SYNC_DEFAULTS.rateLimitPull : SYNC_DEFAULTS.rateLimitPush;
|
|
1198
|
+
const max = positiveNumber(own?.max, defaults.max);
|
|
1199
|
+
const windowSeconds = positiveNumber(own?.windowSeconds, defaults.windowSeconds);
|
|
1200
|
+
const hit = await this.getRateLimitStore().hit(`${kind}:${String(getStringIds(currentUser?.id))}`, windowSeconds);
|
|
1201
|
+
return hit.count > max ? Math.max(1, hit.resetIn) : undefined;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
protected getRateLimitStore(): RateLimitStore {
|
|
1205
|
+
this.rateLimitStore ??= this.redisService?.enabled
|
|
1206
|
+
? new RedisRateLimitStore(this.redisService, 'sync')
|
|
1207
|
+
: new InMemoryRateLimitStore(10_000);
|
|
1208
|
+
return this.rateLimitStore;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* The request rules of a push: a JSON body (which forces a CORS preflight in a browser), and — while the server uses
|
|
1213
|
+
* cookies, i.e. a browser may hold an ambient credential — no cross-site request and no untrusted `Origin`. A client
|
|
1214
|
+
* without fetch metadata (a native app, a server) sends neither header and is not affected.
|
|
1215
|
+
*/
|
|
1216
|
+
assertPushRequest(request: any): void {
|
|
1217
|
+
const contentType = String(request?.headers?.['content-type'] ?? '');
|
|
1218
|
+
if (!/^application\/json\b/i.test(contentType)) {
|
|
1219
|
+
throw new BadRequestException(`${ErrorCode.SYNC_INVALID_REQUEST}: Content-Type must be application/json`);
|
|
1220
|
+
}
|
|
1221
|
+
const config = ConfigService.configFastButReadOnly ?? {};
|
|
1222
|
+
if (!isCookiesEnabled(config.cookies)) {
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
if (String(request?.headers?.['sec-fetch-site'] ?? '').toLowerCase() === 'cross-site') {
|
|
1226
|
+
throw new ForbiddenException(ErrorCode.SYNC_ORIGIN_REFUSED);
|
|
1227
|
+
}
|
|
1228
|
+
const origin = request?.headers?.origin;
|
|
1229
|
+
if (typeof origin === 'string' && origin && !this.isTrustedOrigin(origin)) {
|
|
1230
|
+
throw new ForbiddenException(ErrorCode.SYNC_ORIGIN_REFUSED);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/** The origins credentialed CORS answers (`cors`), plus the app and API origins themselves */
|
|
1235
|
+
protected isTrustedOrigin(origin: string): boolean {
|
|
1236
|
+
const config = ConfigService.configFastButReadOnly ?? {};
|
|
1237
|
+
const cors = buildCorsConfig(config) as { origin?: boolean | string[] };
|
|
1238
|
+
if (cors.origin === true) {
|
|
1239
|
+
return true;
|
|
1240
|
+
}
|
|
1241
|
+
const corsConfig = typeof config.cors === 'object' ? config.cors : {};
|
|
1242
|
+
const { appUrl, baseUrl } = resolveServerUrls({
|
|
1243
|
+
appUrl: config.appUrl,
|
|
1244
|
+
baseUrl: config.baseUrl,
|
|
1245
|
+
deriveAppUrl: corsConfig?.deriveAppUrl,
|
|
1246
|
+
env: config.env,
|
|
1247
|
+
});
|
|
1248
|
+
const trusted = new Set<string>(Array.isArray(cors.origin) ? cors.origin : []);
|
|
1249
|
+
for (const url of [appUrl, baseUrl]) {
|
|
1250
|
+
const value = originOf(url);
|
|
1251
|
+
if (value) {
|
|
1252
|
+
trusted.add(value);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return trusted.has(originOf(origin) ?? origin);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// ===================================================================================================================
|
|
1259
|
+
// Access epochs (plan §3.8) — reported by CrudService and the tenant guard
|
|
1260
|
+
// ===================================================================================================================
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Make the clients concerned resync their next pull: every member of `tenantId` (no `userId`), or one user. For a
|
|
1264
|
+
* write that moves records past `CrudService.update()` — the store or the native API do not report it.
|
|
1265
|
+
*/
|
|
1266
|
+
async bumpAccessEpoch(model: string, tenantId: string | undefined, userId?: string): Promise<void> {
|
|
1267
|
+
const tenant = tenantId ?? '-';
|
|
1268
|
+
await this.stateStore.bump(
|
|
1269
|
+
userId
|
|
1270
|
+
? CoreSyncStateStore.userEpochKey(model, tenant, userId)
|
|
1271
|
+
: CoreSyncStateStore.tenantEpochKey(model, tenant),
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
async recordMoved(move: SyncRecordMove): Promise<void> {
|
|
1276
|
+
if (!this.models.has(move.model)) {
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (move.tenantChanged) {
|
|
1280
|
+
await this.bumpAccessEpoch(move.model, move.tenantId);
|
|
1281
|
+
}
|
|
1282
|
+
for (const user of move.usersLost) {
|
|
1283
|
+
await this.bumpAccessEpoch(move.model, move.tenantId, user);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
async tenantInvalidated(tenantId: string): Promise<void> {
|
|
1288
|
+
for (const registration of this.models.values()) {
|
|
1289
|
+
if (registration.tenantScoped) {
|
|
1290
|
+
await this.bumpAccessEpoch(registration.name, tenantId);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/** The tombstone horizon of a model — the purge (DEV-3902) advances it before it deletes */
|
|
1296
|
+
advanceHorizon(model: string, at: Date): Promise<void> {
|
|
1297
|
+
return this.stateStore.advanceHorizon(model, at);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// ===================================================================================================================
|
|
1301
|
+
// Hints (protocol §9, plan §3.9)
|
|
1302
|
+
// ===================================================================================================================
|
|
1303
|
+
|
|
1304
|
+
/** A write of `CrudService` on a synchronized model succeeded: a hint for its tenants */
|
|
1305
|
+
recordChanged(change: SyncRecordChange): void {
|
|
1306
|
+
this.notifyChange(change.model, change.tenantIds);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Hint that a synchronized model changed in these tenants (none on a model without tenants). `CrudService` sends it
|
|
1311
|
+
* after every successful create, update and delete; a write past it (the data store, the native API, a migration)
|
|
1312
|
+
* calls this, or its clients learn of the change only at their next pull.
|
|
1313
|
+
*/
|
|
1314
|
+
notifyChange(model: string, tenantIds: readonly string[] = []): void {
|
|
1315
|
+
if (!this.models.has(model)) {
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
this.hintService?.notify(model, tenantIds.length ? tenantIds : [undefined]);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/** Whether a hint channel is offered (`sync.hints`, both on by default; the GraphQL one also needs GraphQL) */
|
|
1322
|
+
hintsEnabled(channel: 'graphql' | 'sse'): boolean {
|
|
1323
|
+
const hints = this.config.hints;
|
|
1324
|
+
if (hints === false || !this.hintService?.enabled) {
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
if (hints && typeof hints === 'object' && hints[channel] === false) {
|
|
1328
|
+
return false;
|
|
1329
|
+
}
|
|
1330
|
+
return channel === 'sse' || ConfigService.configFastButReadOnly?.graphQl !== false;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Which hints the caller may receive, decided once when the channel opens (a change of the caller's rights closes
|
|
1335
|
+
* the channel, see openHintStream()): a model the caller holds the pull roles of — the check a pull makes — and, on
|
|
1336
|
+
* a tenant-scoped model, a change in one of the caller's tenants: the validated `X-Tenant-Id`, without it every
|
|
1337
|
+
* membership, everything for an administrator under `adminBypass`. `models` narrows it to the models the client
|
|
1338
|
+
* named (protocol §9); a name that is not offered, or not the caller's, simply never matches — the filter tells
|
|
1339
|
+
* nothing about which models exist. Must run inside the caller's request context.
|
|
1340
|
+
*/
|
|
1341
|
+
protected async hintFilter(currentUser: any, models?: readonly string[]): Promise<(hint: SyncHintEvent) => boolean> {
|
|
1342
|
+
const everywhere = Symbol('everywhere');
|
|
1343
|
+
const scopes = new Map<string, readonly string[] | typeof everywhere>();
|
|
1344
|
+
const wanted = models?.length ? new Set(models) : undefined;
|
|
1345
|
+
for (const registration of this.models.values()) {
|
|
1346
|
+
if (wanted && !wanted.has(registration.name)) {
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
try {
|
|
1350
|
+
await registration.service.checkRights(undefined, currentUser, { roles: [...registration.roles.pull] });
|
|
1351
|
+
} catch {
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
const tenants = registration.tenantScoped ? allowedTenantIds(registration.name) : undefined;
|
|
1355
|
+
scopes.set(registration.name, tenants === undefined ? everywhere : [...tenants].map(String));
|
|
1356
|
+
}
|
|
1357
|
+
return (hint) => {
|
|
1358
|
+
const scope = scopes.get(hint.model);
|
|
1359
|
+
if (scope === undefined) {
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
return scope === everywhere || (!!hint.tenantId && scope.includes(hint.tenantId));
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/** A hint as a subscriber receives it: the model and when it changed, nothing else (plan §3.9) */
|
|
1367
|
+
protected hintPayload(hint: SyncHintEvent): { at: Date; model: string } {
|
|
1368
|
+
return { at: new Date(hint.at), model: hint.model };
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* The SSE stream of `GET /sync/hints`: an `event: change` with `{ model, at }` for every hint the caller may receive
|
|
1373
|
+
* (protocol §9), for the `models` named or all.
|
|
1374
|
+
*
|
|
1375
|
+
* The stream is registered like a GraphQL WebSocket (`registerWsConnection()`): an invalidation that names the
|
|
1376
|
+
* caller or one of their tenants ends it at once, and otherwise it ends before the first hint after
|
|
1377
|
+
* `graphQl.subscriptionRevalidationMs`. An HTTP stream has no handshake to repeat, so it is not re-checked in place —
|
|
1378
|
+
* the EventSource reconnects (`retry`) and the new request passes the guards like every request. A client loses
|
|
1379
|
+
* nothing by it: a hint only means "pull now".
|
|
1380
|
+
*/
|
|
1381
|
+
async openHintStream(request: any, response: any, currentUser: any, models?: readonly string[]): Promise<void> {
|
|
1382
|
+
const accepts = await this.hintFilter(currentUser, models);
|
|
1383
|
+
const context = RequestContext.get();
|
|
1384
|
+
const carrier = {};
|
|
1385
|
+
const closeListeners: ((...args: any[]) => void)[] = [];
|
|
1386
|
+
let closed = false;
|
|
1387
|
+
let unsubscribe: () => void = () => undefined;
|
|
1388
|
+
let keepalive: ReturnType<typeof setInterval> | undefined;
|
|
1389
|
+
const write = (chunk: string) => {
|
|
1390
|
+
if (!closed) {
|
|
1391
|
+
response.write(chunk);
|
|
1392
|
+
// Behind `compression` a stream is only sent when flushed
|
|
1393
|
+
response.flush?.();
|
|
1394
|
+
}
|
|
1395
|
+
};
|
|
1396
|
+
const end = () => {
|
|
1397
|
+
if (closed) {
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
closed = true;
|
|
1401
|
+
unsubscribe();
|
|
1402
|
+
if (keepalive) {
|
|
1403
|
+
clearInterval(keepalive);
|
|
1404
|
+
}
|
|
1405
|
+
for (const listener of closeListeners.splice(0)) {
|
|
1406
|
+
listener();
|
|
1407
|
+
}
|
|
1408
|
+
response.end?.();
|
|
1409
|
+
};
|
|
1410
|
+
// Before anything else may end the stream: a listener added after the client went away never runs
|
|
1411
|
+
request?.once?.('close', end);
|
|
1412
|
+
response.once?.('close', end);
|
|
1413
|
+
registerWsConnection({
|
|
1414
|
+
carrier,
|
|
1415
|
+
// No credential to check again: every re-check ends the stream, and the reconnect is checked by the guards
|
|
1416
|
+
reauthenticate: () => undefined,
|
|
1417
|
+
socket: {
|
|
1418
|
+
close: () => end(),
|
|
1419
|
+
once: (event, listener) => {
|
|
1420
|
+
if (event === 'close') {
|
|
1421
|
+
closeListeners.push(listener);
|
|
1422
|
+
}
|
|
1423
|
+
},
|
|
1424
|
+
},
|
|
1425
|
+
user: currentUser,
|
|
1426
|
+
});
|
|
1427
|
+
const connection = getWsConnection(carrier);
|
|
1428
|
+
if (connection && context) {
|
|
1429
|
+
noteWsConnectionTenant(connection, {
|
|
1430
|
+
isAdminBypass: context.isAdminBypass,
|
|
1431
|
+
tenantId: context.tenantId,
|
|
1432
|
+
tenantIds: context.tenantIds,
|
|
1433
|
+
tenantRole: context.tenantRole,
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
// The client may have gone while the guards and the filter ran — `close` has fired then and comes no more
|
|
1437
|
+
if (request?.destroyed || response?.destroyed || response?.writableEnded) {
|
|
1438
|
+
end();
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
response.status?.(200);
|
|
1443
|
+
response.setHeader?.('Content-Type', 'text/event-stream; charset=utf-8');
|
|
1444
|
+
response.setHeader?.('Cache-Control', 'no-cache, no-transform');
|
|
1445
|
+
response.setHeader?.('Connection', 'keep-alive');
|
|
1446
|
+
// nginx buffers proxied responses unless told otherwise
|
|
1447
|
+
response.setHeader?.('X-Accel-Buffering', 'no');
|
|
1448
|
+
response.flushHeaders?.();
|
|
1449
|
+
write('retry: 3000\n: hints\n\n');
|
|
1450
|
+
|
|
1451
|
+
unsubscribe =
|
|
1452
|
+
this.hintService?.subscribe((hint) => {
|
|
1453
|
+
if (closed || !accepts(hint)) {
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const deliver = () => write(`event: change\ndata: ${JSON.stringify(this.hintPayload(hint))}\n\n`);
|
|
1457
|
+
if (!connection) {
|
|
1458
|
+
deliver();
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
void ensureWsConnectionCurrent(connection).then((current) => {
|
|
1462
|
+
if (current) {
|
|
1463
|
+
deliver();
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
}) ?? unsubscribe;
|
|
1467
|
+
keepalive = setInterval(() => {
|
|
1468
|
+
if (response?.destroyed || response?.writableEnded) {
|
|
1469
|
+
end();
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
write(': keepalive\n\n');
|
|
1473
|
+
}, SYNC_HINT_KEEPALIVE_MS);
|
|
1474
|
+
keepalive.unref?.();
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* The GraphQL subscription `syncHint`: every hint the subscriber may receive. The socket it runs on is re-checked by
|
|
1479
|
+
* `CoreModule` (`registerWsConnection()`), so a change of the subscriber's rights closes it. `models` narrows it as
|
|
1480
|
+
* for SSE. Must be called inside the subscription's request context (the subscribe wrapper installs it).
|
|
1481
|
+
*/
|
|
1482
|
+
async hintIterator(
|
|
1483
|
+
currentUser: any,
|
|
1484
|
+
models?: readonly string[],
|
|
1485
|
+
): Promise<AsyncIterableIterator<{ syncHint: { at: Date; model: string } }>> {
|
|
1486
|
+
const accepts = await this.hintFilter(currentUser, models);
|
|
1487
|
+
const queue: { syncHint: { at: Date; model: string } }[] = [];
|
|
1488
|
+
const waiting: ((result: IteratorResult<{ syncHint: { at: Date; model: string } }>) => void)[] = [];
|
|
1489
|
+
let done = false;
|
|
1490
|
+
const unsubscribe =
|
|
1491
|
+
this.hintService?.subscribe((hint) => {
|
|
1492
|
+
if (done || !accepts(hint)) {
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
const value = { syncHint: this.hintPayload(hint) };
|
|
1496
|
+
const next = waiting.shift();
|
|
1497
|
+
if (next) {
|
|
1498
|
+
next({ done: false, value });
|
|
1499
|
+
} else if (queue.length < 100) {
|
|
1500
|
+
// Bounded: a subscriber that stopped reading loses old hints, never memory — one hint is enough to pull
|
|
1501
|
+
queue.push(value);
|
|
1502
|
+
}
|
|
1503
|
+
}) ?? (() => undefined);
|
|
1504
|
+
const finish = (): IteratorResult<any> => {
|
|
1505
|
+
done = true;
|
|
1506
|
+
unsubscribe();
|
|
1507
|
+
for (const next of waiting.splice(0)) {
|
|
1508
|
+
next({ done: true, value: undefined });
|
|
1509
|
+
}
|
|
1510
|
+
queue.length = 0;
|
|
1511
|
+
return { done: true, value: undefined };
|
|
1512
|
+
};
|
|
1513
|
+
const iterator: AsyncIterableIterator<{ syncHint: { at: Date; model: string } }> = {
|
|
1514
|
+
next: () => {
|
|
1515
|
+
if (done) {
|
|
1516
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
1517
|
+
}
|
|
1518
|
+
const value = queue.shift();
|
|
1519
|
+
return value ? Promise.resolve({ done: false, value }) : new Promise((resolve) => waiting.push(resolve));
|
|
1520
|
+
},
|
|
1521
|
+
return: () => Promise.resolve(finish()),
|
|
1522
|
+
throw: (error?: unknown) => {
|
|
1523
|
+
finish();
|
|
1524
|
+
return Promise.reject(error);
|
|
1525
|
+
},
|
|
1526
|
+
[Symbol.asyncIterator]() {
|
|
1527
|
+
return iterator;
|
|
1528
|
+
},
|
|
1529
|
+
};
|
|
1530
|
+
return iterator;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// ===================================================================================================================
|
|
1534
|
+
// Tombstone purge (plan §3.7, E8)
|
|
1535
|
+
// ===================================================================================================================
|
|
1536
|
+
|
|
1537
|
+
/** `sync.retention.tombstonesDays`: `false` keeps tombstones for ever, an invalid value → the default */
|
|
1538
|
+
protected tombstoneDays(): false | number {
|
|
1539
|
+
const value = this.config.retention?.tombstonesDays;
|
|
1540
|
+
if (value === false) {
|
|
1541
|
+
return false;
|
|
1542
|
+
}
|
|
1543
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : SYNC_DEFAULTS.tombstonesDays;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/** Run the purge now and every `purgeIntervalMs`, on one replica per run (a lease) */
|
|
1547
|
+
protected schedulePurge(): void {
|
|
1548
|
+
if (this.purgeTimer || this.tombstoneDays() === false) {
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
const run = () => {
|
|
1552
|
+
// One purge at a time: an interval that elapses during a slow purge is skipped, not queued
|
|
1553
|
+
this.purgeRunning ??= this.leasedPurge().finally(() => {
|
|
1554
|
+
this.purgeRunning = undefined;
|
|
1555
|
+
});
|
|
1556
|
+
};
|
|
1557
|
+
run();
|
|
1558
|
+
this.purgeTimer = setInterval(run, this.purgeIntervalMs);
|
|
1559
|
+
this.purgeTimer.unref?.();
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/** One scheduled purge, under the lease; fails open (logged, tried again at the next interval) */
|
|
1563
|
+
protected async leasedPurge(): Promise<void> {
|
|
1564
|
+
try {
|
|
1565
|
+
const locks = await this.purgeLeaseStore();
|
|
1566
|
+
const ttlSeconds = Math.max(1, Math.floor(this.purgeIntervalMs / 1000));
|
|
1567
|
+
if (locks && !(await locks.tryAcquire('sync-purge', this.purgeOwner, ttlSeconds))) {
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
await this.purgeTombstones();
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
this.logger.warn(`Sync tombstone purge failed, trying again later: ${(error as Error)?.message ?? error}`);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* The store for the purge lease: Redis when configured, else the lease table of the default SQL source, else
|
|
1578
|
+
* `cron-locks` in MongoDB — the cron leases' order. `undefined` without any: the purge then runs on every replica,
|
|
1579
|
+
* which is harmless (it only deletes what is past the horizon, and the horizon only moves forward). The lease is
|
|
1580
|
+
* never released, so the next replica takes it once it expired: one run per interval across the fleet.
|
|
1581
|
+
*/
|
|
1582
|
+
protected async purgeLeaseStore(): Promise<LockStore | undefined> {
|
|
1583
|
+
if (this.redisService?.enabled) {
|
|
1584
|
+
const client = this.redisService.getClient();
|
|
1585
|
+
const key = (name: string) => this.redisService.key('sync-lock', name);
|
|
1586
|
+
return {
|
|
1587
|
+
release: async () => false,
|
|
1588
|
+
renew: async () => false,
|
|
1589
|
+
tryAcquire: async (name, owner, ttlSeconds) =>
|
|
1590
|
+
(await client.set(key(name), owner, 'EX', ttlSeconds, 'NX')) === 'OK',
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
const sqlLocks = await defaultSqlLockStore('sync');
|
|
1594
|
+
if (sqlLocks) {
|
|
1595
|
+
return sqlLocks;
|
|
1596
|
+
}
|
|
1597
|
+
const connection = getCronJobsInfrastructure().connection;
|
|
1598
|
+
return connection?.db ? cronMongoLocks(connection.db.collection(CRON_LOCK_COLLECTION)) : undefined;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* Remove the tombstones older than `before` (default: `sync.retention.tombstonesDays` ago) for good — of every
|
|
1603
|
+
* synchronized model, or of one. The model's horizon moves to `before` FIRST: a cursor older than it answers its next
|
|
1604
|
+
* pull with a full resync (`410 horizon`) instead of continuing past deletions it would never receive. Answers the
|
|
1605
|
+
* number of tombstones removed per model.
|
|
1606
|
+
*/
|
|
1607
|
+
async purgeTombstones(options: { before?: Date; model?: string } = {}): Promise<Record<string, number>> {
|
|
1608
|
+
const days = this.tombstoneDays();
|
|
1609
|
+
if (!options.before && days === false) {
|
|
1610
|
+
return {};
|
|
1611
|
+
}
|
|
1612
|
+
const before = options.before ?? new Date(Date.now() - (days as number) * 86_400_000);
|
|
1613
|
+
const removed: Record<string, number> = {};
|
|
1614
|
+
for (const registration of this.models.values()) {
|
|
1615
|
+
if (options.model && registration.name !== options.model) {
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1618
|
+
await this.stateStore.advanceHorizon(registration.name, before);
|
|
1619
|
+
removed[registration.name] = await this.purgeModel(registration, before);
|
|
1620
|
+
}
|
|
1621
|
+
const total = Object.values(removed).reduce((sum, count) => sum + count, 0);
|
|
1622
|
+
if (total) {
|
|
1623
|
+
this.logger.log(`Sync: ${total} tombstone(s) older than ${before.toISOString()} removed`);
|
|
1624
|
+
}
|
|
1625
|
+
return removed;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/**
|
|
1629
|
+
* Hard-delete one model's tombstones older than `before`, `SYNC_PURGE_BATCH` at a time: the ids are read in primary-key
|
|
1630
|
+
* order without a lock, and only those rows are deleted (each statement re-checks `deletedAt`, so a row written in
|
|
1631
|
+
* between stays). One `DELETE … WHERE deleted_at < ?` would scan the whole table — on MySQL / MariaDB under REPEATABLE
|
|
1632
|
+
* READ that locks every row and gap it passes until it ends, and every write of the model waits for it.
|
|
1633
|
+
*/
|
|
1634
|
+
protected async purgeModel(registration: SyncModelRegistration, before: Date): Promise<number> {
|
|
1635
|
+
let removed = 0;
|
|
1636
|
+
let after: string | undefined;
|
|
1637
|
+
for (;;) {
|
|
1638
|
+
const batch = await registration.store.find(
|
|
1639
|
+
{ deletedAt: { $lt: before }, ...(after ? { id: { $gt: after } } : {}) } as any,
|
|
1640
|
+
{ includeTombstones: true, limit: SYNC_PURGE_BATCH, select: ['id'], sort: { id: 1 } },
|
|
1641
|
+
);
|
|
1642
|
+
if (!batch.length) {
|
|
1643
|
+
return removed;
|
|
1644
|
+
}
|
|
1645
|
+
removed += await registration.store.deleteMany(
|
|
1646
|
+
{ deletedAt: { $lt: before }, id: { $in: batch.map((record) => record.id) } } as any,
|
|
1647
|
+
{ hardDelete: true, includeTombstones: true },
|
|
1648
|
+
);
|
|
1649
|
+
if (batch.length < SYNC_PURGE_BATCH) {
|
|
1650
|
+
return removed;
|
|
1651
|
+
}
|
|
1652
|
+
after = batch[batch.length - 1].id;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// ===================================================================================================================
|
|
1657
|
+
// Helpers
|
|
1658
|
+
// ===================================================================================================================
|
|
1659
|
+
|
|
1660
|
+
/** 410: the client deletes its records of the model and pulls without a cursor (protocol §8) */
|
|
1661
|
+
protected resync(reason: SyncResyncReason): never {
|
|
1662
|
+
throw new SyncResyncRequiredException(reason);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/** A store call whose failure (an outage, not a refusal) answers 503 */
|
|
1666
|
+
protected async storeCall<T>(call: () => Promise<T>): Promise<T> {
|
|
1667
|
+
try {
|
|
1668
|
+
return await call();
|
|
1669
|
+
} catch (error) {
|
|
1670
|
+
if (error instanceof HttpException) {
|
|
1671
|
+
throw error;
|
|
1672
|
+
}
|
|
1673
|
+
this.logger.error(`Sync: store unavailable (${(error as Error)?.message ?? error})`, (error as Error)?.stack);
|
|
1674
|
+
throw new ServiceUnavailableException(ErrorCode.SYNC_STORE_UNAVAILABLE);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function reply(
|
|
1680
|
+
item: ISyncPushItem,
|
|
1681
|
+
status: ISyncPushResult['status'],
|
|
1682
|
+
extra: Partial<ISyncPushResult> = {},
|
|
1683
|
+
): ISyncPushResult {
|
|
1684
|
+
return { id: item.id, mutationId: item.mutationId, status, ...extra };
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
function conflict(item: ISyncPushItem, record: ISyncRecord): ISyncPushResult {
|
|
1688
|
+
return reply(item, 'conflict', { record, version: record.version });
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
/** The id of a CrudService output record (`id` is declared readable for everyone by `CorePersistenceModel`) */
|
|
1692
|
+
function recordId(record: any): string {
|
|
1693
|
+
const id = record?.id ?? record?._id;
|
|
1694
|
+
return id === undefined || id === null ? '' : String(getStringIds(id));
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function initialPosition(order: 'clock' | 'seq'): SyncCursorPosition {
|
|
1698
|
+
return order === 'seq' ? { seq: 0 } : { at: 0, id: '' };
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function pick(input: Record<string, unknown>, fields: readonly string[]): Record<string, unknown> {
|
|
1702
|
+
return Object.fromEntries(fields.filter((field) => field in input).map((field) => [field, input[field]]));
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
function omit(value: Record<string, unknown>, keys: readonly string[]): Record<string, unknown> {
|
|
1706
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key)));
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
function positiveNumber(value: unknown, fallback: number): number {
|
|
1710
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function originOf(url: string | undefined): string | undefined {
|
|
1714
|
+
if (!url) {
|
|
1715
|
+
return undefined;
|
|
1716
|
+
}
|
|
1717
|
+
try {
|
|
1718
|
+
const parsed = new URL(url);
|
|
1719
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.origin : undefined;
|
|
1720
|
+
} catch {
|
|
1721
|
+
return undefined;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
/** A MongoDB duplicate-key error on a field other than the id (a unique index of the model) */
|
|
1726
|
+
function isMongoDuplicate(error: unknown): boolean {
|
|
1727
|
+
return (error as { code?: unknown })?.code === 11000;
|
|
1728
|
+
}
|