@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,1208 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
var CoreSyncService_1;
|
|
14
|
+
import { BadRequestException, ForbiddenException, HttpException, HttpStatus, Injectable, Logger, NotFoundException, Optional, ServiceUnavailableException, UnauthorizedException, } from '@nestjs/common';
|
|
15
|
+
import { DiscoveryService, ModuleRef } from '@nestjs/core';
|
|
16
|
+
import { RoleEnum } from '../../common/enums/role.enum.js';
|
|
17
|
+
import { getStringIds } from '../../common/helpers/db.helper.js';
|
|
18
|
+
import { ensureWsConnectionCurrent, getWsConnection, noteWsConnectionTenant, registerWsConnection, } from '../../common/helpers/graphql-ws-connection.helper.js';
|
|
19
|
+
import { buildCorsConfig, isCookiesEnabled, isProductionLikeEnv, resolveServerUrls, } from '../../common/helpers/cookies.helper.js';
|
|
20
|
+
import { InjectStore } from '../../common/persistence/inject-store.decorator.js';
|
|
21
|
+
import { getModelDescriptor } from '../../common/persistence/model-metadata.registry.js';
|
|
22
|
+
import { isDuplicateIdError } from '../../common/persistence/mongoose-data-store.js';
|
|
23
|
+
import { getPersistedModelOptions } from '../../common/persistence/persisted-model.decorator.js';
|
|
24
|
+
import { CRON_LOCK_COLLECTION, cronMongoLocks, defaultSqlLockStore, } from '../../common/persistence/infrastructure-locks.js';
|
|
25
|
+
import { getStoreToken } from '../../common/persistence/persistence.tokens.js';
|
|
26
|
+
import { isSyncId, VersionConflictError } from '../../common/persistence/policies/sync.policy.js';
|
|
27
|
+
import { allowedTenantIds, resolveTenantScope } from '../../common/persistence/policies/tenant.policy.js';
|
|
28
|
+
import { DuplicateKeyError, QueryCastError, UnsupportedQueryError } from '../../common/persistence/sql/sql-errors.js';
|
|
29
|
+
import { getSyncAccessListener, setSyncAccessListener, } from '../../common/persistence/sync-access.registry.js';
|
|
30
|
+
import { SYNC_CLOCK_FIELD, SYNC_SEQUENCE_FIELD, } from '../../common/persistence/sync-model.js';
|
|
31
|
+
import { MapAndValidatePipe } from '../../common/pipes/map-and-validate.pipe.js';
|
|
32
|
+
import { ConfigService } from '../../common/services/config.service.js';
|
|
33
|
+
import { getCronJobsInfrastructure } from '../../common/services/core-cron-jobs.registry.js';
|
|
34
|
+
import { CoreRedisService } from '../../common/services/core-redis.service.js';
|
|
35
|
+
import { CrudService } from '../../common/services/crud.service.js';
|
|
36
|
+
import { InMemoryRateLimitStore, RedisRateLimitStore } from '../../common/services/rate-limit-store.js';
|
|
37
|
+
import { RequestContext } from '../../common/services/request-context.service.js';
|
|
38
|
+
import { ErrorCode } from '../error-code/error-codes.js';
|
|
39
|
+
import { SYNC_CLAIM_TIMEOUT_MS, SYNC_DEFAULTS, SYNC_HINT_KEEPALIVE_MS, SYNC_MUTATION_MODEL, SYNC_PULL_LIMIT_CEILING, SYNC_PURGE_BATCH, SYNC_PURGE_INTERVAL_MS, SYNC_REAPPLY_ATTEMPTS, SYNC_STATE_MODEL, } from './core-sync.constants.js';
|
|
40
|
+
import { CoreSyncHintService } from './core-sync-hints.service.js';
|
|
41
|
+
import { decodeSyncCursor, deriveCursorKey, encodeSyncCursor, stableHash, } from './core-sync-cursor.js';
|
|
42
|
+
import { getSyncFieldStrategies, getSyncServiceOptions } from './core-sync.decorators.js';
|
|
43
|
+
import { CoreSyncMutationStore } from './core-sync-mutation.store.js';
|
|
44
|
+
import { CoreSyncStateStore } from './core-sync-state.store.js';
|
|
45
|
+
export class SyncResyncRequiredException extends HttpException {
|
|
46
|
+
reason;
|
|
47
|
+
constructor(reason) {
|
|
48
|
+
super({ code: 'SYNC_RESYNC_REQUIRED', message: ErrorCode.SYNC_RESYNC_REQUIRED, reason, statusCode: HttpStatus.GONE }, HttpStatus.GONE);
|
|
49
|
+
this.reason = reason;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const ADMIN_ONLY = Object.freeze([RoleEnum.ADMIN]);
|
|
53
|
+
const OPERATIONS = ['create', 'delete', 'pull', 'update'];
|
|
54
|
+
const MAX_MUTATION_ID_LENGTH = 128;
|
|
55
|
+
const MAX_ID_LENGTH = 64;
|
|
56
|
+
const SYSTEM_RECORD_FIELDS = new Set([
|
|
57
|
+
'__v',
|
|
58
|
+
'_id',
|
|
59
|
+
'deletedAt',
|
|
60
|
+
'id',
|
|
61
|
+
'updatedAt',
|
|
62
|
+
'version',
|
|
63
|
+
SYNC_CLOCK_FIELD,
|
|
64
|
+
SYNC_SEQUENCE_FIELD,
|
|
65
|
+
]);
|
|
66
|
+
let CoreSyncService = CoreSyncService_1 = class CoreSyncService {
|
|
67
|
+
discoveryService;
|
|
68
|
+
moduleRef;
|
|
69
|
+
redisService;
|
|
70
|
+
hintService;
|
|
71
|
+
logger = new Logger(CoreSyncService_1.name);
|
|
72
|
+
models = new Map();
|
|
73
|
+
mutationStore;
|
|
74
|
+
stateStore;
|
|
75
|
+
cursorKey;
|
|
76
|
+
rateLimitStore;
|
|
77
|
+
purgeTimer;
|
|
78
|
+
purgeRunning;
|
|
79
|
+
purgeOwner = `sync-purge:${process.pid}:${Math.random().toString(36).slice(2)}`;
|
|
80
|
+
purgeIntervalMs = SYNC_PURGE_INTERVAL_MS;
|
|
81
|
+
constructor(mutations, states, discoveryService, moduleRef, redisService, hintService) {
|
|
82
|
+
this.discoveryService = discoveryService;
|
|
83
|
+
this.moduleRef = moduleRef;
|
|
84
|
+
this.redisService = redisService;
|
|
85
|
+
this.hintService = hintService;
|
|
86
|
+
const days = positiveNumber(this.config.retention?.mutationsDays, SYNC_DEFAULTS.mutationsDays);
|
|
87
|
+
this.mutationStore = new CoreSyncMutationStore(mutations, {
|
|
88
|
+
claimTimeoutMs: SYNC_CLAIM_TIMEOUT_MS,
|
|
89
|
+
retentionMs: days * 86_400_000,
|
|
90
|
+
});
|
|
91
|
+
this.stateStore = new CoreSyncStateStore(states);
|
|
92
|
+
}
|
|
93
|
+
get config() {
|
|
94
|
+
const value = ConfigService.configFastButReadOnly?.sync;
|
|
95
|
+
return value && typeof value === 'object' ? value : {};
|
|
96
|
+
}
|
|
97
|
+
async onApplicationBootstrap() {
|
|
98
|
+
this.cursorKey = deriveCursorKey(this.resolveCursorSecret());
|
|
99
|
+
for (const registration of this.discoverModels()) {
|
|
100
|
+
if (this.models.has(registration.name)) {
|
|
101
|
+
throw new Error(`Sync: two services declare @SyncService() for the model "${registration.name}"`);
|
|
102
|
+
}
|
|
103
|
+
this.models.set(registration.name, registration);
|
|
104
|
+
}
|
|
105
|
+
setSyncAccessListener(this);
|
|
106
|
+
if (this.models.size) {
|
|
107
|
+
this.logger.log(`Offline sync for ${[...this.models.keys()].join(', ')}`);
|
|
108
|
+
this.schedulePurge();
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
this.logger.warn('Offline sync is configured, but no service declares @SyncService() — no model is offered');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async onModuleDestroy() {
|
|
115
|
+
if (getSyncAccessListener() === this) {
|
|
116
|
+
setSyncAccessListener(undefined);
|
|
117
|
+
}
|
|
118
|
+
if (this.purgeTimer) {
|
|
119
|
+
clearInterval(this.purgeTimer);
|
|
120
|
+
this.purgeTimer = undefined;
|
|
121
|
+
}
|
|
122
|
+
await this.purgeRunning?.catch(() => undefined);
|
|
123
|
+
}
|
|
124
|
+
getModels() {
|
|
125
|
+
return this.models;
|
|
126
|
+
}
|
|
127
|
+
resolveCursorSecret() {
|
|
128
|
+
const secret = this.config.cursorSecret || process.env.SECRETS_ENCRYPTION_KEY;
|
|
129
|
+
if (secret) {
|
|
130
|
+
return secret;
|
|
131
|
+
}
|
|
132
|
+
const env = ConfigService.configFastButReadOnly?.env;
|
|
133
|
+
if (isProductionLikeEnv(env)) {
|
|
134
|
+
throw new Error(`Sync: no sync.cursorSecret / SECRETS_ENCRYPTION_KEY set in "${env}" — pull cursors would be signed with a ` +
|
|
135
|
+
'known key');
|
|
136
|
+
}
|
|
137
|
+
this.logger.warn('No sync.cursorSecret / SECRETS_ENCRYPTION_KEY set — pull cursors are signed with an insecure development ' +
|
|
138
|
+
'default. Never run this in production (the boot refuses it there).');
|
|
139
|
+
return 'lt-nest-server-sync-cursor-dev-only-insecure-default';
|
|
140
|
+
}
|
|
141
|
+
discoverModels() {
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
const registrations = [];
|
|
144
|
+
for (const wrapper of this.discoveryService.getProviders()) {
|
|
145
|
+
const instance = wrapper.instance;
|
|
146
|
+
const options = wrapper.metatype ? getSyncServiceOptions(wrapper.metatype) : undefined;
|
|
147
|
+
if (!options || !instance || seen.has(instance)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
seen.add(instance);
|
|
151
|
+
registrations.push(this.resolveRegistration(instance, options));
|
|
152
|
+
}
|
|
153
|
+
return registrations;
|
|
154
|
+
}
|
|
155
|
+
resolveRegistration(service, options) {
|
|
156
|
+
const name = options.modelName ?? options.model?.name;
|
|
157
|
+
const label = `@SyncService() on ${service?.constructor?.name}`;
|
|
158
|
+
if (!(service instanceof CrudService)) {
|
|
159
|
+
throw new Error(`Sync: ${label} — the service must extend CrudService`);
|
|
160
|
+
}
|
|
161
|
+
if (!name || !options.createInput || !options.updateInput) {
|
|
162
|
+
throw new Error(`Sync: ${label} needs model, createInput and updateInput`);
|
|
163
|
+
}
|
|
164
|
+
let store;
|
|
165
|
+
try {
|
|
166
|
+
store = this.moduleRef.get(getStoreToken(name), { strict: false });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
throw new Error(`Sync: ${label} — the model "${name}" is not registered with CorePersistenceModule.forFeature()`);
|
|
170
|
+
}
|
|
171
|
+
const order = store?.capabilities?.changeOrder;
|
|
172
|
+
if (!order) {
|
|
173
|
+
throw new Error(`Sync: ${label} — the model "${name}" is not synchronized (@PersistedModel({ sync }))`);
|
|
174
|
+
}
|
|
175
|
+
if (order === 'clock' && typeof store.changeClock !== 'function') {
|
|
176
|
+
throw new Error(`Sync: the store of "${name}" orders changes by clock but offers no changeClock()`);
|
|
177
|
+
}
|
|
178
|
+
const declared = getPersistedModelOptions(options.model)?.sync;
|
|
179
|
+
const declaration = declared && typeof declared === 'object' ? declared : {};
|
|
180
|
+
const strategy = declaration.strategy ?? 'reject';
|
|
181
|
+
if (strategy === 'custom' && typeof service.resolveSyncConflict !== 'function') {
|
|
182
|
+
throw new Error(`Sync: "${name}" declares strategy 'custom', but ${service.constructor.name} has no resolveSyncConflict()`);
|
|
183
|
+
}
|
|
184
|
+
const missing = OPERATIONS.filter((operation) => !declaration.roles?.[operation]?.length);
|
|
185
|
+
if (missing.length) {
|
|
186
|
+
this.logger.warn(`Sync: "${name}" declares no sync roles for ${missing.join(', ')} — open to ADMIN only. Declare them in ` +
|
|
187
|
+
'@PersistedModel({ sync: { roles } })');
|
|
188
|
+
}
|
|
189
|
+
const roles = Object.freeze(Object.fromEntries(OPERATIONS.map((operation) => [operation, Object.freeze([...(declaration.roles?.[operation] ?? ADMIN_ONLY)])])));
|
|
190
|
+
const descriptor = getModelDescriptor(name);
|
|
191
|
+
return {
|
|
192
|
+
createInput: options.createInput,
|
|
193
|
+
fieldStrategies: Object.freeze(getSyncFieldStrategies(options.model)),
|
|
194
|
+
idFormat: store.capabilities.idType,
|
|
195
|
+
model: options.model,
|
|
196
|
+
name,
|
|
197
|
+
order,
|
|
198
|
+
roles,
|
|
199
|
+
scope: descriptor?.sync?.scope ?? [],
|
|
200
|
+
service,
|
|
201
|
+
store,
|
|
202
|
+
strategy,
|
|
203
|
+
tenantScoped: !!descriptor?.tenantScoped,
|
|
204
|
+
updateInput: options.updateInput,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
registration(model) {
|
|
208
|
+
const registration = typeof model === 'string' ? this.models.get(model) : undefined;
|
|
209
|
+
if (!registration) {
|
|
210
|
+
throw new NotFoundException(ErrorCode.SYNC_MODEL_NOT_FOUND);
|
|
211
|
+
}
|
|
212
|
+
return registration;
|
|
213
|
+
}
|
|
214
|
+
async pull(model, query, currentUser) {
|
|
215
|
+
const registration = this.registration(model);
|
|
216
|
+
await registration.service.checkRights(undefined, currentUser, { roles: [...registration.roles.pull] });
|
|
217
|
+
const limit = this.pullLimit(query.limit);
|
|
218
|
+
const access = await this.accessState(registration, currentUser);
|
|
219
|
+
const state = query.cursor === undefined || query.cursor === ''
|
|
220
|
+
? undefined
|
|
221
|
+
: await this.continuedCursor(registration, query.cursor, access);
|
|
222
|
+
const clock = await this.readClock(registration);
|
|
223
|
+
const runStart = state?.runStart ?? clock.now;
|
|
224
|
+
const since = state ? state.since : runStart;
|
|
225
|
+
const position = state?.position ?? initialPosition(registration.order);
|
|
226
|
+
const rows = await this.readPositions(registration, position, limit + 1);
|
|
227
|
+
const page = rows.slice(0, limit);
|
|
228
|
+
const contents = await this.readContents(registration, page.map((row) => String(row.id)), currentUser);
|
|
229
|
+
const maxBytes = positiveNumber(this.config.pull?.maxBytes, SYNC_DEFAULTS.maxBytes);
|
|
230
|
+
const items = [];
|
|
231
|
+
let bytes = 2;
|
|
232
|
+
let consumed = 0;
|
|
233
|
+
let last = position;
|
|
234
|
+
for (const raw of page) {
|
|
235
|
+
const record = contents.get(String(raw.id));
|
|
236
|
+
const item = record ? this.toSyncRecord(registration, record, raw, currentUser) : undefined;
|
|
237
|
+
if (item) {
|
|
238
|
+
const size = Buffer.byteLength(JSON.stringify(item)) + 1;
|
|
239
|
+
if (items.length && bytes + size > maxBytes) {
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
items.push(item);
|
|
243
|
+
bytes += size;
|
|
244
|
+
}
|
|
245
|
+
consumed++;
|
|
246
|
+
last = this.positionOf(registration, raw);
|
|
247
|
+
}
|
|
248
|
+
const hasMore = consumed < rows.length;
|
|
249
|
+
const next = hasMore
|
|
250
|
+
? { position: last, runStart, since }
|
|
251
|
+
:
|
|
252
|
+
{
|
|
253
|
+
position: registration.order === 'seq' ? last : { at: runStart - clock.windowMs, id: '' },
|
|
254
|
+
since: runStart,
|
|
255
|
+
};
|
|
256
|
+
const cursor = encodeSyncCursor({ ...next, epochs: access.epochs, fingerprint: access.fingerprint, model: registration.name, user: access.user }, this.cursorKey);
|
|
257
|
+
return { cursor, hasMore, items };
|
|
258
|
+
}
|
|
259
|
+
async continuedCursor(registration, cursor, access) {
|
|
260
|
+
const state = decodeSyncCursor(cursor, this.cursorKey);
|
|
261
|
+
if (!state ||
|
|
262
|
+
state.model !== registration.name ||
|
|
263
|
+
state.user !== access.user ||
|
|
264
|
+
'seq' in state.position !== (registration.order === 'seq')) {
|
|
265
|
+
this.resync('invalid');
|
|
266
|
+
}
|
|
267
|
+
if (state.fingerprint !== access.fingerprint || state.epochs !== access.epochs) {
|
|
268
|
+
this.resync('access');
|
|
269
|
+
}
|
|
270
|
+
const horizon = await this.stateStore.horizon(registration.name);
|
|
271
|
+
if (horizon && state.since < horizon.getTime()) {
|
|
272
|
+
this.resync('horizon');
|
|
273
|
+
}
|
|
274
|
+
return state;
|
|
275
|
+
}
|
|
276
|
+
pullLimit(value) {
|
|
277
|
+
const max = Math.min(positiveNumber(this.config.pull?.maxLimit, SYNC_DEFAULTS.maxLimit), SYNC_PULL_LIMIT_CEILING);
|
|
278
|
+
const fallback = Math.min(positiveNumber(this.config.pull?.defaultLimit, SYNC_DEFAULTS.pullLimit), max);
|
|
279
|
+
if (value === undefined || value === '') {
|
|
280
|
+
return fallback;
|
|
281
|
+
}
|
|
282
|
+
const limit = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN;
|
|
283
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > max) {
|
|
284
|
+
throw new BadRequestException(`${ErrorCode.SYNC_INVALID_REQUEST}: limit must be an integer from 1 to ${max}`);
|
|
285
|
+
}
|
|
286
|
+
return limit;
|
|
287
|
+
}
|
|
288
|
+
async readClock(registration) {
|
|
289
|
+
if (registration.order !== 'clock') {
|
|
290
|
+
return { now: Date.now(), windowMs: 0 };
|
|
291
|
+
}
|
|
292
|
+
const clock = await this.storeCall(() => registration.store.changeClock());
|
|
293
|
+
const configured = this.config.pull?.windowSeconds;
|
|
294
|
+
const windowMs = typeof configured === 'number' && configured >= 0 ? configured * 1000 : clock.windowMs;
|
|
295
|
+
return { now: clock.now.getTime(), windowMs };
|
|
296
|
+
}
|
|
297
|
+
async readPositions(registration, position, count) {
|
|
298
|
+
let where;
|
|
299
|
+
let sort;
|
|
300
|
+
if ('seq' in position) {
|
|
301
|
+
where = { [SYNC_SEQUENCE_FIELD]: { $gt: position.seq } };
|
|
302
|
+
sort = { [SYNC_SEQUENCE_FIELD]: 1 };
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
const at = new Date(position.at);
|
|
306
|
+
where = position.id
|
|
307
|
+
? { $or: [{ [SYNC_CLOCK_FIELD]: { $gt: at } }, { id: { $gt: position.id }, [SYNC_CLOCK_FIELD]: at }] }
|
|
308
|
+
: { [SYNC_CLOCK_FIELD]: { $gte: at } };
|
|
309
|
+
sort = { [SYNC_CLOCK_FIELD]: 1, id: 1 };
|
|
310
|
+
}
|
|
311
|
+
const options = { includeTombstones: true, limit: count, select: this.systemFields(registration), sort };
|
|
312
|
+
const scope = registration.tenantScoped ? resolveTenantScope() : undefined;
|
|
313
|
+
const tenants = scope?.kind === 'tenants' ? [...new Set(scope.tenantIds.map(String))] : [];
|
|
314
|
+
if (!tenants.length) {
|
|
315
|
+
return this.storeCall(() => registration.store.find(where, options));
|
|
316
|
+
}
|
|
317
|
+
const pages = await Promise.all(tenants.map((tenantId) => this.storeCall(() => registration.store.find({ $and: [where, { tenantId }] }, options))));
|
|
318
|
+
return pages
|
|
319
|
+
.flat()
|
|
320
|
+
.sort((a, b) => this.comparePositions(registration, a, b))
|
|
321
|
+
.slice(0, count);
|
|
322
|
+
}
|
|
323
|
+
comparePositions(registration, a, b) {
|
|
324
|
+
if (registration.order === 'seq') {
|
|
325
|
+
return Number(a?.[SYNC_SEQUENCE_FIELD]) - Number(b?.[SYNC_SEQUENCE_FIELD]);
|
|
326
|
+
}
|
|
327
|
+
const at = new Date(a?.[SYNC_CLOCK_FIELD]).getTime() - new Date(b?.[SYNC_CLOCK_FIELD]).getTime();
|
|
328
|
+
if (at) {
|
|
329
|
+
return at;
|
|
330
|
+
}
|
|
331
|
+
const left = String(a?.id);
|
|
332
|
+
const right = String(b?.id);
|
|
333
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
334
|
+
}
|
|
335
|
+
async readContents(registration, ids, currentUser) {
|
|
336
|
+
const result = new Map();
|
|
337
|
+
if (!ids.length) {
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
const records = await this.storeCall(() => registration.service.find({ filterQuery: { _id: { $in: ids } } }, { currentUser, includeTombstones: true, roles: [...registration.roles.pull] }));
|
|
341
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
342
|
+
result.set(String(recordId(record)), record);
|
|
343
|
+
}
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
async readSystemFields(registration, ids) {
|
|
347
|
+
const result = new Map();
|
|
348
|
+
if (!ids.length) {
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
const rows = await this.storeCall(() => registration.store.find({ id: { $in: ids } }, { includeTombstones: true, select: this.systemFields(registration) }));
|
|
352
|
+
for (const row of rows) {
|
|
353
|
+
result.set(String(row.id), row);
|
|
354
|
+
}
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
systemFields(registration) {
|
|
358
|
+
const fields = getModelDescriptor(registration.name)?.fields ?? {};
|
|
359
|
+
return [
|
|
360
|
+
'deletedAt',
|
|
361
|
+
'version',
|
|
362
|
+
registration.order === 'seq' ? SYNC_SEQUENCE_FIELD : SYNC_CLOCK_FIELD,
|
|
363
|
+
...['tenantId', 'updatedAt'].filter((field) => field in fields),
|
|
364
|
+
];
|
|
365
|
+
}
|
|
366
|
+
positionOf(registration, raw) {
|
|
367
|
+
if (registration.order === 'seq') {
|
|
368
|
+
return { seq: Number(raw?.[SYNC_SEQUENCE_FIELD]) };
|
|
369
|
+
}
|
|
370
|
+
return { at: new Date(raw?.[SYNC_CLOCK_FIELD]).getTime(), id: String(raw?.id) };
|
|
371
|
+
}
|
|
372
|
+
toSyncRecord(registration, record, raw, currentUser) {
|
|
373
|
+
if (!record || !raw) {
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
const checked = typeof record.securityCheck === 'function' ? record.securityCheck(currentUser, false) : record;
|
|
377
|
+
if (!checked) {
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
const plain = { ...checked };
|
|
381
|
+
const base = {
|
|
382
|
+
deletedAt: raw.deletedAt ? new Date(raw.deletedAt) : null,
|
|
383
|
+
id: String(raw.id),
|
|
384
|
+
...(raw.updatedAt ? { updatedAt: new Date(raw.updatedAt) } : {}),
|
|
385
|
+
version: Number(raw.version),
|
|
386
|
+
};
|
|
387
|
+
if (base.deletedAt) {
|
|
388
|
+
if (registration.tenantScoped && raw.tenantId !== undefined && raw.tenantId !== null) {
|
|
389
|
+
base.tenantId = String(getStringIds(raw.tenantId));
|
|
390
|
+
}
|
|
391
|
+
for (const field of registration.scope) {
|
|
392
|
+
if (plain[field] !== undefined) {
|
|
393
|
+
base[field] = getStringIds(plain[field]);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return base;
|
|
397
|
+
}
|
|
398
|
+
const data = {};
|
|
399
|
+
for (const [key, value] of Object.entries(plain)) {
|
|
400
|
+
if (!SYSTEM_RECORD_FIELDS.has(key) && value !== undefined && typeof value !== 'function') {
|
|
401
|
+
data[key] = value;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return { ...base, data };
|
|
405
|
+
}
|
|
406
|
+
async accessState(registration, currentUser) {
|
|
407
|
+
const context = RequestContext.get();
|
|
408
|
+
const user = String(getStringIds(currentUser?.id));
|
|
409
|
+
const scope = context?.tenantId
|
|
410
|
+
? [context.tenantId]
|
|
411
|
+
: context?.tenantIds
|
|
412
|
+
? [...context.tenantIds].map(String).sort()
|
|
413
|
+
: context?.isAdminBypass
|
|
414
|
+
? ['*']
|
|
415
|
+
: [];
|
|
416
|
+
const fingerprint = stableHash([
|
|
417
|
+
user,
|
|
418
|
+
scope,
|
|
419
|
+
[...(Array.isArray(currentUser?.roles) ? currentUser.roles : [])].map(String).sort(),
|
|
420
|
+
context?.tenantRole ?? null,
|
|
421
|
+
!!(currentUser?.verified || currentUser?.verifiedAt || currentUser?.emailVerified),
|
|
422
|
+
]);
|
|
423
|
+
const tenants = registration.tenantScoped && scope.length ? scope : ['-'];
|
|
424
|
+
const keys = tenants.flatMap((tenant) => [
|
|
425
|
+
CoreSyncStateStore.tenantEpochKey(registration.name, tenant),
|
|
426
|
+
...(registration.scope.length ? [CoreSyncStateStore.userEpochKey(registration.name, tenant, user)] : []),
|
|
427
|
+
]);
|
|
428
|
+
const epochs = await this.storeCall(() => this.stateStore.epochs(keys));
|
|
429
|
+
return { epochs: stableHash([...epochs.entries()].sort(([a], [b]) => a.localeCompare(b))), fingerprint, user };
|
|
430
|
+
}
|
|
431
|
+
async push(model, body, currentUser) {
|
|
432
|
+
const registration = this.registration(model);
|
|
433
|
+
const items = this.pushItems(body);
|
|
434
|
+
const results = [];
|
|
435
|
+
for (const item of items) {
|
|
436
|
+
results.push(await this.pushItem(registration, item, currentUser));
|
|
437
|
+
}
|
|
438
|
+
return { results };
|
|
439
|
+
}
|
|
440
|
+
pushItems(body) {
|
|
441
|
+
const invalid = (reason) => new BadRequestException(`${ErrorCode.SYNC_INVALID_REQUEST}: ${reason}`);
|
|
442
|
+
const items = body?.items;
|
|
443
|
+
if (!Array.isArray(items)) {
|
|
444
|
+
throw invalid('the body must be { "items": [ … ] }');
|
|
445
|
+
}
|
|
446
|
+
const max = positiveNumber(this.config.push?.maxItems, SYNC_DEFAULTS.maxItems);
|
|
447
|
+
if (items.length > max) {
|
|
448
|
+
throw invalid(`at most ${max} items per push`);
|
|
449
|
+
}
|
|
450
|
+
return items.map((item, index) => {
|
|
451
|
+
const at = `items[${index}]`;
|
|
452
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
453
|
+
throw invalid(`${at} must be an object`);
|
|
454
|
+
}
|
|
455
|
+
const { baseVersion, data, fields, id, mutationId, op } = item;
|
|
456
|
+
if (typeof mutationId !== 'string' || !mutationId || mutationId.length > MAX_MUTATION_ID_LENGTH) {
|
|
457
|
+
throw invalid(`${at}.mutationId must be a string of 1 to ${MAX_MUTATION_ID_LENGTH} characters`);
|
|
458
|
+
}
|
|
459
|
+
if (op !== 'upsert' && op !== 'delete') {
|
|
460
|
+
throw invalid(`${at}.op must be "upsert" or "delete"`);
|
|
461
|
+
}
|
|
462
|
+
if (typeof id !== 'string' || !id || id.length > MAX_ID_LENGTH) {
|
|
463
|
+
throw invalid(`${at}.id must be a string of 1 to ${MAX_ID_LENGTH} characters`);
|
|
464
|
+
}
|
|
465
|
+
if (baseVersion !== undefined && (!Number.isInteger(baseVersion) || baseVersion < 1)) {
|
|
466
|
+
throw invalid(`${at}.baseVersion must be a positive integer`);
|
|
467
|
+
}
|
|
468
|
+
if (op === 'upsert' && (!data || typeof data !== 'object' || Array.isArray(data))) {
|
|
469
|
+
throw invalid(`${at}.data must be an object`);
|
|
470
|
+
}
|
|
471
|
+
if (fields !== undefined && (!Array.isArray(fields) || fields.some((field) => typeof field !== 'string'))) {
|
|
472
|
+
throw invalid(`${at}.fields must be a list of field names`);
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
...(baseVersion === undefined ? {} : { baseVersion: baseVersion }),
|
|
476
|
+
...(op === 'upsert' ? { data: data } : {}),
|
|
477
|
+
...(fields === undefined ? {} : { fields: fields }),
|
|
478
|
+
id,
|
|
479
|
+
mutationId,
|
|
480
|
+
op,
|
|
481
|
+
};
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
async pushItem(registration, item, currentUser) {
|
|
485
|
+
if (!isSyncId(item.id, registration.idFormat)) {
|
|
486
|
+
return reply(item, 'rejected', { code: 'INVALID_ID' });
|
|
487
|
+
}
|
|
488
|
+
const key = CoreSyncMutationStore.key(registration.name, String(getStringIds(currentUser?.id)), item.id, item.mutationId);
|
|
489
|
+
let claim;
|
|
490
|
+
try {
|
|
491
|
+
claim = await this.mutationStore.claim(key);
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
this.logger.warn(`Sync: mutation store unavailable (${error?.message ?? error})`);
|
|
495
|
+
return reply(item, 'retry');
|
|
496
|
+
}
|
|
497
|
+
if (claim.kind === 'busy') {
|
|
498
|
+
return reply(item, 'retry');
|
|
499
|
+
}
|
|
500
|
+
if (claim.kind === 'done') {
|
|
501
|
+
return this.repeatedResult(registration, item, claim.outcome, currentUser);
|
|
502
|
+
}
|
|
503
|
+
let result;
|
|
504
|
+
try {
|
|
505
|
+
result = await this.applyItem(registration, item, currentUser);
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
this.logger.warn(`Sync: ${registration.name} ${item.id} not applied, client retries (${error?.message ?? error})`);
|
|
509
|
+
result = reply(item, 'retry');
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
if (result.status === 'retry') {
|
|
513
|
+
await this.mutationStore.release(key);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
await this.mutationStore.complete(key, { code: result.code, status: result.status, version: result.version });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
this.logger.warn(`Sync: outcome of a mutation not recorded (${error?.message ?? error})`);
|
|
521
|
+
}
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
async repeatedResult(registration, item, outcome, currentUser) {
|
|
525
|
+
const result = reply(item, outcome.status, {
|
|
526
|
+
...(outcome.code ? { code: outcome.code } : {}),
|
|
527
|
+
...(outcome.version === undefined ? {} : { version: outcome.version }),
|
|
528
|
+
});
|
|
529
|
+
if (outcome.status === 'conflict' || outcome.status === 'merged') {
|
|
530
|
+
const record = await this.findVisible(registration, item.id, currentUser).catch(() => undefined);
|
|
531
|
+
if (record) {
|
|
532
|
+
result.record = record;
|
|
533
|
+
result.version = record.version;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return result;
|
|
537
|
+
}
|
|
538
|
+
async applyItem(registration, item, currentUser) {
|
|
539
|
+
const current = await this.findVisible(registration, item.id, currentUser);
|
|
540
|
+
if (item.op === 'delete') {
|
|
541
|
+
return this.applyDelete(registration, item, current, currentUser);
|
|
542
|
+
}
|
|
543
|
+
if (item.baseVersion === undefined) {
|
|
544
|
+
return current ? conflict(item, current) : this.applyCreate(registration, item, currentUser);
|
|
545
|
+
}
|
|
546
|
+
if (!current) {
|
|
547
|
+
return reply(item, 'not_found');
|
|
548
|
+
}
|
|
549
|
+
if (current.deletedAt) {
|
|
550
|
+
return conflict(item, current);
|
|
551
|
+
}
|
|
552
|
+
return this.applyUpdate(registration, item, currentUser);
|
|
553
|
+
}
|
|
554
|
+
async applyCreate(registration, item, currentUser) {
|
|
555
|
+
let input;
|
|
556
|
+
try {
|
|
557
|
+
input = await this.validateInput(registration.createInput, item.data);
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
561
|
+
}
|
|
562
|
+
if (registration.tenantScoped && resolveTenantScope().kind === 'tenants') {
|
|
563
|
+
return reply(item, 'rejected', { code: 'FORBIDDEN' });
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
await registration.service.create(input, {
|
|
567
|
+
createId: item.id,
|
|
568
|
+
currentUser,
|
|
569
|
+
inputType: registration.createInput,
|
|
570
|
+
roles: [...registration.roles.create],
|
|
571
|
+
});
|
|
572
|
+
return reply(item, 'applied', { version: 1 });
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
async applyUpdate(registration, item, currentUser) {
|
|
579
|
+
let input;
|
|
580
|
+
try {
|
|
581
|
+
input = await this.validateInput(registration.updateInput, item.data);
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
await this.update(registration, item.id, input, item.baseVersion, currentUser);
|
|
588
|
+
return reply(item, 'applied', { version: item.baseVersion + 1 });
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
if (error instanceof VersionConflictError) {
|
|
592
|
+
return this.resolveUpdateConflict(registration, item, input, currentUser);
|
|
593
|
+
}
|
|
594
|
+
if (error instanceof NotFoundException) {
|
|
595
|
+
const now = await this.findVisible(registration, item.id, currentUser);
|
|
596
|
+
return now ? conflict(item, now) : reply(item, 'not_found');
|
|
597
|
+
}
|
|
598
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
async resolveUpdateConflict(registration, item, input, currentUser) {
|
|
602
|
+
switch (registration.strategy) {
|
|
603
|
+
case 'lww':
|
|
604
|
+
return this.reapply(registration, item, input, 'applied', currentUser);
|
|
605
|
+
case 'merge': {
|
|
606
|
+
const changed = (item.fields ?? Object.keys(item.data ?? {})).filter((field) => field in input);
|
|
607
|
+
if (changed.some((field) => registration.fieldStrategies[field] === 'reject')) {
|
|
608
|
+
return this.currentConflict(registration, item, currentUser);
|
|
609
|
+
}
|
|
610
|
+
return this.reapply(registration, item, pick(input, changed), 'merged', currentUser);
|
|
611
|
+
}
|
|
612
|
+
case 'custom':
|
|
613
|
+
return this.applyCustom(registration, item, currentUser);
|
|
614
|
+
default:
|
|
615
|
+
return this.currentConflict(registration, item, currentUser);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
async applyDelete(registration, item, current, currentUser) {
|
|
619
|
+
if (item.baseVersion === undefined) {
|
|
620
|
+
return reply(item, 'rejected', {
|
|
621
|
+
code: 'VALIDATION_FAILED',
|
|
622
|
+
details: [{ constraints: { isDefined: 'baseVersion is required for a delete' }, property: 'baseVersion' }],
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
if (!current) {
|
|
626
|
+
return reply(item, 'not_found');
|
|
627
|
+
}
|
|
628
|
+
if (current.deletedAt) {
|
|
629
|
+
return reply(item, 'applied', { version: current.version });
|
|
630
|
+
}
|
|
631
|
+
try {
|
|
632
|
+
await this.remove(registration, item.id, item.baseVersion, currentUser);
|
|
633
|
+
return reply(item, 'applied', { version: item.baseVersion + 1 });
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
if (error instanceof VersionConflictError) {
|
|
637
|
+
switch (registration.strategy) {
|
|
638
|
+
case 'lww':
|
|
639
|
+
return this.reapplyDelete(registration, item, currentUser);
|
|
640
|
+
case 'custom':
|
|
641
|
+
return this.applyCustom(registration, item, currentUser);
|
|
642
|
+
default:
|
|
643
|
+
return this.currentConflict(registration, item, currentUser);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (error instanceof NotFoundException) {
|
|
647
|
+
const now = await this.findVisible(registration, item.id, currentUser);
|
|
648
|
+
return now?.deletedAt ? reply(item, 'applied', { version: now.version }) : reply(item, 'not_found');
|
|
649
|
+
}
|
|
650
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async applyCustom(registration, item, currentUser) {
|
|
654
|
+
const server = await this.findVisible(registration, item.id, currentUser);
|
|
655
|
+
if (!server) {
|
|
656
|
+
return reply(item, 'not_found');
|
|
657
|
+
}
|
|
658
|
+
if (server.deletedAt) {
|
|
659
|
+
return conflict(item, server);
|
|
660
|
+
}
|
|
661
|
+
const context = {
|
|
662
|
+
change: { baseVersion: item.baseVersion, data: item.data, fields: item.fields, op: item.op },
|
|
663
|
+
currentUser,
|
|
664
|
+
id: item.id,
|
|
665
|
+
model: registration.name,
|
|
666
|
+
server,
|
|
667
|
+
};
|
|
668
|
+
const resolution = await registration.service.resolveSyncConflict(context);
|
|
669
|
+
if (!resolution || 'refuse' in resolution) {
|
|
670
|
+
return reply(item, 'rejected', { code: 'CUSTOM_REFUSED' });
|
|
671
|
+
}
|
|
672
|
+
if ('delete' in resolution) {
|
|
673
|
+
return this.reapplyDelete(registration, item, currentUser);
|
|
674
|
+
}
|
|
675
|
+
let input;
|
|
676
|
+
try {
|
|
677
|
+
input = await this.validateInput(registration.updateInput, resolution.data);
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
681
|
+
}
|
|
682
|
+
return this.reapply(registration, item, input, 'merged', currentUser);
|
|
683
|
+
}
|
|
684
|
+
async reapply(registration, item, input, status, currentUser) {
|
|
685
|
+
for (let attempt = 0; attempt < SYNC_REAPPLY_ATTEMPTS; attempt++) {
|
|
686
|
+
const current = await this.findVisible(registration, item.id, currentUser);
|
|
687
|
+
if (!current) {
|
|
688
|
+
return reply(item, 'not_found');
|
|
689
|
+
}
|
|
690
|
+
if (current.deletedAt) {
|
|
691
|
+
return conflict(item, current);
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
await this.update(registration, item.id, input, current.version, currentUser);
|
|
695
|
+
if (status === 'applied') {
|
|
696
|
+
return reply(item, 'applied', { version: current.version + 1 });
|
|
697
|
+
}
|
|
698
|
+
const record = await this.findVisible(registration, item.id, currentUser);
|
|
699
|
+
return reply(item, 'merged', { ...(record ? { record } : {}), version: current.version + 1 });
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
if (!(error instanceof VersionConflictError) && !(error instanceof NotFoundException)) {
|
|
703
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return reply(item, 'retry');
|
|
708
|
+
}
|
|
709
|
+
async reapplyDelete(registration, item, currentUser) {
|
|
710
|
+
for (let attempt = 0; attempt < SYNC_REAPPLY_ATTEMPTS; attempt++) {
|
|
711
|
+
const current = await this.findVisible(registration, item.id, currentUser);
|
|
712
|
+
if (!current) {
|
|
713
|
+
return reply(item, 'not_found');
|
|
714
|
+
}
|
|
715
|
+
if (current.deletedAt) {
|
|
716
|
+
return reply(item, 'applied', { version: current.version });
|
|
717
|
+
}
|
|
718
|
+
try {
|
|
719
|
+
await this.remove(registration, item.id, current.version, currentUser);
|
|
720
|
+
return reply(item, 'applied', { version: current.version + 1 });
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
if (!(error instanceof VersionConflictError) && !(error instanceof NotFoundException)) {
|
|
724
|
+
return this.writeFailure(registration, item, error, currentUser);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return reply(item, 'retry');
|
|
729
|
+
}
|
|
730
|
+
async currentConflict(registration, item, currentUser) {
|
|
731
|
+
const current = await this.findVisible(registration, item.id, currentUser);
|
|
732
|
+
return current ? conflict(item, current) : reply(item, 'not_found');
|
|
733
|
+
}
|
|
734
|
+
update(registration, id, input, expectedVersion, currentUser) {
|
|
735
|
+
return registration.service.update(id, input, {
|
|
736
|
+
currentUser,
|
|
737
|
+
expectedVersion,
|
|
738
|
+
inputType: registration.updateInput,
|
|
739
|
+
roles: [...registration.roles.update],
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
remove(registration, id, expectedVersion, currentUser) {
|
|
743
|
+
return registration.service.delete(id, { currentUser, expectedVersion, roles: [...registration.roles.delete] });
|
|
744
|
+
}
|
|
745
|
+
async findVisible(registration, id, currentUser) {
|
|
746
|
+
const [raw] = (await this.readSystemFields(registration, [id])).values();
|
|
747
|
+
if (!raw) {
|
|
748
|
+
return undefined;
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
const record = await registration.service.get(String(raw.id), {
|
|
752
|
+
currentUser,
|
|
753
|
+
includeTombstones: true,
|
|
754
|
+
roles: [...registration.roles.pull],
|
|
755
|
+
});
|
|
756
|
+
return this.toSyncRecord(registration, record, raw, currentUser);
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
if (error instanceof NotFoundException ||
|
|
760
|
+
error instanceof ForbiddenException ||
|
|
761
|
+
error instanceof UnauthorizedException) {
|
|
762
|
+
return undefined;
|
|
763
|
+
}
|
|
764
|
+
throw error;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
async validateInput(inputType, data) {
|
|
768
|
+
const value = await new MapAndValidatePipe().transform(data && typeof data === 'object' ? { ...data } : {}, { metatype: inputType, type: 'body' });
|
|
769
|
+
return { ...value };
|
|
770
|
+
}
|
|
771
|
+
async writeFailure(registration, item, error, currentUser) {
|
|
772
|
+
if ((error instanceof DuplicateKeyError && error.primaryKey) || isDuplicateIdError(error)) {
|
|
773
|
+
const now = await this.findVisible(registration, item.id, currentUser);
|
|
774
|
+
return now ? conflict(item, now) : reply(item, 'rejected', { code: 'ID_UNAVAILABLE' });
|
|
775
|
+
}
|
|
776
|
+
if (error instanceof ForbiddenException || error instanceof UnauthorizedException) {
|
|
777
|
+
return reply(item, 'rejected', { code: 'FORBIDDEN' });
|
|
778
|
+
}
|
|
779
|
+
if (error instanceof BadRequestException) {
|
|
780
|
+
const response = error.getResponse();
|
|
781
|
+
const details = error instanceof DuplicateKeyError
|
|
782
|
+
? undefined
|
|
783
|
+
: response && typeof response === 'object'
|
|
784
|
+
? omit(response, ['error', 'statusCode'])
|
|
785
|
+
: undefined;
|
|
786
|
+
return reply(item, 'rejected', { code: 'VALIDATION_FAILED', ...(details ? { details } : {}) });
|
|
787
|
+
}
|
|
788
|
+
if (error instanceof QueryCastError || error instanceof UnsupportedQueryError || isMongoDuplicate(error)) {
|
|
789
|
+
return reply(item, 'rejected', { code: 'VALIDATION_FAILED' });
|
|
790
|
+
}
|
|
791
|
+
throw error;
|
|
792
|
+
}
|
|
793
|
+
async rateLimit(kind, currentUser) {
|
|
794
|
+
const all = this.config.rateLimit;
|
|
795
|
+
const own = all === false ? false : all?.[kind];
|
|
796
|
+
if (own === false) {
|
|
797
|
+
return undefined;
|
|
798
|
+
}
|
|
799
|
+
const defaults = kind === 'pull' ? SYNC_DEFAULTS.rateLimitPull : SYNC_DEFAULTS.rateLimitPush;
|
|
800
|
+
const max = positiveNumber(own?.max, defaults.max);
|
|
801
|
+
const windowSeconds = positiveNumber(own?.windowSeconds, defaults.windowSeconds);
|
|
802
|
+
const hit = await this.getRateLimitStore().hit(`${kind}:${String(getStringIds(currentUser?.id))}`, windowSeconds);
|
|
803
|
+
return hit.count > max ? Math.max(1, hit.resetIn) : undefined;
|
|
804
|
+
}
|
|
805
|
+
getRateLimitStore() {
|
|
806
|
+
this.rateLimitStore ??= this.redisService?.enabled
|
|
807
|
+
? new RedisRateLimitStore(this.redisService, 'sync')
|
|
808
|
+
: new InMemoryRateLimitStore(10_000);
|
|
809
|
+
return this.rateLimitStore;
|
|
810
|
+
}
|
|
811
|
+
assertPushRequest(request) {
|
|
812
|
+
const contentType = String(request?.headers?.['content-type'] ?? '');
|
|
813
|
+
if (!/^application\/json\b/i.test(contentType)) {
|
|
814
|
+
throw new BadRequestException(`${ErrorCode.SYNC_INVALID_REQUEST}: Content-Type must be application/json`);
|
|
815
|
+
}
|
|
816
|
+
const config = ConfigService.configFastButReadOnly ?? {};
|
|
817
|
+
if (!isCookiesEnabled(config.cookies)) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (String(request?.headers?.['sec-fetch-site'] ?? '').toLowerCase() === 'cross-site') {
|
|
821
|
+
throw new ForbiddenException(ErrorCode.SYNC_ORIGIN_REFUSED);
|
|
822
|
+
}
|
|
823
|
+
const origin = request?.headers?.origin;
|
|
824
|
+
if (typeof origin === 'string' && origin && !this.isTrustedOrigin(origin)) {
|
|
825
|
+
throw new ForbiddenException(ErrorCode.SYNC_ORIGIN_REFUSED);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
isTrustedOrigin(origin) {
|
|
829
|
+
const config = ConfigService.configFastButReadOnly ?? {};
|
|
830
|
+
const cors = buildCorsConfig(config);
|
|
831
|
+
if (cors.origin === true) {
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
const corsConfig = typeof config.cors === 'object' ? config.cors : {};
|
|
835
|
+
const { appUrl, baseUrl } = resolveServerUrls({
|
|
836
|
+
appUrl: config.appUrl,
|
|
837
|
+
baseUrl: config.baseUrl,
|
|
838
|
+
deriveAppUrl: corsConfig?.deriveAppUrl,
|
|
839
|
+
env: config.env,
|
|
840
|
+
});
|
|
841
|
+
const trusted = new Set(Array.isArray(cors.origin) ? cors.origin : []);
|
|
842
|
+
for (const url of [appUrl, baseUrl]) {
|
|
843
|
+
const value = originOf(url);
|
|
844
|
+
if (value) {
|
|
845
|
+
trusted.add(value);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return trusted.has(originOf(origin) ?? origin);
|
|
849
|
+
}
|
|
850
|
+
async bumpAccessEpoch(model, tenantId, userId) {
|
|
851
|
+
const tenant = tenantId ?? '-';
|
|
852
|
+
await this.stateStore.bump(userId
|
|
853
|
+
? CoreSyncStateStore.userEpochKey(model, tenant, userId)
|
|
854
|
+
: CoreSyncStateStore.tenantEpochKey(model, tenant));
|
|
855
|
+
}
|
|
856
|
+
async recordMoved(move) {
|
|
857
|
+
if (!this.models.has(move.model)) {
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (move.tenantChanged) {
|
|
861
|
+
await this.bumpAccessEpoch(move.model, move.tenantId);
|
|
862
|
+
}
|
|
863
|
+
for (const user of move.usersLost) {
|
|
864
|
+
await this.bumpAccessEpoch(move.model, move.tenantId, user);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async tenantInvalidated(tenantId) {
|
|
868
|
+
for (const registration of this.models.values()) {
|
|
869
|
+
if (registration.tenantScoped) {
|
|
870
|
+
await this.bumpAccessEpoch(registration.name, tenantId);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
advanceHorizon(model, at) {
|
|
875
|
+
return this.stateStore.advanceHorizon(model, at);
|
|
876
|
+
}
|
|
877
|
+
recordChanged(change) {
|
|
878
|
+
this.notifyChange(change.model, change.tenantIds);
|
|
879
|
+
}
|
|
880
|
+
notifyChange(model, tenantIds = []) {
|
|
881
|
+
if (!this.models.has(model)) {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
this.hintService?.notify(model, tenantIds.length ? tenantIds : [undefined]);
|
|
885
|
+
}
|
|
886
|
+
hintsEnabled(channel) {
|
|
887
|
+
const hints = this.config.hints;
|
|
888
|
+
if (hints === false || !this.hintService?.enabled) {
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
if (hints && typeof hints === 'object' && hints[channel] === false) {
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
return channel === 'sse' || ConfigService.configFastButReadOnly?.graphQl !== false;
|
|
895
|
+
}
|
|
896
|
+
async hintFilter(currentUser, models) {
|
|
897
|
+
const everywhere = Symbol('everywhere');
|
|
898
|
+
const scopes = new Map();
|
|
899
|
+
const wanted = models?.length ? new Set(models) : undefined;
|
|
900
|
+
for (const registration of this.models.values()) {
|
|
901
|
+
if (wanted && !wanted.has(registration.name)) {
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
try {
|
|
905
|
+
await registration.service.checkRights(undefined, currentUser, { roles: [...registration.roles.pull] });
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
const tenants = registration.tenantScoped ? allowedTenantIds(registration.name) : undefined;
|
|
911
|
+
scopes.set(registration.name, tenants === undefined ? everywhere : [...tenants].map(String));
|
|
912
|
+
}
|
|
913
|
+
return (hint) => {
|
|
914
|
+
const scope = scopes.get(hint.model);
|
|
915
|
+
if (scope === undefined) {
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
return scope === everywhere || (!!hint.tenantId && scope.includes(hint.tenantId));
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
hintPayload(hint) {
|
|
922
|
+
return { at: new Date(hint.at), model: hint.model };
|
|
923
|
+
}
|
|
924
|
+
async openHintStream(request, response, currentUser, models) {
|
|
925
|
+
const accepts = await this.hintFilter(currentUser, models);
|
|
926
|
+
const context = RequestContext.get();
|
|
927
|
+
const carrier = {};
|
|
928
|
+
const closeListeners = [];
|
|
929
|
+
let closed = false;
|
|
930
|
+
let unsubscribe = () => undefined;
|
|
931
|
+
let keepalive;
|
|
932
|
+
const write = (chunk) => {
|
|
933
|
+
if (!closed) {
|
|
934
|
+
response.write(chunk);
|
|
935
|
+
response.flush?.();
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
const end = () => {
|
|
939
|
+
if (closed) {
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
closed = true;
|
|
943
|
+
unsubscribe();
|
|
944
|
+
if (keepalive) {
|
|
945
|
+
clearInterval(keepalive);
|
|
946
|
+
}
|
|
947
|
+
for (const listener of closeListeners.splice(0)) {
|
|
948
|
+
listener();
|
|
949
|
+
}
|
|
950
|
+
response.end?.();
|
|
951
|
+
};
|
|
952
|
+
request?.once?.('close', end);
|
|
953
|
+
response.once?.('close', end);
|
|
954
|
+
registerWsConnection({
|
|
955
|
+
carrier,
|
|
956
|
+
reauthenticate: () => undefined,
|
|
957
|
+
socket: {
|
|
958
|
+
close: () => end(),
|
|
959
|
+
once: (event, listener) => {
|
|
960
|
+
if (event === 'close') {
|
|
961
|
+
closeListeners.push(listener);
|
|
962
|
+
}
|
|
963
|
+
},
|
|
964
|
+
},
|
|
965
|
+
user: currentUser,
|
|
966
|
+
});
|
|
967
|
+
const connection = getWsConnection(carrier);
|
|
968
|
+
if (connection && context) {
|
|
969
|
+
noteWsConnectionTenant(connection, {
|
|
970
|
+
isAdminBypass: context.isAdminBypass,
|
|
971
|
+
tenantId: context.tenantId,
|
|
972
|
+
tenantIds: context.tenantIds,
|
|
973
|
+
tenantRole: context.tenantRole,
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
if (request?.destroyed || response?.destroyed || response?.writableEnded) {
|
|
977
|
+
end();
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
response.status?.(200);
|
|
981
|
+
response.setHeader?.('Content-Type', 'text/event-stream; charset=utf-8');
|
|
982
|
+
response.setHeader?.('Cache-Control', 'no-cache, no-transform');
|
|
983
|
+
response.setHeader?.('Connection', 'keep-alive');
|
|
984
|
+
response.setHeader?.('X-Accel-Buffering', 'no');
|
|
985
|
+
response.flushHeaders?.();
|
|
986
|
+
write('retry: 3000\n: hints\n\n');
|
|
987
|
+
unsubscribe =
|
|
988
|
+
this.hintService?.subscribe((hint) => {
|
|
989
|
+
if (closed || !accepts(hint)) {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const deliver = () => write(`event: change\ndata: ${JSON.stringify(this.hintPayload(hint))}\n\n`);
|
|
993
|
+
if (!connection) {
|
|
994
|
+
deliver();
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
void ensureWsConnectionCurrent(connection).then((current) => {
|
|
998
|
+
if (current) {
|
|
999
|
+
deliver();
|
|
1000
|
+
}
|
|
1001
|
+
});
|
|
1002
|
+
}) ?? unsubscribe;
|
|
1003
|
+
keepalive = setInterval(() => {
|
|
1004
|
+
if (response?.destroyed || response?.writableEnded) {
|
|
1005
|
+
end();
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
write(': keepalive\n\n');
|
|
1009
|
+
}, SYNC_HINT_KEEPALIVE_MS);
|
|
1010
|
+
keepalive.unref?.();
|
|
1011
|
+
}
|
|
1012
|
+
async hintIterator(currentUser, models) {
|
|
1013
|
+
const accepts = await this.hintFilter(currentUser, models);
|
|
1014
|
+
const queue = [];
|
|
1015
|
+
const waiting = [];
|
|
1016
|
+
let done = false;
|
|
1017
|
+
const unsubscribe = this.hintService?.subscribe((hint) => {
|
|
1018
|
+
if (done || !accepts(hint)) {
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const value = { syncHint: this.hintPayload(hint) };
|
|
1022
|
+
const next = waiting.shift();
|
|
1023
|
+
if (next) {
|
|
1024
|
+
next({ done: false, value });
|
|
1025
|
+
}
|
|
1026
|
+
else if (queue.length < 100) {
|
|
1027
|
+
queue.push(value);
|
|
1028
|
+
}
|
|
1029
|
+
}) ?? (() => undefined);
|
|
1030
|
+
const finish = () => {
|
|
1031
|
+
done = true;
|
|
1032
|
+
unsubscribe();
|
|
1033
|
+
for (const next of waiting.splice(0)) {
|
|
1034
|
+
next({ done: true, value: undefined });
|
|
1035
|
+
}
|
|
1036
|
+
queue.length = 0;
|
|
1037
|
+
return { done: true, value: undefined };
|
|
1038
|
+
};
|
|
1039
|
+
const iterator = {
|
|
1040
|
+
next: () => {
|
|
1041
|
+
if (done) {
|
|
1042
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
1043
|
+
}
|
|
1044
|
+
const value = queue.shift();
|
|
1045
|
+
return value ? Promise.resolve({ done: false, value }) : new Promise((resolve) => waiting.push(resolve));
|
|
1046
|
+
},
|
|
1047
|
+
return: () => Promise.resolve(finish()),
|
|
1048
|
+
throw: (error) => {
|
|
1049
|
+
finish();
|
|
1050
|
+
return Promise.reject(error);
|
|
1051
|
+
},
|
|
1052
|
+
[Symbol.asyncIterator]() {
|
|
1053
|
+
return iterator;
|
|
1054
|
+
},
|
|
1055
|
+
};
|
|
1056
|
+
return iterator;
|
|
1057
|
+
}
|
|
1058
|
+
tombstoneDays() {
|
|
1059
|
+
const value = this.config.retention?.tombstonesDays;
|
|
1060
|
+
if (value === false) {
|
|
1061
|
+
return false;
|
|
1062
|
+
}
|
|
1063
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : SYNC_DEFAULTS.tombstonesDays;
|
|
1064
|
+
}
|
|
1065
|
+
schedulePurge() {
|
|
1066
|
+
if (this.purgeTimer || this.tombstoneDays() === false) {
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const run = () => {
|
|
1070
|
+
this.purgeRunning ??= this.leasedPurge().finally(() => {
|
|
1071
|
+
this.purgeRunning = undefined;
|
|
1072
|
+
});
|
|
1073
|
+
};
|
|
1074
|
+
run();
|
|
1075
|
+
this.purgeTimer = setInterval(run, this.purgeIntervalMs);
|
|
1076
|
+
this.purgeTimer.unref?.();
|
|
1077
|
+
}
|
|
1078
|
+
async leasedPurge() {
|
|
1079
|
+
try {
|
|
1080
|
+
const locks = await this.purgeLeaseStore();
|
|
1081
|
+
const ttlSeconds = Math.max(1, Math.floor(this.purgeIntervalMs / 1000));
|
|
1082
|
+
if (locks && !(await locks.tryAcquire('sync-purge', this.purgeOwner, ttlSeconds))) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
await this.purgeTombstones();
|
|
1086
|
+
}
|
|
1087
|
+
catch (error) {
|
|
1088
|
+
this.logger.warn(`Sync tombstone purge failed, trying again later: ${error?.message ?? error}`);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
async purgeLeaseStore() {
|
|
1092
|
+
if (this.redisService?.enabled) {
|
|
1093
|
+
const client = this.redisService.getClient();
|
|
1094
|
+
const key = (name) => this.redisService.key('sync-lock', name);
|
|
1095
|
+
return {
|
|
1096
|
+
release: async () => false,
|
|
1097
|
+
renew: async () => false,
|
|
1098
|
+
tryAcquire: async (name, owner, ttlSeconds) => (await client.set(key(name), owner, 'EX', ttlSeconds, 'NX')) === 'OK',
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
const sqlLocks = await defaultSqlLockStore('sync');
|
|
1102
|
+
if (sqlLocks) {
|
|
1103
|
+
return sqlLocks;
|
|
1104
|
+
}
|
|
1105
|
+
const connection = getCronJobsInfrastructure().connection;
|
|
1106
|
+
return connection?.db ? cronMongoLocks(connection.db.collection(CRON_LOCK_COLLECTION)) : undefined;
|
|
1107
|
+
}
|
|
1108
|
+
async purgeTombstones(options = {}) {
|
|
1109
|
+
const days = this.tombstoneDays();
|
|
1110
|
+
if (!options.before && days === false) {
|
|
1111
|
+
return {};
|
|
1112
|
+
}
|
|
1113
|
+
const before = options.before ?? new Date(Date.now() - days * 86_400_000);
|
|
1114
|
+
const removed = {};
|
|
1115
|
+
for (const registration of this.models.values()) {
|
|
1116
|
+
if (options.model && registration.name !== options.model) {
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
await this.stateStore.advanceHorizon(registration.name, before);
|
|
1120
|
+
removed[registration.name] = await this.purgeModel(registration, before);
|
|
1121
|
+
}
|
|
1122
|
+
const total = Object.values(removed).reduce((sum, count) => sum + count, 0);
|
|
1123
|
+
if (total) {
|
|
1124
|
+
this.logger.log(`Sync: ${total} tombstone(s) older than ${before.toISOString()} removed`);
|
|
1125
|
+
}
|
|
1126
|
+
return removed;
|
|
1127
|
+
}
|
|
1128
|
+
async purgeModel(registration, before) {
|
|
1129
|
+
let removed = 0;
|
|
1130
|
+
let after;
|
|
1131
|
+
for (;;) {
|
|
1132
|
+
const batch = await registration.store.find({ deletedAt: { $lt: before }, ...(after ? { id: { $gt: after } } : {}) }, { includeTombstones: true, limit: SYNC_PURGE_BATCH, select: ['id'], sort: { id: 1 } });
|
|
1133
|
+
if (!batch.length) {
|
|
1134
|
+
return removed;
|
|
1135
|
+
}
|
|
1136
|
+
removed += await registration.store.deleteMany({ deletedAt: { $lt: before }, id: { $in: batch.map((record) => record.id) } }, { hardDelete: true, includeTombstones: true });
|
|
1137
|
+
if (batch.length < SYNC_PURGE_BATCH) {
|
|
1138
|
+
return removed;
|
|
1139
|
+
}
|
|
1140
|
+
after = batch[batch.length - 1].id;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
resync(reason) {
|
|
1144
|
+
throw new SyncResyncRequiredException(reason);
|
|
1145
|
+
}
|
|
1146
|
+
async storeCall(call) {
|
|
1147
|
+
try {
|
|
1148
|
+
return await call();
|
|
1149
|
+
}
|
|
1150
|
+
catch (error) {
|
|
1151
|
+
if (error instanceof HttpException) {
|
|
1152
|
+
throw error;
|
|
1153
|
+
}
|
|
1154
|
+
this.logger.error(`Sync: store unavailable (${error?.message ?? error})`, error?.stack);
|
|
1155
|
+
throw new ServiceUnavailableException(ErrorCode.SYNC_STORE_UNAVAILABLE);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
CoreSyncService = CoreSyncService_1 = __decorate([
|
|
1160
|
+
Injectable(),
|
|
1161
|
+
__param(0, InjectStore(SYNC_MUTATION_MODEL)),
|
|
1162
|
+
__param(1, InjectStore(SYNC_STATE_MODEL)),
|
|
1163
|
+
__param(4, Optional()),
|
|
1164
|
+
__param(5, Optional()),
|
|
1165
|
+
__metadata("design:paramtypes", [Object, Object, DiscoveryService,
|
|
1166
|
+
ModuleRef,
|
|
1167
|
+
CoreRedisService,
|
|
1168
|
+
CoreSyncHintService])
|
|
1169
|
+
], CoreSyncService);
|
|
1170
|
+
export { CoreSyncService };
|
|
1171
|
+
function reply(item, status, extra = {}) {
|
|
1172
|
+
return { id: item.id, mutationId: item.mutationId, status, ...extra };
|
|
1173
|
+
}
|
|
1174
|
+
function conflict(item, record) {
|
|
1175
|
+
return reply(item, 'conflict', { record, version: record.version });
|
|
1176
|
+
}
|
|
1177
|
+
function recordId(record) {
|
|
1178
|
+
const id = record?.id ?? record?._id;
|
|
1179
|
+
return id === undefined || id === null ? '' : String(getStringIds(id));
|
|
1180
|
+
}
|
|
1181
|
+
function initialPosition(order) {
|
|
1182
|
+
return order === 'seq' ? { seq: 0 } : { at: 0, id: '' };
|
|
1183
|
+
}
|
|
1184
|
+
function pick(input, fields) {
|
|
1185
|
+
return Object.fromEntries(fields.filter((field) => field in input).map((field) => [field, input[field]]));
|
|
1186
|
+
}
|
|
1187
|
+
function omit(value, keys) {
|
|
1188
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key)));
|
|
1189
|
+
}
|
|
1190
|
+
function positiveNumber(value, fallback) {
|
|
1191
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
1192
|
+
}
|
|
1193
|
+
function originOf(url) {
|
|
1194
|
+
if (!url) {
|
|
1195
|
+
return undefined;
|
|
1196
|
+
}
|
|
1197
|
+
try {
|
|
1198
|
+
const parsed = new URL(url);
|
|
1199
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.origin : undefined;
|
|
1200
|
+
}
|
|
1201
|
+
catch {
|
|
1202
|
+
return undefined;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
function isMongoDuplicate(error) {
|
|
1206
|
+
return error?.code === 11000;
|
|
1207
|
+
}
|
|
1208
|
+
//# sourceMappingURL=core-sync.service.js.map
|