@lenne.tech/nest-server 12.1.1 → 12.3.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 +31 -4
- package/.claude/rules/configurable-features.md +8 -6
- package/.claude/rules/package-management.md +29 -2
- package/.claude/rules/persistence.md +27 -1
- package/.claude/rules/testing.md +80 -15
- package/.claude/rules/versioning.md +13 -2
- package/CLAUDE.md +7 -1
- package/FRAMEWORK-API.md +20 -4
- package/README.md +20 -2
- package/dist/config.env.js +2 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +4 -0
- package/dist/core/common/helpers/graphql-ws-connection.helper.js +17 -3
- package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +49 -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 +4 -2
- 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/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.js +101 -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-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/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/ai/core-ai-mcp.controller.d.ts +3 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +29 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.constants.d.ts +2 -0
- package/dist/core/modules/ai/core-ai.constants.js +2 -0
- package/dist/core/modules/ai/core-ai.constants.js.map +1 -1
- package/dist/core/modules/ai/core-ai.controller.d.ts +6 -0
- package/dist/core/modules/ai/core-ai.controller.js +31 -1
- package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
- package/dist/core/modules/ai/core-ai.module.js +8 -1
- package/dist/core/modules/ai/core-ai.module.js.map +1 -1
- package/dist/core/modules/ai/core-ai.resolver.d.ts +6 -0
- package/dist/core/modules/ai/core-ai.resolver.js +32 -0
- package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +65 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +6 -3
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +48 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.d.ts +9 -0
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js +54 -0
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js.map +1 -0
- package/dist/core/modules/ai/index.d.ts +7 -0
- package/dist/core/modules/ai/index.js +6 -0
- package/dist/core/modules/ai/index.js.map +1 -1
- package/dist/core/modules/ai/inputs/core-ai-settings.input.d.ts +3 -0
- package/dist/core/modules/ai/inputs/core-ai-settings.input.js +31 -0
- package/dist/core/modules/ai/inputs/core-ai-settings.input.js.map +1 -0
- package/dist/core/modules/ai/interfaces/ai-tool.interface.d.ts +2 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.d.ts +4 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.js +30 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.js.map +1 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.d.ts +8 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.js +45 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.js.map +1 -0
- package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +4 -1
- package/dist/core/modules/ai/services/core-ai-conversation.service.js +16 -4
- package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +6 -6
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +27 -2
- package/dist/core/modules/ai/services/core-ai-mcp.service.js +118 -11
- package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js +9 -6
- package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-user-setting.service.d.ts +17 -0
- package/dist/core/modules/ai/services/core-ai-user-setting.service.js +72 -0
- package/dist/core/modules/ai/services/core-ai-user-setting.service.js.map +1 -0
- package/dist/core/modules/ai/services/core-ai.service.d.ts +8 -3
- package/dist/core/modules/ai/services/core-ai.service.js +68 -29
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +3 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +2 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.d.ts +8 -0
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js +64 -0
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +5 -3
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +2 -0
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/index.d.ts +1 -0
- package/dist/core/modules/better-auth/index.js +1 -0
- package/dist/core/modules/better-auth/index.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +72 -0
- package/dist/core/modules/error-code/error-codes.js +64 -0
- package/dist/core/modules/error-code/error-codes.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 +143 -0
- package/dist/core/modules/sync/core-sync.service.js +1237 -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 +14 -0
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +21 -1
- package/dist/core/modules/tenant/core-tenant.service.js +153 -1
- package/dist/core/modules/tenant/core-tenant.service.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 +8 -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/dist/test/test.helper.d.ts +1 -0
- package/dist/test/test.helper.js +9 -2
- package/dist/test/test.helper.js.map +1 -1
- package/docs/REQUEST-LIFECYCLE.md +4 -2
- package/docs/error-codes.md +14 -0
- package/docs/offline-sync-protocol.md +111 -68
- package/docs/security-overrides.md +14 -0
- package/migration-guides/12.0.x-to-12.1.x.md +0 -1
- package/migration-guides/12.1.x-to-12.2.x.md +154 -0
- package/migration-guides/12.2.x-to-12.3.x.md +440 -0
- package/package.json +23 -21
- package/src/config.env.ts +6 -2
- package/src/core/common/helpers/graphql-ws-connection.helper.ts +32 -4
- package/src/core/common/interfaces/server-options.interface.ts +229 -8
- 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 +5 -2
- 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/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 +129 -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-errors.ts +5 -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/crud.service.ts +257 -19
- package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +36 -15
- package/src/core/modules/ai/README.md +163 -25
- package/src/core/modules/ai/core-ai-mcp.controller.ts +48 -1
- package/src/core/modules/ai/core-ai.constants.ts +6 -0
- package/src/core/modules/ai/core-ai.controller.ts +36 -1
- package/src/core/modules/ai/core-ai.module.ts +12 -0
- package/src/core/modules/ai/core-ai.resolver.ts +36 -0
- package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +35 -4
- package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +68 -1
- package/src/core/modules/ai/helpers/ai-tool-authorization.helper.ts +99 -0
- package/src/core/modules/ai/index.ts +18 -0
- package/src/core/modules/ai/inputs/core-ai-settings.input.ts +24 -0
- package/src/core/modules/ai/interfaces/ai-hook.interface.ts +1 -1
- package/src/core/modules/ai/interfaces/ai-tool.interface.ts +38 -26
- package/src/core/modules/ai/models/core-ai-mode.model.ts +1 -1
- package/src/core/modules/ai/models/core-ai-settings.model.ts +23 -0
- package/src/core/modules/ai/models/core-ai-tool-policy.model.ts +1 -1
- package/src/core/modules/ai/models/core-ai-user-setting.model.ts +46 -0
- package/src/core/modules/ai/services/core-ai-conversation.service.ts +31 -6
- package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +12 -9
- package/src/core/modules/ai/services/core-ai-mcp.service.ts +206 -13
- package/src/core/modules/ai/services/core-ai-prompt-builder.service.ts +13 -6
- package/src/core/modules/ai/services/core-ai-user-setting.service.ts +85 -0
- package/src/core/modules/ai/services/core-ai.service.ts +134 -35
- package/src/core/modules/api-token/README.md +4 -2
- package/src/core/modules/better-auth/CUSTOMIZATION.md +1 -1
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +7 -0
- package/src/core/modules/better-auth/README.md +32 -1
- package/src/core/modules/better-auth/better-auth.config.ts +12 -1
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +5 -2
- package/src/core/modules/better-auth/core-better-auth-path-guard.middleware.ts +85 -0
- package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +5 -2
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +14 -7
- package/src/core/modules/better-auth/core-better-auth.module.ts +12 -2
- package/src/core/modules/better-auth/index.ts +1 -0
- package/src/core/modules/error-code/error-codes.ts +77 -0
- 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 +198 -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 +1773 -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/INTEGRATION-CHECKLIST.md +12 -1
- package/src/core/modules/tenant/README.md +49 -3
- package/src/core/modules/tenant/core-tenant.guard.ts +26 -2
- package/src/core/modules/tenant/core-tenant.service.ts +287 -2
- 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
- package/src/test/test.helper.ts +17 -3
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Offline Sync — Security
|
|
2
|
+
|
|
3
|
+
What the server guarantees, and what a client has to do itself. Offline sync puts a copy of the user's data on the
|
|
4
|
+
device, outside the server's control: the server can decide what reaches a device, but only the client can remove it
|
|
5
|
+
again. The protocol is in [`docs/offline-sync-protocol.md`](../../../../docs/offline-sync-protocol.md) (§11 client
|
|
6
|
+
duties, §12 server guarantees), the module in [README.md](./README.md).
|
|
7
|
+
|
|
8
|
+
## What the server enforces
|
|
9
|
+
|
|
10
|
+
| Guarantee | How |
|
|
11
|
+
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
12
|
+
| No path around the rights | Every pull and push runs through the model's `CrudService` — `process()`, `@Restricted`, `securityCheck()`, the tenant policy, row-level security on PostgreSQL — with the operation roles the model declares (`@PersistedModel({ sync: { roles } })`). There is no lean read path |
|
|
13
|
+
| No foreign data | A pull never delivers a record or tombstone of another tenant or one the caller's rights hide. A push never changes one: an update or delete answers `not_found`, as for an unused id; a create on its id answers `ID_UNAVAILABLE`, the same answer for every reason, with no tenant, owner, content or deletion state. That an id is taken stays observable (protocol §3, decided O2) — ids generated as §3 requires cannot be guessed, and the push rate limit bounds probing |
|
|
14
|
+
| Tombstones without content | A tombstone keeps its id, `version`, `deletedAt`, `updatedAt`, `tenantId` and the user-id scope fields the model declares — nothing a user typed |
|
|
15
|
+
| No hard delete | `op: 'delete'` leaves a tombstone through the normal delete pipeline; removing data for good is the purge's alone, after the retention horizon moved past it |
|
|
16
|
+
| Access loss is enforced | A changed role, a removed membership or a record moved away answers the next pull with `410 access` while the caller may still pull the model. Once they may not pull it at all (pull roles withdrawn), or `X-Tenant-Id` names a tenant they left or that was deactivated, the pull answers `403`. Either way the client must drop its copies |
|
|
17
|
+
| Cookie pushes are CSRF-safe | `Content-Type: application/json` is required, and while cookies are in use a cross-site `Sec-Fetch-Site` or an untrusted `Origin` is refused (`403`) |
|
|
18
|
+
| Hints say nothing | `{ model, at }` only, delivered to callers with the pull roles and access to the tenant of the change; an open stream ends when its user's rights change |
|
|
19
|
+
| Probing is bounded | Per-user rate limits on pull and push; client ids carry 64 random bits (protocol §3), so the next id of a client cannot be guessed |
|
|
20
|
+
|
|
21
|
+
## What a client must do
|
|
22
|
+
|
|
23
|
+
### On logout
|
|
24
|
+
|
|
25
|
+
The device holds personal data the server no longer controls. When the user signs out, delete:
|
|
26
|
+
|
|
27
|
+
- **every local record** of every synchronized model,
|
|
28
|
+
- **the cursors** (they are bound to the user; another user's pull with them answers `410 invalid`),
|
|
29
|
+
- **the outbox** — the unsent changes. Before signing out, tell the user that changes are still unsent and offer to
|
|
30
|
+
sync first; after signing out they cannot be pushed under this account anyway,
|
|
31
|
+
- **the credential**: the bearer token from wherever it is stored. A cookie session ends through the server's sign-out
|
|
32
|
+
route, which also clears the cookie.
|
|
33
|
+
|
|
34
|
+
In a browser that is the IndexedDB database (or the SQLite-WASM file in the OPFS) of the app; in a native or desktop
|
|
35
|
+
app the SQLite file. Deleting the whole database is simpler and safer than deleting rows.
|
|
36
|
+
|
|
37
|
+
### On an account switch
|
|
38
|
+
|
|
39
|
+
Exactly as on logout, before the next user's first pull. A client that kept the previous user's records would show
|
|
40
|
+
them to the next user until a resync — the server cannot reach into the device to prevent it.
|
|
41
|
+
|
|
42
|
+
### On `410 access`
|
|
43
|
+
|
|
44
|
+
Delete every local record of that model, keep the unsent changes, pull without a cursor, then push the kept changes
|
|
45
|
+
(protocol §8). The records the user may no longer see are gone afterwards — which is the point.
|
|
46
|
+
|
|
47
|
+
### On `403` on a pull
|
|
48
|
+
|
|
49
|
+
The caller may no longer pull the model at all, or not in that tenant. Treat it like `410 access` for that model (and
|
|
50
|
+
tenant): delete its local records and its cursor. The unsent changes cannot be pushed under this account any more —
|
|
51
|
+
tell the user rather than retrying.
|
|
52
|
+
|
|
53
|
+
### At rest
|
|
54
|
+
|
|
55
|
+
- A browser app authenticates with the session cookie (`httpOnly`), not with a token in `localStorage`, where any
|
|
56
|
+
script injected into the page can read it.
|
|
57
|
+
- A native app keeps its token in the OS keychain, never in the SQLite file next to the data.
|
|
58
|
+
- A service worker caches the app shell only, never responses of `/sync` or `/iam` — the reference test client
|
|
59
|
+
(`tests/clients/pwa/sw.js` in the framework repository) shows the split.
|
|
60
|
+
- Device encryption is the operating system's job; recommend it where the data is sensitive.
|
|
61
|
+
|
|
62
|
+
### Ids
|
|
63
|
+
|
|
64
|
+
Generate the ids of offline records with a secure random source (`crypto.getRandomValues`), as protocol §3 describes
|
|
65
|
+
— never a driver's default `new ObjectId()`, whose next value is predictable from the last one.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { getTenantContextResolver } from '../../common/services/core-tenant-context.registry.js';
|
|
4
|
+
import { ErrorCode } from '../error-code/error-codes.js';
|
|
5
|
+
import { CoreSyncService } from './core-sync.service.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the sync routes share: the tenant scope of a header-less request and the per-user rate limit. Declares no route,
|
|
9
|
+
* so `CoreSyncController` (pull, push) and `CoreSyncHintsController` (hints) each register only their own.
|
|
10
|
+
*
|
|
11
|
+
* `@Injectable()` only so the constructor's parameter types are emitted: the two controllers inherit it.
|
|
12
|
+
*/
|
|
13
|
+
@Injectable()
|
|
14
|
+
export abstract class CoreSyncBaseController {
|
|
15
|
+
constructor(protected readonly syncService: CoreSyncService) {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Without `X-Tenant-Id` a sync request spans every tenant the caller is an active member of (plan §3.4) — or every
|
|
19
|
+
* tenant for an administrator under `adminBypass`. The tenant guard admits an `S_USER` route without resolving
|
|
20
|
+
* those memberships, so this asks the guard's own membership logic (`core-tenant-context.registry.ts`). A header
|
|
21
|
+
* the guard validated is kept; without multi-tenancy nothing happens.
|
|
22
|
+
*/
|
|
23
|
+
protected async scopeTenants(req: any, currentUser: any): Promise<void> {
|
|
24
|
+
if (!req || !currentUser?.id || req.tenantId || req.tenantIds || req.isAdminBypass) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const resolved = await getTenantContextResolver()?.resolve(currentUser, undefined);
|
|
28
|
+
if (resolved?.isAdminBypass) {
|
|
29
|
+
req.isAdminBypass = true;
|
|
30
|
+
} else if (resolved?.tenantIds) {
|
|
31
|
+
req.tenantIds = resolved.tenantIds;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 429 with `Retry-After` once the caller exceeded the limit of this kind */
|
|
36
|
+
protected async limit(kind: 'pull' | 'push', currentUser: any, res: any): Promise<void> {
|
|
37
|
+
const retryAfter = await this.syncService.rateLimit(kind, currentUser);
|
|
38
|
+
if (retryAfter !== undefined) {
|
|
39
|
+
res?.setHeader?.('Retry-After', String(retryAfter));
|
|
40
|
+
throw new HttpException(ErrorCode.RATE_LIMIT_EXCEEDED, HttpStatus.TOO_MANY_REQUESTS);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash, createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The pull cursor (protocol §5, plan §3.4): opaque to the client, signed by the server, bound to the model and the user
|
|
5
|
+
* it was issued for. Format: `base64url(JSON).base64url(HMAC-SHA256)`. Nothing in it is a secret — the signature only
|
|
6
|
+
* keeps a client from editing it into another user's, another model's or an earlier position.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Where a pull continues */
|
|
10
|
+
export type SyncCursorPosition =
|
|
11
|
+
/** SQL: the last change number delivered (`syncSeq`) */
|
|
12
|
+
| { seq: number }
|
|
13
|
+
/** MongoDB: the keyset `(syncAt, id)` of the last record delivered; `id: ''` starts at `at` itself */
|
|
14
|
+
| { at: number; id: string };
|
|
15
|
+
|
|
16
|
+
export interface SyncCursorState {
|
|
17
|
+
/** Hash of the access epochs the cursor was issued under */
|
|
18
|
+
epochs: string;
|
|
19
|
+
/** Hash of the caller's access when the cursor was issued (user, tenant scope, roles, verification) */
|
|
20
|
+
fingerprint: string;
|
|
21
|
+
model: string;
|
|
22
|
+
position: SyncCursorPosition;
|
|
23
|
+
/** Start (epoch ms) of the run in progress; absent once a page ended its run */
|
|
24
|
+
runStart?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Start (epoch ms) of the run since which the client's state is complete — compared with the tombstone horizon: a
|
|
27
|
+
* tombstone purged after this point may never have reached the client
|
|
28
|
+
*/
|
|
29
|
+
since: number;
|
|
30
|
+
user: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Cursors longer than this are refused unread: nothing the server issues comes close */
|
|
34
|
+
const MAX_CURSOR_LENGTH = 2048;
|
|
35
|
+
|
|
36
|
+
/** The key the cursors are signed with, derived from the configured secret (never the secret itself) */
|
|
37
|
+
export function deriveCursorKey(secret: string): Buffer {
|
|
38
|
+
return Buffer.from(hkdfSync('sha256', secret, 'lenne.tech', 'nest-server:sync-cursor:v1', 32));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function encodeSyncCursor(state: SyncCursorState, key: Buffer): string {
|
|
42
|
+
const payload = Buffer.from(
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
e: state.epochs,
|
|
45
|
+
f: state.fingerprint,
|
|
46
|
+
k: state.since,
|
|
47
|
+
m: state.model,
|
|
48
|
+
p: 'seq' in state.position ? { s: state.position.seq } : { a: state.position.at, i: state.position.id },
|
|
49
|
+
...(state.runStart === undefined ? {} : { r: state.runStart }),
|
|
50
|
+
u: state.user,
|
|
51
|
+
v: 1,
|
|
52
|
+
}),
|
|
53
|
+
).toString('base64url');
|
|
54
|
+
return `${payload}.${sign(payload, key)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The cursor's state, or `undefined` when it is malformed, tampered with or of another format version */
|
|
58
|
+
export function decodeSyncCursor(cursor: unknown, key: Buffer): SyncCursorState | undefined {
|
|
59
|
+
if (typeof cursor !== 'string' || !cursor || cursor.length > MAX_CURSOR_LENGTH) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const [payload, signature, rest] = cursor.split('.');
|
|
63
|
+
if (!payload || !signature || rest !== undefined) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const expected = Buffer.from(sign(payload, key));
|
|
67
|
+
const given = Buffer.from(signature);
|
|
68
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given)) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
let raw: any;
|
|
72
|
+
try {
|
|
73
|
+
raw = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
if (
|
|
78
|
+
!raw ||
|
|
79
|
+
raw.v !== 1 ||
|
|
80
|
+
typeof raw.m !== 'string' ||
|
|
81
|
+
typeof raw.u !== 'string' ||
|
|
82
|
+
typeof raw.f !== 'string' ||
|
|
83
|
+
typeof raw.e !== 'string' ||
|
|
84
|
+
!Number.isFinite(raw.k) ||
|
|
85
|
+
(raw.r !== undefined && !Number.isFinite(raw.r)) ||
|
|
86
|
+
!raw.p ||
|
|
87
|
+
typeof raw.p !== 'object'
|
|
88
|
+
) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
let position: SyncCursorPosition;
|
|
92
|
+
if (Number.isFinite(raw.p.s)) {
|
|
93
|
+
position = { seq: raw.p.s };
|
|
94
|
+
} else if (Number.isFinite(raw.p.a) && typeof raw.p.i === 'string') {
|
|
95
|
+
position = { at: raw.p.a, id: raw.p.i };
|
|
96
|
+
} else {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
epochs: raw.e,
|
|
101
|
+
fingerprint: raw.f,
|
|
102
|
+
model: raw.m,
|
|
103
|
+
position,
|
|
104
|
+
...(raw.r === undefined ? {} : { runStart: raw.r }),
|
|
105
|
+
since: raw.k,
|
|
106
|
+
user: raw.u,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A short, stable hash of a JSON-serializable value (fingerprints, epoch sets) */
|
|
111
|
+
export function stableHash(value: unknown): string {
|
|
112
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('base64url').slice(0, 32);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sign(payload: string, key: Buffer): string {
|
|
116
|
+
return createHmac('sha256', key).update(payload).digest('base64url');
|
|
117
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Controller, Get, NotFoundException, Query, Req, Res } from '@nestjs/common';
|
|
2
|
+
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
3
|
+
|
|
4
|
+
import { CurrentUser } from '../../common/decorators/current-user.decorator.js';
|
|
5
|
+
import { Roles } from '../../common/decorators/roles.decorator.js';
|
|
6
|
+
import { RoleEnum } from '../../common/enums/role.enum.js';
|
|
7
|
+
import { ErrorCode } from '../error-code/error-codes.js';
|
|
8
|
+
import { CoreSyncBaseController } from './core-sync-base.controller.js';
|
|
9
|
+
import { SYNC_DEFAULTS } from './core-sync.constants.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `GET /sync/hints` (protocol §9): server-sent change hints. A controller of its own, registered before
|
|
13
|
+
* `CoreSyncController`: Nest registers a class's own routes before the ones it inherits, so a project controller that
|
|
14
|
+
* re-declares `pull()` (`@Get(':model')`) would otherwise read `hints` as a model name and answer 404 — and an
|
|
15
|
+
* EventSource does not reconnect after an error status, so the hints would stop without a sign.
|
|
16
|
+
*
|
|
17
|
+
* A signed-in caller always gets the stream (200); models the caller may not pull never send a hint, so the stream
|
|
18
|
+
* reveals nothing about which models exist. API tokens are refused here like on every route without
|
|
19
|
+
* `@ApiTokenScopes()`; a project that wants them extends this controller, re-declares `hints()` with
|
|
20
|
+
* `@ApiTokenScopes('sync')` and passes it as `sync.hintsController` override.
|
|
21
|
+
*/
|
|
22
|
+
@ApiTags('sync')
|
|
23
|
+
@Controller(SYNC_DEFAULTS.path)
|
|
24
|
+
@Roles(RoleEnum.S_USER)
|
|
25
|
+
export class CoreSyncHintsController extends CoreSyncBaseController {
|
|
26
|
+
/** The stream is `CoreSyncService.openHintStream()`; 404 while the SSE channel is switched off */
|
|
27
|
+
@ApiBearerAuth()
|
|
28
|
+
@ApiOperation({
|
|
29
|
+
description:
|
|
30
|
+
'Server-sent events: `event: change` with `{ model, at }` whenever a model the caller may pull changes; ' +
|
|
31
|
+
'`models` (comma-separated) narrows the stream',
|
|
32
|
+
summary: 'Change hints (SSE)',
|
|
33
|
+
})
|
|
34
|
+
@Get('hints')
|
|
35
|
+
@Roles(RoleEnum.S_USER)
|
|
36
|
+
async hints(
|
|
37
|
+
@Query('models') models: string,
|
|
38
|
+
@CurrentUser() currentUser: any,
|
|
39
|
+
@Req() req: any,
|
|
40
|
+
@Res() res: any,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
if (!this.syncService.hintsEnabled('sse')) {
|
|
43
|
+
throw new NotFoundException(ErrorCode.SYNC_MODEL_NOT_FOUND);
|
|
44
|
+
}
|
|
45
|
+
// Counted like a pull: an EventSource reconnects on its own, and every reconnect is a request
|
|
46
|
+
await this.limit('pull', currentUser, res);
|
|
47
|
+
// Without X-Tenant-Id the stream spans every membership of the caller, as a pull does
|
|
48
|
+
await this.scopeTenants(req, currentUser);
|
|
49
|
+
const named = typeof models === 'string' ? models.split(',').map((model) => model.trim()) : [];
|
|
50
|
+
await this.syncService.openHintStream(req, res, currentUser, named.filter(Boolean));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy, Optional } from '@nestjs/common';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import type { ISync } from '../../common/interfaces/server-options.interface.js';
|
|
5
|
+
import { ConfigService } from '../../common/services/config.service.js';
|
|
6
|
+
import { CoreRedisService } from '../../common/services/core-redis.service.js';
|
|
7
|
+
import { SYNC_DEFAULTS } from './core-sync.constants.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A hint as the bus carries it: a synchronized model changed, in a tenant (`undefined` on a model without tenants).
|
|
11
|
+
* Subscribers receive `{ model, at }` only (plan §3.9); the tenant decides who receives it.
|
|
12
|
+
*/
|
|
13
|
+
export interface SyncHintEvent {
|
|
14
|
+
at: number;
|
|
15
|
+
model: string;
|
|
16
|
+
tenantId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The hint bus of offline sync (plan §3.9, protocol §9): a change of a synchronized model becomes one hint per model and
|
|
21
|
+
* tenant per `sync.hints.debounceMs`, delivered to this replica's subscribers at once and, with `redis`, to every other
|
|
22
|
+
* replica's through one Redis channel. Without Redis, or while it does not answer, hints stay on this replica — a hint
|
|
23
|
+
* only means "pull now", so a client that misses one learns of the change at its next pull.
|
|
24
|
+
*
|
|
25
|
+
* Who may receive a hint is not decided here: each subscriber filters by its own access (`CoreSyncService`).
|
|
26
|
+
*/
|
|
27
|
+
@Injectable()
|
|
28
|
+
export class CoreSyncHintService implements OnApplicationBootstrap, OnModuleDestroy {
|
|
29
|
+
protected readonly logger = new Logger(CoreSyncHintService.name);
|
|
30
|
+
/** This replica, so a hint it published is not delivered a second time when Redis brings it back */
|
|
31
|
+
protected readonly origin = randomUUID();
|
|
32
|
+
protected readonly subscribers = new Set<(hint: SyncHintEvent) => void>();
|
|
33
|
+
/** Pending hints by model and tenant: one per key and debounce window, removed when sent */
|
|
34
|
+
protected readonly pending = new Map<string, ReturnType<typeof setTimeout>>();
|
|
35
|
+
protected channel?: string;
|
|
36
|
+
protected redisListener?: (channel: string, message: string) => void;
|
|
37
|
+
|
|
38
|
+
constructor(@Optional() protected readonly redisService?: CoreRedisService) {}
|
|
39
|
+
|
|
40
|
+
/** The `sync.hints` configuration (an object; `true` / absent read as `{}`, `false` as `{ enabled: false }`) */
|
|
41
|
+
protected get config(): Exclude<ISync['hints'], boolean | undefined> {
|
|
42
|
+
const sync = ConfigService.configFastButReadOnly?.sync;
|
|
43
|
+
const hints = sync && typeof sync === 'object' ? sync.hints : undefined;
|
|
44
|
+
if (hints === false) {
|
|
45
|
+
return { enabled: false };
|
|
46
|
+
}
|
|
47
|
+
return hints && typeof hints === 'object' ? hints : {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether hints are sent at all (`sync.hints`, on by default) */
|
|
51
|
+
get enabled(): boolean {
|
|
52
|
+
return this.config.enabled !== false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `sync.hints.debounceMs`: at most one hint per model and tenant per window; an invalid value → the default */
|
|
56
|
+
protected get debounceMs(): number {
|
|
57
|
+
const value = this.config.debounceMs;
|
|
58
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : SYNC_DEFAULTS.hintDebounceMs;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async onApplicationBootstrap(): Promise<void> {
|
|
62
|
+
if (!this.enabled || !this.redisService?.enabled) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const channel = this.redisService.key('sync', 'hints');
|
|
66
|
+
try {
|
|
67
|
+
// Held in a field: the subscriber connection is shared, a listener left behind would deliver into a destroyed bus
|
|
68
|
+
this.channel = channel;
|
|
69
|
+
this.redisListener = (incoming: string, message: string) => {
|
|
70
|
+
if (incoming === channel) {
|
|
71
|
+
this.receive(message);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
this.redisService.getSubscriber().on('message', this.redisListener);
|
|
75
|
+
// Registered with the Redis service, so the subscription comes back after every reconnect
|
|
76
|
+
await this.redisService.subscribeChannel(channel);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
this.logger.warn(
|
|
79
|
+
`Sync hints: Redis subscribe failed (${(error as Error)?.message ?? error}) — retried when the connection is ` +
|
|
80
|
+
"up; until then this replica's subscribers receive only its own hints",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
onModuleDestroy(): void {
|
|
86
|
+
for (const timer of this.pending.values()) {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
this.pending.clear();
|
|
90
|
+
this.subscribers.clear();
|
|
91
|
+
if (this.redisListener) {
|
|
92
|
+
try {
|
|
93
|
+
this.redisService?.getSubscriber().off('message', this.redisListener);
|
|
94
|
+
if (this.channel) {
|
|
95
|
+
void this.redisService?.releaseChannel(this.channel).catch(() => undefined);
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// Redis already gone — nothing to detach from
|
|
99
|
+
}
|
|
100
|
+
this.redisListener = undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A synchronized model changed in these tenants (none: a model without tenants). Sent as one hint per model and
|
|
106
|
+
* tenant once the debounce window ends — a burst of writes is one hint.
|
|
107
|
+
*/
|
|
108
|
+
notify(model: string, tenantIds: readonly (string | undefined)[] = [undefined]): void {
|
|
109
|
+
if (!this.enabled) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const tenantId of tenantIds.length ? new Set(tenantIds) : [undefined]) {
|
|
113
|
+
const key = `${model}\u0000${tenantId ?? ''}`;
|
|
114
|
+
if (this.pending.has(key)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const timer = setTimeout(() => {
|
|
118
|
+
this.pending.delete(key);
|
|
119
|
+
this.send({ at: Date.now(), model, ...(tenantId ? { tenantId } : {}) });
|
|
120
|
+
}, this.debounceMs);
|
|
121
|
+
timer.unref?.();
|
|
122
|
+
this.pending.set(key, timer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Receive every hint (this replica's and, with Redis, every other's); answers the unsubscribe */
|
|
127
|
+
subscribe(handler: (hint: SyncHintEvent) => void): () => void {
|
|
128
|
+
this.subscribers.add(handler);
|
|
129
|
+
return () => {
|
|
130
|
+
this.subscribers.delete(handler);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Deliver a hint here and hand it to the other replicas */
|
|
135
|
+
protected send(hint: SyncHintEvent): void {
|
|
136
|
+
this.deliver(hint);
|
|
137
|
+
if (!this.channel || !this.redisService?.enabled) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
void this.redisService
|
|
142
|
+
.getClient()
|
|
143
|
+
.publish(this.channel, JSON.stringify({ ...hint, origin: this.origin }))
|
|
144
|
+
.catch((error: Error) => this.logger.debug(`Sync hint publish failed: ${error.message}`));
|
|
145
|
+
} catch (error) {
|
|
146
|
+
this.logger.debug(`Sync hint publish failed: ${(error as Error)?.message ?? error}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A hint from the Redis channel — another replica's; this replica's own were delivered when sent */
|
|
151
|
+
protected receive(message: string): void {
|
|
152
|
+
let parsed: any;
|
|
153
|
+
try {
|
|
154
|
+
parsed = JSON.parse(message);
|
|
155
|
+
} catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!parsed || parsed.origin === this.origin || typeof parsed.model !== 'string' || !Number.isFinite(parsed.at)) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.deliver({
|
|
162
|
+
at: Number(parsed.at),
|
|
163
|
+
model: parsed.model,
|
|
164
|
+
...(typeof parsed.tenantId === 'string' && parsed.tenantId ? { tenantId: parsed.tenantId } : {}),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
protected deliver(hint: SyncHintEvent): void {
|
|
169
|
+
// A Set tolerates a subscriber removing itself while it is called
|
|
170
|
+
for (const handler of this.subscribers) {
|
|
171
|
+
try {
|
|
172
|
+
handler(hint);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
this.logger.warn(`Sync hint subscriber failed: ${(error as Error)?.message ?? error}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createSchema } from '../../common/persistence/mongo/mongoose-compat.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The sync module's infrastructure stores (offline-sync plan §3.3 E7, §3.7) as framework models: registered with
|
|
5
|
+
* `CorePersistenceModule.forFeature()`, they follow `database` like every other model and run through one code path
|
|
6
|
+
* on every database — a collection on MongoDB, a table on SQL. Nothing here relies on the TTL for correctness: every
|
|
7
|
+
* reader checks `expiresAt` itself; MongoDB's TTL index and the SQL expiry sweep only reclaim the space.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One pushed mutation per `key` (model, user, record, mutation id — hashed): claimed before the record is written
|
|
12
|
+
* (`state: 'pending'`), then its outcome (`state: 'done'` with `status`, `version`, `code`). `attempt` makes every
|
|
13
|
+
* takeover a conditional write that exactly one request wins.
|
|
14
|
+
*/
|
|
15
|
+
export const SyncMutationSchema = createSchema(
|
|
16
|
+
{
|
|
17
|
+
attempt: { type: Number },
|
|
18
|
+
claimedAt: { type: Date },
|
|
19
|
+
code: { type: String },
|
|
20
|
+
expiresAt: { index: { expireAfterSeconds: 0 }, type: Date },
|
|
21
|
+
key: { index: { unique: true }, type: String },
|
|
22
|
+
state: { type: String },
|
|
23
|
+
status: { type: String },
|
|
24
|
+
version: { type: Number },
|
|
25
|
+
},
|
|
26
|
+
{ collection: 'syncMutations', versionKey: false },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Access epochs (`epoch:…` keys, an integer bumped when access to synchronized records changes, plan §3.8) and tombstone
|
|
31
|
+
* horizons (`horizon:<model>`, the oldest point a cursor may continue from, advanced by the purge before it deletes).
|
|
32
|
+
*/
|
|
33
|
+
export const SyncStateSchema = createSchema(
|
|
34
|
+
{
|
|
35
|
+
epoch: { type: Number },
|
|
36
|
+
horizon: { type: Date },
|
|
37
|
+
key: { index: { unique: true }, type: String },
|
|
38
|
+
},
|
|
39
|
+
{ collection: 'syncStates', versionKey: false },
|
|
40
|
+
);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import type { DataStore, StoredRecord } from '../../common/persistence/data-store.interface.js';
|
|
4
|
+
|
|
5
|
+
/** The recorded outcome of a mutation: what a repeat of it is answered with (protocol §6.5) */
|
|
6
|
+
export interface SyncMutationOutcome {
|
|
7
|
+
code?: string;
|
|
8
|
+
status: string;
|
|
9
|
+
version?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What a claim found:
|
|
14
|
+
* - `claimed`: this request writes the mutation and then records its outcome (`complete()`) or gives it up (`release()`)
|
|
15
|
+
* - `busy`: another request is writing it right now — answer `retry`
|
|
16
|
+
* - `done`: it was applied before — answer with its outcome
|
|
17
|
+
*/
|
|
18
|
+
export type SyncMutationClaim = { kind: 'busy' } | { kind: 'claimed' } | { kind: 'done'; outcome: SyncMutationOutcome };
|
|
19
|
+
|
|
20
|
+
interface MutationRecord {
|
|
21
|
+
attempt?: number;
|
|
22
|
+
claimedAt?: Date;
|
|
23
|
+
code?: null | string;
|
|
24
|
+
expiresAt?: Date;
|
|
25
|
+
key: string;
|
|
26
|
+
state?: string;
|
|
27
|
+
status?: null | string;
|
|
28
|
+
version?: null | number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Idempotency of pushed changes (offline-sync plan §3.7): the mutation is claimed BEFORE the record is written, under a
|
|
33
|
+
* unique key, so two tabs sending the same change at once end in one write — the second finds the claim and is told to
|
|
34
|
+
* retry, then finds the outcome. A record written after the write could not give that: both requests would miss it.
|
|
35
|
+
*
|
|
36
|
+
* A claim older than the claim timeout belongs to a request that died between claiming and recording; it is taken over
|
|
37
|
+
* with a conditional write on `attempt`, which exactly one contender wins. An entry past `expiresAt` counts as absent
|
|
38
|
+
* whether or not the TTL has removed it yet.
|
|
39
|
+
*/
|
|
40
|
+
export class CoreSyncMutationStore {
|
|
41
|
+
constructor(
|
|
42
|
+
protected readonly store: DataStore<MutationRecord>,
|
|
43
|
+
protected readonly options: { claimTimeoutMs: number; retentionMs: number },
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The key of a mutation: model, user, record and mutation id, hashed to a fixed length. The user is part of it, so a
|
|
48
|
+
* mutation id of somebody else can neither block a change nor reveal its outcome.
|
|
49
|
+
*/
|
|
50
|
+
static key(model: string, userId: string, recordId: string, mutationId: string): string {
|
|
51
|
+
return createHash('sha256').update([model, userId, recordId, mutationId].join('\u0000')).digest('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async claim(key: string, now = new Date()): Promise<SyncMutationClaim> {
|
|
55
|
+
// A concurrent claimant can make each step fail once; three rounds settle every interleaving of two requests
|
|
56
|
+
for (let round = 0; round < 3; round++) {
|
|
57
|
+
const existing = await this.store.findOne({ key });
|
|
58
|
+
if (!existing) {
|
|
59
|
+
try {
|
|
60
|
+
await this.store.insert({ key, ...this.pending(now, 1) });
|
|
61
|
+
return { kind: 'claimed' };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (!isDuplicateKey(error)) {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (isExpired(existing, now)) {
|
|
70
|
+
if (await this.takeOver(existing, now)) {
|
|
71
|
+
return { kind: 'claimed' };
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (existing.state === 'done' && existing.status) {
|
|
76
|
+
return { kind: 'done', outcome: outcomeOf(existing) };
|
|
77
|
+
}
|
|
78
|
+
const claimedAt = existing.claimedAt ? new Date(existing.claimedAt).getTime() : 0;
|
|
79
|
+
if (now.getTime() - claimedAt < this.options.claimTimeoutMs) {
|
|
80
|
+
return { kind: 'busy' };
|
|
81
|
+
}
|
|
82
|
+
return (await this.takeOver(existing, now)) ? { kind: 'claimed' } : { kind: 'busy' };
|
|
83
|
+
}
|
|
84
|
+
return { kind: 'busy' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Record the outcome of a claimed mutation; kept for the retention period from now */
|
|
88
|
+
async complete(key: string, outcome: SyncMutationOutcome, now = new Date()): Promise<void> {
|
|
89
|
+
await this.store.updateOne(
|
|
90
|
+
{ key },
|
|
91
|
+
{
|
|
92
|
+
$set: {
|
|
93
|
+
code: outcome.code ?? null,
|
|
94
|
+
expiresAt: new Date(now.getTime() + this.options.retentionMs),
|
|
95
|
+
state: 'done',
|
|
96
|
+
status: outcome.status,
|
|
97
|
+
version: outcome.version ?? null,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Give up a claim whose write did not happen (a transient failure), so the client's retry is written anew */
|
|
104
|
+
async release(key: string): Promise<void> {
|
|
105
|
+
await this.store.deleteOne({ key, state: 'pending' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
protected pending(now: Date, attempt: number): Omit<MutationRecord, 'key'> {
|
|
109
|
+
return {
|
|
110
|
+
attempt,
|
|
111
|
+
claimedAt: now,
|
|
112
|
+
code: null,
|
|
113
|
+
expiresAt: new Date(now.getTime() + this.options.retentionMs),
|
|
114
|
+
state: 'pending',
|
|
115
|
+
status: null,
|
|
116
|
+
version: null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Take an abandoned or expired entry over: conditional on the attempt the reader saw, so one contender wins */
|
|
121
|
+
protected async takeOver(existing: StoredRecord<MutationRecord>, now: Date): Promise<boolean> {
|
|
122
|
+
const attempt = existing.attempt ?? 0;
|
|
123
|
+
const taken = await this.store.updateOne(
|
|
124
|
+
{ attempt: existing.attempt ?? null, id: existing.id },
|
|
125
|
+
{ $set: this.pending(now, attempt + 1) },
|
|
126
|
+
);
|
|
127
|
+
return !!taken;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isExpired(record: MutationRecord, now: Date): boolean {
|
|
132
|
+
return !!record.expiresAt && new Date(record.expiresAt).getTime() <= now.getTime();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function outcomeOf(record: MutationRecord): SyncMutationOutcome {
|
|
136
|
+
return {
|
|
137
|
+
...(record.code ? { code: record.code } : {}),
|
|
138
|
+
status: record.status,
|
|
139
|
+
...(typeof record.version === 'number' ? { version: record.version } : {}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A unique index refused the write — MongoDB's code, which the SQL stores carry too (`DuplicateKeyError`) */
|
|
144
|
+
export function isDuplicateKey(error: unknown): boolean {
|
|
145
|
+
return (error as { code?: unknown })?.code === 11000;
|
|
146
|
+
}
|