@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,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire format of the sync protocol (`docs/offline-sync-protocol.md`) and the hook of the `custom` strategy.
|
|
3
|
+
* Types only.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** A pushed change (protocol §6.2) */
|
|
7
|
+
export interface ISyncPushItem {
|
|
8
|
+
/** The version the change is based on; absent only on the item that creates the record */
|
|
9
|
+
baseVersion?: number;
|
|
10
|
+
/** The fields to set (`upsert`); validated against the model's input like any create/update */
|
|
11
|
+
data?: Record<string, unknown>;
|
|
12
|
+
/** For `merge`: the field names the client changed. Without it, every key in `data` counts as changed */
|
|
13
|
+
fields?: string[];
|
|
14
|
+
/** The record's id, in the model's format */
|
|
15
|
+
id: string;
|
|
16
|
+
/** Unique per change, generated by the client; the same change sent again carries the same id */
|
|
17
|
+
mutationId: string;
|
|
18
|
+
op: SyncPushOp;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SyncPushOp = 'delete' | 'upsert';
|
|
22
|
+
|
|
23
|
+
/** Protocol §7 */
|
|
24
|
+
export type SyncItemStatus = 'applied' | 'conflict' | 'merged' | 'not_found' | 'rejected' | 'retry';
|
|
25
|
+
|
|
26
|
+
/** Protocol §7 — the reason of a `rejected` item */
|
|
27
|
+
export type SyncRejectCode = 'CUSTOM_REFUSED' | 'FORBIDDEN' | 'ID_UNAVAILABLE' | 'INVALID_ID' | 'VALIDATION_FAILED';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A record as a pull delivers it (protocol §5.2): a live record carries its content in `data`, a tombstone carries
|
|
31
|
+
* no `data` — only its id, version, times, `tenantId` and the model's scope fields (protocol §2).
|
|
32
|
+
*/
|
|
33
|
+
export interface ISyncRecord {
|
|
34
|
+
[scopeField: string]: unknown;
|
|
35
|
+
data?: Record<string, unknown>;
|
|
36
|
+
deletedAt: Date | null;
|
|
37
|
+
id: string;
|
|
38
|
+
updatedAt?: Date;
|
|
39
|
+
version: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Protocol §6.6 */
|
|
43
|
+
export interface ISyncPushResult {
|
|
44
|
+
code?: SyncRejectCode;
|
|
45
|
+
details?: unknown;
|
|
46
|
+
id: string;
|
|
47
|
+
mutationId: string;
|
|
48
|
+
/** The server's record (`conflict`, `merged`) */
|
|
49
|
+
record?: ISyncRecord;
|
|
50
|
+
status: SyncItemStatus;
|
|
51
|
+
version?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Protocol §5.2 */
|
|
55
|
+
export interface ISyncPullResult {
|
|
56
|
+
cursor: string;
|
|
57
|
+
hasMore: boolean;
|
|
58
|
+
items: ISyncRecord[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Protocol §8 */
|
|
62
|
+
export type SyncResyncReason = 'access' | 'horizon' | 'invalid';
|
|
63
|
+
|
|
64
|
+
/** What `resolveSyncConflict()` receives under the `custom` strategy */
|
|
65
|
+
export interface ISyncConflictContext {
|
|
66
|
+
/** What the client sent */
|
|
67
|
+
change: { baseVersion?: number; data?: Record<string, unknown>; fields?: string[]; op: SyncPushOp };
|
|
68
|
+
/** The authenticated caller, as the controller received it */
|
|
69
|
+
currentUser: any;
|
|
70
|
+
id: string;
|
|
71
|
+
model: string;
|
|
72
|
+
/** The record as the caller may see it now, a live record (a tombstone always conflicts) */
|
|
73
|
+
server: ISyncRecord;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The answer of `resolveSyncConflict()`: the fields to write on top of the server's record, a deletion, or a refusal
|
|
78
|
+
* (`rejected` / `CUSTOM_REFUSED`). The write runs through the same pipeline as every push.
|
|
79
|
+
*/
|
|
80
|
+
export type SyncConflictResolution = { data: Record<string, unknown> } | { delete: true } | { refuse: true };
|
|
81
|
+
|
|
82
|
+
/** A model service whose model declares `strategy: 'custom'` implements this */
|
|
83
|
+
export interface SyncConflictResolver {
|
|
84
|
+
resolveSyncConflict(context: ISyncConflictContext): Promise<SyncConflictResolution> | SyncConflictResolution;
|
|
85
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Field, ObjectType } from '@nestjs/graphql';
|
|
2
|
+
|
|
3
|
+
import { Restricted } from '../../../common/decorators/restricted.decorator.js';
|
|
4
|
+
import { RoleEnum } from '../../../common/enums/role.enum.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A change hint of offline sync (protocol §9): the model changed at this time — nothing else, no record, no id, no
|
|
8
|
+
* tenant. A client answers it with a pull. Who receives it was decided before it was sent.
|
|
9
|
+
*/
|
|
10
|
+
@ObjectType('SyncHint', { description: 'A synchronized model changed: pull it' })
|
|
11
|
+
@Restricted(RoleEnum.S_EVERYONE)
|
|
12
|
+
export class CoreSyncHintModel {
|
|
13
|
+
@Field(() => Date, { description: 'When the change was sent' })
|
|
14
|
+
at: Date;
|
|
15
|
+
|
|
16
|
+
@Field(() => String, { description: 'The changed model, as in GET /sync/{model}' })
|
|
17
|
+
model: string;
|
|
18
|
+
}
|
|
@@ -175,6 +175,16 @@ See [Tenant README — BetterAuth Integration](./README.md#betterauth-iam-integr
|
|
|
175
175
|
|
|
176
176
|
See the module README: "Deactivated Tenants" and "Service-Level Role Checks".
|
|
177
177
|
|
|
178
|
+
### 12. Removing a Tenant (since 12.3.0)
|
|
179
|
+
|
|
180
|
+
- **Can a tenant be deleted in your project?** Call `tenantService.purgeTenantData(tenantId)` where the project deletes
|
|
181
|
+
its tenant record, after deleting the tenant's files through `CoreFileService`. WHY: the core has no tenant model and
|
|
182
|
+
cannot notice the deletion; until the purge runs, the tenant's API tokens keep authenticating and its records stay.
|
|
183
|
+
- **Does a model reference the tenant in a field other than `tenantId`?** Override `purgeTenantRecords()` and call
|
|
184
|
+
`super`. WHY: the purge finds `tenantId` paths and the core's own references only.
|
|
185
|
+
|
|
186
|
+
See the module README: "Removing a Tenant".
|
|
187
|
+
|
|
178
188
|
## Verification Checklist
|
|
179
189
|
|
|
180
190
|
- [ ] `pnpm run build` succeeds
|
|
@@ -206,4 +216,5 @@ See the module README: "Deactivated Tenants" and "Service-Level Role Checks".
|
|
|
206
216
|
| Public endpoint accessing tenantId-schema | 403 Safety Net exception | Use `@SkipTenantCheck()` + `RequestContext.runWithBypassTenantGuard()` |
|
|
207
217
|
| Passing user-supplied tenantId to create() | Cross-tenant write possible | Let plugin auto-set tenantId from context |
|
|
208
218
|
| Custom hierarchy doesn't match config | Roles fail unexpectedly | Ensure `createHierarchyRoles()` input matches `multiTenancy.roleHierarchy` |
|
|
209
|
-
| `@SkipTenantCheck()` on BetterAuth handler | Redundant since v11.21.2 | Remove — auto-skip handles this via `betterAuth.skipTenantCheck` (default) |
|
|
219
|
+
| `@SkipTenantCheck()` on BetterAuth handler | Redundant since v11.21.2 | Remove — auto-skip handles this via `betterAuth.skipTenantCheck` (default) |
|
|
220
|
+
| Deleting only the tenant record | Tenant tokens and records survive | Call `purgeTenantData(tenantId)` when the tenant is deleted |
|
|
@@ -391,6 +391,51 @@ Mechanism, for anyone extending the guard (`core-tenant.guard.ts`):
|
|
|
391
391
|
- **A received broadcast clears locally and does NOT re-publish** (`applyInvalidation()` → `clearUser()` / `clearAll()`, the no-broadcast variants). Re-publishing would bounce the message around the cluster forever.
|
|
392
392
|
- **Publishing is fire-and-forget and failures are debug-level only.** A missed broadcast is not an error: it merely leaves the other replicas' caches stale until their TTL — exactly the no-Redis behaviour. Nothing about a request path depends on the broadcast succeeding.
|
|
393
393
|
|
|
394
|
+
### Removing a Tenant (`purgeTenantData`, since 12.3.0)
|
|
395
|
+
|
|
396
|
+
The core has no tenant model, so it cannot notice that a project deleted one — but it knows every place where it
|
|
397
|
+
stores tenant data itself. When the project deletes its tenant record, it calls:
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
const report = await this.tenantService.purgeTenantData(tenantId);
|
|
401
|
+
// { TenantMember: 4, Invoice: 120, ApiToken: 2, AiConnection: 1 } — models with nothing to delete are left out
|
|
402
|
+
|
|
403
|
+
// Count first, e.g. for a confirmation dialog:
|
|
404
|
+
await this.tenantService.purgeTenantData(tenantId, { dryRun: true });
|
|
405
|
+
|
|
406
|
+
// Leave a model alone (in addition to multiTenancy.excludeSchemas):
|
|
407
|
+
await this.tenantService.purgeTenantData(tenantId, { exclude: ['AuditEntry'] });
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
| Step | What is deleted |
|
|
411
|
+
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
412
|
+
| 1 | Every way in, so nobody writes into the tenant while its data goes: each membership in any status (`deleteAllMembers()`), its cached access invalidated at once — on every replica with Redis — and the tenant's API tokens (`tenant`), which need no member |
|
|
413
|
+
| 2 | Every model registered with `CorePersistenceModule.forFeature()` that has a `tenantId` path, on whatever database it is routed to — the project's own models included |
|
|
414
|
+
| 3 | Models with a `tenantId` path registered only through `MongooseModule.forFeature()` (MongoDB) |
|
|
415
|
+
| 4 | Core models that name the tenant in another field: AI budget limits, connection preferences, tool grants and tool policies (`scope: 'tenant'`, `refId`) |
|
|
416
|
+
| 5 | AI connections: one only this tenant could use is deleted; from a shared one only the tenant is removed. An empty `tenantIds` list means "every tenant", so emptying it would hand the connection to all of them |
|
|
417
|
+
|
|
418
|
+
Not deleted:
|
|
419
|
+
|
|
420
|
+
- models in `multiTenancy.excludeSchemas` — a `tenantId` there is not ownership — and models named in `exclude`;
|
|
421
|
+
- users (the `users` table or collection): they are global and may belong to other tenants. That holds where `User`
|
|
422
|
+
carries a `tenantId` too (per-tenant users): delete those through the user service before the purge, which also
|
|
423
|
+
ends their Better-Auth sessions and accounts;
|
|
424
|
+
- **files**: the file metadata carries no `tenantId`, and only the project knows which files (GridFS, S3, file system)
|
|
425
|
+
belonged to the tenant. Delete them through `CoreFileService` before the purge (a TUS upload records
|
|
426
|
+
`metadata.tenantId`);
|
|
427
|
+
- references the core does not know of, e.g. a `companyId` field. Add them by overriding the protected
|
|
428
|
+
`purgeTenantRecords()` and calling `super`.
|
|
429
|
+
|
|
430
|
+
The memberships cannot be excluded. Records go in batches of 1000 ids, so a large tenant never holds a synchronized
|
|
431
|
+
model's change lock (and with it every other tenant's writes to that model) for the whole purge. A synchronized model
|
|
432
|
+
(offline sync) keeps a tombstone per deleted record until the tombstone purge removes it. The purge runs as a system operation (no tenant filter), so call it only from a route that
|
|
433
|
+
checked who may delete the tenant.
|
|
434
|
+
|
|
435
|
+
`tenantGuard.invalidateTenant(tenantId)` clears the tenant's cached memberships as well (since 12.3.0). Before, it
|
|
436
|
+
forgot only the `isTenantActive` answer, so memberships removed without `invalidateUser()` kept counting for up to
|
|
437
|
+
`cacheTtlMs`.
|
|
438
|
+
|
|
394
439
|
### SkipTenantCheck
|
|
395
440
|
|
|
396
441
|
For high-frequency endpoints that don't access tenant-scoped data, use `@SkipTenantCheck()` to avoid the membership lookup entirely.
|
|
@@ -466,9 +511,10 @@ this module:
|
|
|
466
511
|
global roles removed (no admin bypass), optionally restricted to one tenant and capped by
|
|
467
512
|
`maxTenantRole`.
|
|
468
513
|
|
|
469
|
-
Both are denied on routes without `@ApiTokenScopes()`. **
|
|
470
|
-
|
|
471
|
-
deletion, and a tenant token does not depend on any member, so it keeps authenticating
|
|
514
|
+
Both are denied on routes without `@ApiTokenScopes()`. **When you delete a tenant, call
|
|
515
|
+
`CoreTenantService.purgeTenantData()` (or at least `CoreApiTokenService.deleteAllForTenant()`)** — the core has no
|
|
516
|
+
tenant model to notice the deletion, and a tenant token does not depend on any member, so it keeps authenticating
|
|
517
|
+
until then.
|
|
472
518
|
|
|
473
519
|
## Related
|
|
474
520
|
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { DataSourceConfigError } from '../../common/persistence/data-source.config.js';
|
|
27
27
|
import type { DataStore } from '../../common/persistence/data-store.interface.js';
|
|
28
28
|
import { getStoreToken } from '../../common/persistence/persistence.tokens.js';
|
|
29
|
+
import { getSyncAccessListener } from '../../common/persistence/sync-access.registry.js';
|
|
29
30
|
import { ConfigService } from '../../common/services/config.service.js';
|
|
30
31
|
import { ResolvedTenantContext, setTenantContextResolver } from '../../common/services/core-tenant-context.registry.js';
|
|
31
32
|
import { CoreRedisService } from '../../common/services/core-redis.service.js';
|
|
@@ -383,6 +384,13 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
383
384
|
invalidateTenant(tenantId: string): void {
|
|
384
385
|
this.clearTenant(tenantId);
|
|
385
386
|
this.publishInvalidation({ scope: 'tenant', tenantId });
|
|
387
|
+
// Offline sync: the tenant's members resync on their next pull (a no-op without the sync module). Once, here —
|
|
388
|
+
// the epoch lives in the database, so a replica receiving the broadcast must not bump it again
|
|
389
|
+
void getSyncAccessListener()
|
|
390
|
+
?.tenantInvalidated(tenantId)
|
|
391
|
+
.catch((error) =>
|
|
392
|
+
this.logger.warn(`Sync access epoch of tenant ${tenantId} not bumped: ${(error as Error)?.message ?? error}`),
|
|
393
|
+
);
|
|
386
394
|
}
|
|
387
395
|
|
|
388
396
|
/**
|
|
@@ -803,11 +811,27 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
803
811
|
}
|
|
804
812
|
|
|
805
813
|
/**
|
|
806
|
-
* Forget
|
|
807
|
-
*
|
|
814
|
+
* Forget everything cached about a tenant locally (no broadcast): its `isTenantActive` answer, the cached
|
|
815
|
+
* memberships in it and every cached tenant list that contains it. Then re-check the open WebSocket connections
|
|
816
|
+
* scoped to it.
|
|
817
|
+
*
|
|
818
|
+
* The memberships matter for a tenant that is gone rather than switched off: when its memberships were removed
|
|
819
|
+
* without `invalidateUser()`, a cached membership kept it reachable until `cacheTtlMs` ran out. A tenant switched off
|
|
820
|
+
* through `isTenantActive` needs only the active flag, which every membership path asks right after.
|
|
808
821
|
*/
|
|
809
822
|
protected clearTenant(tenantId: string): void {
|
|
810
823
|
this.tenantActiveCache.delete(tenantId);
|
|
824
|
+
for (const key of this.membershipCache.keys()) {
|
|
825
|
+
// Key: `${userId}:${tenantId}` — user ids carry no ':', so the first one separates the two
|
|
826
|
+
if (key.slice(key.indexOf(':') + 1) === tenantId) {
|
|
827
|
+
this.membershipCache.delete(key);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
for (const [key, entry] of this.tenantIdsCache.entries()) {
|
|
831
|
+
if (entry.ids.includes(tenantId)) {
|
|
832
|
+
this.tenantIdsCache.delete(key);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
811
835
|
revalidateWsConnectionsForTenant(tenantId);
|
|
812
836
|
}
|
|
813
837
|
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { BadRequestException, Injectable, Logger, NotFoundException, Optional } from '@nestjs/common';
|
|
2
|
-
import { InjectModel } from '../../common/persistence/mongo/mongoose-compat.js';
|
|
3
|
-
import type { Model } from 'mongoose';
|
|
2
|
+
import { InjectConnection, InjectModel } from '../../common/persistence/mongo/mongoose-compat.js';
|
|
3
|
+
import type { Connection, Model } from 'mongoose';
|
|
4
4
|
|
|
5
5
|
import { looksLikeGlobalOnlyRole, looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum.js';
|
|
6
6
|
import type { DataStore } from '../../common/persistence/data-store.interface.js';
|
|
7
|
+
import { getModelDescriptors } from '../../common/persistence/model-metadata.registry.js';
|
|
8
|
+
import { defaultCollectionName } from '../../common/persistence/mongo/collection-name.js';
|
|
9
|
+
import { tableName } from '../../common/persistence/sql/sql-schema.shared.js';
|
|
10
|
+
import { getModelStore, getStoreForModel } from '../../common/persistence/persistence-runtime.registry.js';
|
|
7
11
|
import { ConfigService } from '../../common/services/config.service.js';
|
|
8
12
|
import { RequestContext } from '../../common/services/request-context.service.js';
|
|
13
|
+
import {
|
|
14
|
+
AI_BUDGET_LIMIT_MODEL,
|
|
15
|
+
AI_CONNECTION_MODEL,
|
|
16
|
+
AI_CONNECTION_PREFERENCE_MODEL,
|
|
17
|
+
AI_TOOL_GRANT_MODEL,
|
|
18
|
+
AI_TOOL_POLICY_MODEL,
|
|
19
|
+
} from '../ai/core-ai.constants.js';
|
|
20
|
+
import { API_TOKEN_MODEL_TOKEN } from '../api-token/core-api-token.constants.js';
|
|
9
21
|
import { CoreTenantMemberModel } from './core-tenant-member.model.js';
|
|
10
22
|
import { routedMemberStore } from './core-tenant-member.store.js';
|
|
11
23
|
import { DEFAULT_ROLE_HIERARCHY, TENANT_MEMBER_MODEL_TOKEN, TenantMemberStatus } from './core-tenant.enums.js';
|
|
@@ -34,6 +46,14 @@ import { CoreTenantGuard } from './core-tenant.guard.js';
|
|
|
34
46
|
export class CoreTenantService {
|
|
35
47
|
protected readonly logger = new Logger(CoreTenantService.name);
|
|
36
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The MongoDB connection, `null` without MongoDB. `purgeTenantData()` reads the models registered only through
|
|
51
|
+
* `MongooseModule.forFeature()` from it. Injected as a property, so a subclass constructor needs no change
|
|
52
|
+
*/
|
|
53
|
+
@Optional()
|
|
54
|
+
@InjectConnection()
|
|
55
|
+
protected readonly mongoConnection?: Connection | null;
|
|
56
|
+
|
|
37
57
|
constructor(
|
|
38
58
|
@InjectModel(TENANT_MEMBER_MODEL_TOKEN) protected readonly memberModel: Model<CoreTenantMemberModel>,
|
|
39
59
|
@Optional() protected readonly tenantGuard?: CoreTenantGuard,
|
|
@@ -298,6 +318,271 @@ export class CoreTenantService {
|
|
|
298
318
|
}
|
|
299
319
|
});
|
|
300
320
|
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Delete every membership of a tenant, whatever its status, and clear what the tenant guard cached for it.
|
|
324
|
+
*
|
|
325
|
+
* Meant for removing a tenant: unlike {@link removeMember} it deletes the rows instead of suspending them and skips
|
|
326
|
+
* the last-owner check, because the tenant itself goes. Every affected user is invalidated in the guard (on every
|
|
327
|
+
* replica with Redis), the tenant last. Answers how many memberships went.
|
|
328
|
+
*/
|
|
329
|
+
async deleteAllMembers(tenantId: string): Promise<number> {
|
|
330
|
+
requireTenantId(tenantId);
|
|
331
|
+
return RequestContext.runWithBypassTenantGuard(async () => {
|
|
332
|
+
const where = { tenant: tenantId };
|
|
333
|
+
const store = this.memberDataStore();
|
|
334
|
+
const members = store ? await store.find(where) : await this.memberModel.find(where).select('user').lean().exec();
|
|
335
|
+
const deleted = store ? await store.deleteMany(where) : (await this.memberModel.deleteMany(where)).deletedCount;
|
|
336
|
+
for (const userId of new Set(members.map((member) => String(member.user)))) {
|
|
337
|
+
this.tenantGuard?.invalidateUser(userId);
|
|
338
|
+
}
|
|
339
|
+
this.tenantGuard?.invalidateTenant(tenantId);
|
|
340
|
+
return deleted;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Delete everything the framework knows to belong to a tenant. Answers how many records went per model (models with
|
|
346
|
+
* none are left out); with `dryRun` it only counts.
|
|
347
|
+
*
|
|
348
|
+
* First every way in ({@link endTenantAccess}): the memberships ({@link deleteAllMembers}) and the tenant's API
|
|
349
|
+
* tokens (`tenant`), which need no member — so nobody writes into the tenant while its data goes. Then, through
|
|
350
|
+
* {@link purgeTenantRecords}:
|
|
351
|
+
*
|
|
352
|
+
* - every model registered with `CorePersistenceModule.forFeature()` that has a `tenantId` path, on whatever
|
|
353
|
+
* database it is routed to — the project's own models included;
|
|
354
|
+
* - models with a `tenantId` path registered only through `MongooseModule.forFeature()` (MongoDB);
|
|
355
|
+
* - the core models that reference a tenant without a `tenantId` path: the AI budget limits, connection preferences,
|
|
356
|
+
* tool grants and tool policies (`scope: 'tenant'`, `refId`);
|
|
357
|
+
* - AI connections: one only this tenant could use is deleted, from a shared one the tenant is removed. Never the
|
|
358
|
+
* other way round — an empty `tenantIds` list means "every tenant".
|
|
359
|
+
*
|
|
360
|
+
* Left out: models in `multiTenancy.excludeSchemas` (a `tenantId` there is no ownership), every model named in
|
|
361
|
+
* `exclude`, users — the `users` table or collection, also where users carry a `tenantId`: a project with per-tenant
|
|
362
|
+
* users deletes them through its user service, which also ends their Better-Auth sessions and accounts — and files:
|
|
363
|
+
* their bytes can only be assigned to a tenant by the project, which deletes them through `CoreFileService`. The
|
|
364
|
+
* memberships cannot be excluded. A synchronized model keeps a tombstone
|
|
365
|
+
* per deleted record until the tombstone purge removes it.
|
|
366
|
+
*/
|
|
367
|
+
async purgeTenantData(tenantId: string, options: TenantPurgeOptions = {}): Promise<Record<string, number>> {
|
|
368
|
+
requireTenantId(tenantId);
|
|
369
|
+
const dryRun = options.dryRun === true;
|
|
370
|
+
const exclude = new Set([
|
|
371
|
+
...(ConfigService.configFastButReadOnly?.multiTenancy?.excludeSchemas ?? []),
|
|
372
|
+
...(options.exclude ?? []),
|
|
373
|
+
]);
|
|
374
|
+
exclude.delete(this.memberModel.modelName);
|
|
375
|
+
|
|
376
|
+
// Access ends before the data goes
|
|
377
|
+
const access = await this.endTenantAccess(tenantId, { dryRun, exclude });
|
|
378
|
+
const records = await this.purgeTenantRecords(tenantId, { dryRun, exclude });
|
|
379
|
+
|
|
380
|
+
return Object.fromEntries(Object.entries({ ...access, ...records }).filter(([, count]) => count > 0));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* End every way into a tenant (or with `dryRun` count it): the memberships, and the references that grant access on
|
|
385
|
+
* their own — the tenant's API tokens, which need no member. Runs before {@link purgeTenantRecords}, or a member or an
|
|
386
|
+
* integration with a tenant token writes into the tenant while its data goes
|
|
387
|
+
*/
|
|
388
|
+
protected async endTenantAccess(
|
|
389
|
+
tenantId: string,
|
|
390
|
+
{ dryRun, exclude }: { dryRun: boolean; exclude: ReadonlySet<string> },
|
|
391
|
+
): Promise<Record<string, number>> {
|
|
392
|
+
const report: Record<string, number> = {
|
|
393
|
+
[this.memberModel.modelName]: dryRun ? await this.countMembers(tenantId) : await this.deleteAllMembers(tenantId),
|
|
394
|
+
};
|
|
395
|
+
await RequestContext.runWithBypassTenantGuard(() =>
|
|
396
|
+
this.purgeReferences(tenantId, { access: true, dryRun, exclude }, report),
|
|
397
|
+
);
|
|
398
|
+
return report;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Delete (or with `dryRun` count) a tenant's records in every model except what {@link endTenantAccess} handles. The
|
|
403
|
+
* place to add a project's own references: override it, call `super`, add the counts. Runs after the access is gone;
|
|
404
|
+
* `exclude` already holds `multiTenancy.excludeSchemas`
|
|
405
|
+
*/
|
|
406
|
+
protected async purgeTenantRecords(
|
|
407
|
+
tenantId: string,
|
|
408
|
+
{ dryRun, exclude }: { dryRun: boolean; exclude: ReadonlySet<string> },
|
|
409
|
+
): Promise<Record<string, number>> {
|
|
410
|
+
const report: Record<string, number> = {};
|
|
411
|
+
await RequestContext.runWithBypassTenantGuard(async () => {
|
|
412
|
+
const handled = new Set<string>([
|
|
413
|
+
this.memberModel.modelName,
|
|
414
|
+
AI_CONNECTION_MODEL,
|
|
415
|
+
...TENANT_REFERENCES.map((reference) => reference.model),
|
|
416
|
+
]);
|
|
417
|
+
|
|
418
|
+
// Every routed model with a tenantId path
|
|
419
|
+
for (const [name, descriptor] of getModelDescriptors()) {
|
|
420
|
+
if (tableName(descriptor) === USERS_COLLECTION) {
|
|
421
|
+
handled.add(name);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const store =
|
|
425
|
+
descriptor.tenantScoped &&
|
|
426
|
+
!handled.has(name) &&
|
|
427
|
+
!exclude.has(name) &&
|
|
428
|
+
getModelStore(this.mongoConnection, name);
|
|
429
|
+
if (store) {
|
|
430
|
+
handled.add(name);
|
|
431
|
+
const where = { tenantId };
|
|
432
|
+
report[name] = dryRun ? await store.count(where) : await this.deleteInBatches(store, where);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// MongoDB models no store serves (registered only through MongooseModule.forFeature())
|
|
437
|
+
for (const [name, model] of Object.entries(this.mongoConnection?.models ?? {})) {
|
|
438
|
+
if (
|
|
439
|
+
!handled.has(name) &&
|
|
440
|
+
!exclude.has(name) &&
|
|
441
|
+
model.schema?.path('tenantId') &&
|
|
442
|
+
(model.schema.get('collection') ?? defaultCollectionName(name)) !== USERS_COLLECTION
|
|
443
|
+
) {
|
|
444
|
+
const where = { tenantId };
|
|
445
|
+
report[name] = dryRun ? await model.countDocuments(where) : (await model.deleteMany(where)).deletedCount;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Core models that name the tenant in another field
|
|
450
|
+
await this.purgeReferences(tenantId, { access: false, dryRun, exclude }, report);
|
|
451
|
+
|
|
452
|
+
if (!exclude.has(AI_CONNECTION_MODEL)) {
|
|
453
|
+
report[AI_CONNECTION_MODEL] = await this.purgeTenantConnections(tenantId, dryRun);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
return report;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The core references of {@link TENANT_REFERENCES} that do (`access`) or do not grant access, into `report` */
|
|
460
|
+
private async purgeReferences(
|
|
461
|
+
tenantId: string,
|
|
462
|
+
{ access, dryRun, exclude }: { access: boolean; dryRun: boolean; exclude: ReadonlySet<string> },
|
|
463
|
+
report: Record<string, number>,
|
|
464
|
+
): Promise<void> {
|
|
465
|
+
for (const reference of TENANT_REFERENCES) {
|
|
466
|
+
const store =
|
|
467
|
+
!!reference.access === access &&
|
|
468
|
+
!exclude.has(reference.model) &&
|
|
469
|
+
getModelStore(this.mongoConnection, reference.model);
|
|
470
|
+
if (store) {
|
|
471
|
+
const where = reference.where(tenantId);
|
|
472
|
+
report[reference.model] = dryRun ? await store.count(where) : await this.deleteInBatches(store, where);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Take a tenant out of the AI connections: delete a connection only this tenant could use, remove the tenant from
|
|
479
|
+
* the list of a shared one. Never empty a list — an empty `tenantIds` means "every tenant", so that would hand the
|
|
480
|
+
* connection, API key included, to all of them. Answers how many connections were touched
|
|
481
|
+
*/
|
|
482
|
+
protected async purgeTenantConnections(tenantId: string, dryRun: boolean): Promise<number> {
|
|
483
|
+
const store = getModelStore(this.mongoConnection, AI_CONNECTION_MODEL);
|
|
484
|
+
if (!store) {
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
const connections = await store.find({ tenantIds: { $all: [tenantId] } });
|
|
488
|
+
if (dryRun) {
|
|
489
|
+
return connections.length;
|
|
490
|
+
}
|
|
491
|
+
for (const connection of connections) {
|
|
492
|
+
const remaining = (connection.tenantIds ?? []).filter((id: string) => id !== tenantId);
|
|
493
|
+
if (remaining.length === 0) {
|
|
494
|
+
await store.deleteById(connection.id);
|
|
495
|
+
} else {
|
|
496
|
+
await store.updateById(connection.id, { tenantIds: remaining });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return connections.length;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Delete what `where` matches in batches of {@link TENANT_PURGE_BATCH} ids, as the tombstone purge does. One statement
|
|
504
|
+
* over all of a tenant's rows of a synchronized SQL model holds the model's change lock until it commits, and every
|
|
505
|
+
* other tenant's writes to that model wait for it. Answers how many records went
|
|
506
|
+
*/
|
|
507
|
+
protected async deleteInBatches(store: DataStore<any>, where: Record<string, unknown>): Promise<number> {
|
|
508
|
+
let deleted = 0;
|
|
509
|
+
let after: string | undefined;
|
|
510
|
+
for (;;) {
|
|
511
|
+
const batch = await store.find(
|
|
512
|
+
{ ...where, ...(after ? { id: { $gt: after } } : {}) },
|
|
513
|
+
{ limit: TENANT_PURGE_BATCH, select: ['id'], sort: { id: 1 } },
|
|
514
|
+
);
|
|
515
|
+
if (!batch.length) {
|
|
516
|
+
return deleted;
|
|
517
|
+
}
|
|
518
|
+
deleted += await store.deleteMany({ ...where, id: { $in: batch.map((record) => record.id) } });
|
|
519
|
+
if (batch.length < TENANT_PURGE_BATCH) {
|
|
520
|
+
return deleted;
|
|
521
|
+
}
|
|
522
|
+
after = batch[batch.length - 1].id;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The store serving the membership model on either database, or `undefined` for a model registered only through
|
|
528
|
+
* `MongooseModule.forFeature()` — then the Mongoose model is used
|
|
529
|
+
*/
|
|
530
|
+
protected memberDataStore(): DataStore<any> | undefined {
|
|
531
|
+
return getStoreForModel(this.memberModel) ?? getModelStore(this.mongoConnection, this.memberModel.modelName);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** How many memberships a tenant has, in any status */
|
|
535
|
+
protected countMembers(tenantId: string): Promise<number> {
|
|
536
|
+
const where = { tenant: tenantId };
|
|
537
|
+
const store = this.memberDataStore();
|
|
538
|
+
return RequestContext.runWithBypassTenantGuard(() =>
|
|
539
|
+
store ? store.count(where) : this.memberModel.countDocuments(where).exec(),
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Options of {@link CoreTenantService.purgeTenantData} */
|
|
545
|
+
export interface TenantPurgeOptions {
|
|
546
|
+
/** Only count what would be deleted */
|
|
547
|
+
dryRun?: boolean;
|
|
548
|
+
/** Model names to leave alone, in addition to `multiTenancy.excludeSchemas`. The memberships always go */
|
|
549
|
+
exclude?: string[];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Records `purgeTenantData()` deletes per statement */
|
|
553
|
+
const TENANT_PURGE_BATCH = 1000;
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* The table / collection the user model shares with Better-Auth. `purgeTenantData()` never deletes from it, also where
|
|
557
|
+
* users carry a `tenantId`: a user leaves through the user service, which also ends the Better-Auth sessions and accounts
|
|
558
|
+
*/
|
|
559
|
+
const USERS_COLLECTION = 'users';
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Core models that reference a tenant without a `tenantId` path, and how they name it. `access`: the record lets
|
|
563
|
+
* somebody into the tenant on its own, so it goes together with the memberships, before the data
|
|
564
|
+
*/
|
|
565
|
+
const TENANT_REFERENCES: ReadonlyArray<{
|
|
566
|
+
access?: boolean;
|
|
567
|
+
model: string;
|
|
568
|
+
where: (tenantId: string) => Record<string, unknown>;
|
|
569
|
+
}> = [
|
|
570
|
+
// A tenant token needs no membership
|
|
571
|
+
{ access: true, model: API_TOKEN_MODEL_TOKEN, where: (tenantId) => ({ tenant: tenantId }) },
|
|
572
|
+
{ model: AI_BUDGET_LIMIT_MODEL, where: (tenantId) => ({ refId: tenantId, scope: 'tenant' }) },
|
|
573
|
+
{ model: AI_CONNECTION_PREFERENCE_MODEL, where: (tenantId) => ({ refId: tenantId, scope: 'tenant' }) },
|
|
574
|
+
{ model: AI_TOOL_GRANT_MODEL, where: (tenantId) => ({ refId: tenantId, scope: 'tenant' }) },
|
|
575
|
+
{ model: AI_TOOL_POLICY_MODEL, where: (tenantId) => ({ refId: tenantId, scope: 'tenant' }) },
|
|
576
|
+
];
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Refuse a tenant id a delete must not run with. `deleteMany({ tenantId: undefined })` matches every document on
|
|
580
|
+
* MongoDB, and an operator object (`{ $ne: null }`) would match every tenant
|
|
581
|
+
*/
|
|
582
|
+
function requireTenantId(tenantId: unknown): asserts tenantId is string {
|
|
583
|
+
if (typeof tenantId !== 'string' || !tenantId.trim()) {
|
|
584
|
+
throw new BadRequestException('tenantId must be a non-empty string');
|
|
585
|
+
}
|
|
301
586
|
}
|
|
302
587
|
|
|
303
588
|
/**
|
package/src/core.module.ts
CHANGED
|
@@ -84,6 +84,7 @@ import { isHubEnabled, isHubQueriesEnabled } from './core/modules/hub/hub-config
|
|
|
84
84
|
import { CorePermissionsModule } from './core/modules/permissions/core-permissions.module.js';
|
|
85
85
|
import { CoreSystemSetupModule } from './core/modules/system-setup/core-system-setup.module.js';
|
|
86
86
|
import { CoreApiTokenModule } from './core/modules/api-token/core-api-token.module.js';
|
|
87
|
+
import { CoreSyncModule } from './core/modules/sync/core-sync.module.js';
|
|
87
88
|
import { CoreTenantModule } from './core/modules/tenant/core-tenant.module.js';
|
|
88
89
|
|
|
89
90
|
/** Configuration warnings of `forRoot()`, through Nest's logger so log levels and a project's logger apply. */
|
|
@@ -676,6 +677,30 @@ export class CoreModule implements NestModule {
|
|
|
676
677
|
imports.push(CoreApiTokenModule.forRoot({ ...overrides?.apiToken }));
|
|
677
678
|
}
|
|
678
679
|
|
|
680
|
+
// Add CoreSyncModule when sync is configured (boolean shorthand, presence implies enabled). Without it nothing of
|
|
681
|
+
// offline sync is registered: no route, no infrastructure model, no listener on the write paths.
|
|
682
|
+
const syncConfig = config.sync;
|
|
683
|
+
if (
|
|
684
|
+
syncConfig === true ||
|
|
685
|
+
(typeof syncConfig === 'object' && syncConfig !== null && syncConfig.enabled !== false)
|
|
686
|
+
) {
|
|
687
|
+
imports.push(
|
|
688
|
+
CoreSyncModule.forRoot({
|
|
689
|
+
...overrides?.sync,
|
|
690
|
+
// The subscription needs GraphQL, and is switched off with its channel or with all hints
|
|
691
|
+
graphQlHints:
|
|
692
|
+
config.graphQl !== false &&
|
|
693
|
+
!(
|
|
694
|
+
typeof syncConfig === 'object' &&
|
|
695
|
+
(syncConfig.hints === false ||
|
|
696
|
+
(typeof syncConfig.hints === 'object' &&
|
|
697
|
+
(syncConfig.hints.graphql === false || syncConfig.hints.enabled === false)))
|
|
698
|
+
),
|
|
699
|
+
path: typeof syncConfig === 'object' ? syncConfig.path : undefined,
|
|
700
|
+
}),
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
679
704
|
// Set exports
|
|
680
705
|
const exports: any[] = [
|
|
681
706
|
ConfigService,
|
package/src/index.ts
CHANGED
|
@@ -98,6 +98,8 @@ export * from './core/common/persistence/mongoose-data-store.js';
|
|
|
98
98
|
export * from './core/common/persistence/persisted-model.decorator.js';
|
|
99
99
|
export * from './core/common/persistence/persistence.tokens.js';
|
|
100
100
|
export * from './core/common/persistence/persistence-runtime.registry.js';
|
|
101
|
+
export { VersionConflictError } from './core/common/persistence/policies/sync.policy.js';
|
|
102
|
+
export * from './core/common/persistence/sync-model.js';
|
|
101
103
|
export * from './core/common/persistence/unsupported-capability.error.js';
|
|
102
104
|
export * from './core/common/persistence/sql/mysql/mysql-data-store.js';
|
|
103
105
|
export * from './core/common/persistence/sql/mysql/mysql-lock-store.js';
|
|
@@ -276,6 +278,25 @@ export * from './core/modules/system-setup/core-system-setup.controller.js';
|
|
|
276
278
|
export * from './core/modules/system-setup/core-system-setup.module.js';
|
|
277
279
|
export * from './core/modules/system-setup/core-system-setup.service.js';
|
|
278
280
|
|
|
281
|
+
// =====================================================================================================================
|
|
282
|
+
// Core - Modules - Sync (offline sync)
|
|
283
|
+
// =====================================================================================================================
|
|
284
|
+
|
|
285
|
+
export * from './core/modules/sync/core-sync-base.controller.js';
|
|
286
|
+
export * from './core/modules/sync/core-sync.constants.js';
|
|
287
|
+
export * from './core/modules/sync/core-sync.controller.js';
|
|
288
|
+
export * from './core/modules/sync/core-sync.decorators.js';
|
|
289
|
+
export * from './core/modules/sync/core-sync-hints.controller.js';
|
|
290
|
+
export * from './core/modules/sync/core-sync-hints.service.js';
|
|
291
|
+
export * from './core/modules/sync/core-sync-infrastructure.models.js';
|
|
292
|
+
export * from './core/modules/sync/core-sync.module.js';
|
|
293
|
+
export * from './core/modules/sync/core-sync-mutation.store.js';
|
|
294
|
+
export * from './core/modules/sync/core-sync.resolver.js';
|
|
295
|
+
export * from './core/modules/sync/core-sync.service.js';
|
|
296
|
+
export * from './core/modules/sync/core-sync-state.store.js';
|
|
297
|
+
export * from './core/modules/sync/interfaces/core-sync.interfaces.js';
|
|
298
|
+
export * from './core/modules/sync/models/core-sync-hint.model.js';
|
|
299
|
+
|
|
279
300
|
// =====================================================================================================================
|
|
280
301
|
// Core - Modules - Tus
|
|
281
302
|
// =====================================================================================================================
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { InputType } from '@nestjs/graphql';
|
|
2
|
+
|
|
3
|
+
import { Restricted } from '../../../../core/common/decorators/restricted.decorator.js';
|
|
4
|
+
import { UnifiedField } from '../../../../core/common/decorators/unified-field.decorator.js';
|
|
5
|
+
import { RoleEnum } from '../../../../core/common/enums/role.enum.js';
|
|
6
|
+
import { SyncNoteInput } from './sync-note.input.js';
|
|
7
|
+
|
|
8
|
+
/** Input of a pushed note that creates it; the client chose its id (protocol §3) */
|
|
9
|
+
@InputType({ description: 'Input to create a synchronized note' })
|
|
10
|
+
@Restricted(RoleEnum.ADMIN)
|
|
11
|
+
export class SyncNoteCreateInput extends SyncNoteInput {
|
|
12
|
+
@UnifiedField({ description: 'Title of the note', roles: RoleEnum.S_EVERYONE })
|
|
13
|
+
override title: string = undefined;
|
|
14
|
+
}
|