@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,270 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FieldDescriptor,
|
|
3
|
+
IndexDescriptor,
|
|
4
|
+
ModelDescriptor,
|
|
5
|
+
SyncDescriptor,
|
|
6
|
+
SyncIdFormat,
|
|
7
|
+
} from './model-descriptor.interface.js';
|
|
8
|
+
|
|
9
|
+
export type { SyncDescriptor, SyncIdFormat } from './model-descriptor.interface.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Synchronized models (offline sync, `docs/offline-sync-protocol.md`, offline-sync plan §3.2).
|
|
13
|
+
*
|
|
14
|
+
* A model declared with `@PersistedModel({ sync })` carries three system fields every store maintains and no caller
|
|
15
|
+
* writes — `version`, `deletedAt` and its position in the change order — and its delete leaves a tombstone instead of
|
|
16
|
+
* removing the record. This leaf holds the declaration, the descriptor it produces and the field names, so the stores,
|
|
17
|
+
* the schema compilers and the Mongoose plugin read one definition. Types and plain functions only: every persistence
|
|
18
|
+
* file may import it without an import cycle.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How a push meets a record that changed since the client's base version (protocol §6.4): `reject` answers `conflict`,
|
|
23
|
+
* `lww` applies the change on top (last to ARRIVE at the server wins), `merge` applies the fields the client changed
|
|
24
|
+
* unless one of them is declared `@SyncField('reject')`, `custom` asks the model service's `resolveSyncConflict()`.
|
|
25
|
+
*/
|
|
26
|
+
export type SyncConflictStrategy = 'custom' | 'lww' | 'merge' | 'reject';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Who may do what through the sync routes (protocol §1.3), passed as `serviceOptions.roles` to every `CrudService`
|
|
30
|
+
* call of the module — so `S_CREATOR` and `S_SELF` are decided against the stored record, as everywhere. The roles
|
|
31
|
+
* a project puts on its own routes do not reach `/sync`; an operation without roles here is open to `ADMIN` only.
|
|
32
|
+
*/
|
|
33
|
+
export interface SyncOperationRoles {
|
|
34
|
+
create?: string[];
|
|
35
|
+
delete?: string[];
|
|
36
|
+
/** Also who may receive hints for the model; `S_CREATOR` / `S_SELF` decide nothing here (a pull has no record) */
|
|
37
|
+
pull?: string[];
|
|
38
|
+
update?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SYNC_STRATEGIES: readonly SyncConflictStrategy[] = ['custom', 'lww', 'merge', 'reject'];
|
|
42
|
+
const SYNC_OPERATIONS: readonly (keyof SyncOperationRoles)[] = ['create', 'delete', 'pull', 'update'];
|
|
43
|
+
|
|
44
|
+
export interface SyncModelOptions {
|
|
45
|
+
/**
|
|
46
|
+
* Format of the model's ids. `uuidv7` is meant for NEW models: ids are stored as text on SQL and as a BSON UUID
|
|
47
|
+
* (binary subtype 4) on MongoDB, and every reference to the model must hold that format too.
|
|
48
|
+
* @default 'objectId'
|
|
49
|
+
*/
|
|
50
|
+
idFormat?: SyncIdFormat;
|
|
51
|
+
/**
|
|
52
|
+
* Operation roles of the sync routes (`SyncOperationRoles`). Read by the sync module only.
|
|
53
|
+
* @default every operation open to ADMIN only (the boot warns until roles are declared)
|
|
54
|
+
*/
|
|
55
|
+
roles?: SyncOperationRoles;
|
|
56
|
+
/**
|
|
57
|
+
* The user-id fields a tombstone keeps besides `tenantId` (e.g. `['ownerId']`), so a deletion reaches exactly the
|
|
58
|
+
* users who could see the record. Every other content field is cleared when the record is deleted.
|
|
59
|
+
* @default []
|
|
60
|
+
*/
|
|
61
|
+
scope?: string[];
|
|
62
|
+
/**
|
|
63
|
+
* What a push does when the record changed since the client's base version. Read by the sync module only.
|
|
64
|
+
* @default 'reject'
|
|
65
|
+
*/
|
|
66
|
+
strategy?: SyncConflictStrategy;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Incremented by every write; `1` after the insert */
|
|
70
|
+
export const SYNC_VERSION_FIELD = 'version';
|
|
71
|
+
/** `null` for a live record, the deletion time for a tombstone */
|
|
72
|
+
export const SYNC_DELETED_FIELD = 'deletedAt';
|
|
73
|
+
/** MongoDB's change position: the server clock of the last write (plan §3.4, C2) */
|
|
74
|
+
export const SYNC_CLOCK_FIELD = 'syncAt';
|
|
75
|
+
/** The SQL change position: a commit-ordered sequence number (plan §3.4, C1d) */
|
|
76
|
+
export const SYNC_SEQUENCE_FIELD = 'syncSeq';
|
|
77
|
+
/** The value a multi-row statement writes before its final statement numbers the rows (plan §3.4) */
|
|
78
|
+
export const SYNC_SEQUENCE_PENDING = -1;
|
|
79
|
+
|
|
80
|
+
/** Fields only the store writes on a synchronized model */
|
|
81
|
+
export const SYNC_SYSTEM_FIELDS: readonly string[] = Object.freeze([
|
|
82
|
+
SYNC_VERSION_FIELD,
|
|
83
|
+
SYNC_DELETED_FIELD,
|
|
84
|
+
SYNC_CLOCK_FIELD,
|
|
85
|
+
SYNC_SEQUENCE_FIELD,
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The live-record condition: the same form in every read filter and in every partial unique index, because MongoDB
|
|
90
|
+
* uses a partial index only for a query that implies its filter, and the equality also covers a document without the
|
|
91
|
+
* field (P2-4, measured in phase 2). SQL compiles it to `deleted_at IS NULL`.
|
|
92
|
+
*/
|
|
93
|
+
export function liveFilter(): { deletedAt: null } {
|
|
94
|
+
return { [SYNC_DELETED_FIELD]: null } as { deletedAt: null };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The declaration of `@PersistedModel({ sync })`, resolved; `undefined` for a model that is not synchronized */
|
|
98
|
+
export function resolveSyncOptions(
|
|
99
|
+
value: boolean | SyncModelOptions | undefined,
|
|
100
|
+
model: string,
|
|
101
|
+
): SyncDescriptor | undefined {
|
|
102
|
+
if (value === undefined || value === false) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const options: SyncModelOptions = value === true ? {} : value;
|
|
106
|
+
if (!options || typeof options !== 'object') {
|
|
107
|
+
throw new Error(`@PersistedModel(${model}): sync must be true or an options object`);
|
|
108
|
+
}
|
|
109
|
+
const idFormat = options.idFormat ?? 'objectId';
|
|
110
|
+
if (idFormat !== 'objectId' && idFormat !== 'uuidv7') {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`@PersistedModel(${model}): sync.idFormat must be 'objectId' or 'uuidv7', got ${JSON.stringify(idFormat)}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const scope = options.scope ?? [];
|
|
116
|
+
if (!Array.isArray(scope) || scope.some((field) => typeof field !== 'string' || !field)) {
|
|
117
|
+
throw new Error(`@PersistedModel(${model}): sync.scope must be a list of field names`);
|
|
118
|
+
}
|
|
119
|
+
if (options.strategy !== undefined && !SYNC_STRATEGIES.includes(options.strategy)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`@PersistedModel(${model}): sync.strategy must be one of ${SYNC_STRATEGIES.join(', ')}, got ${JSON.stringify(options.strategy)}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (options.roles !== undefined) {
|
|
125
|
+
const roles = options.roles;
|
|
126
|
+
const invalid =
|
|
127
|
+
!roles ||
|
|
128
|
+
typeof roles !== 'object' ||
|
|
129
|
+
Object.entries(roles).some(
|
|
130
|
+
([operation, list]) =>
|
|
131
|
+
!SYNC_OPERATIONS.includes(operation as keyof SyncOperationRoles) ||
|
|
132
|
+
!Array.isArray(list) ||
|
|
133
|
+
list.some((role) => typeof role !== 'string' || !role),
|
|
134
|
+
);
|
|
135
|
+
if (invalid) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`@PersistedModel(${model}): sync.roles maps ${SYNC_OPERATIONS.join(', ')} to lists of role names`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { idFormat, scope: Object.freeze([...scope]) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The descriptor of a synchronized model: the system fields added, the id typed by the declared format, and every
|
|
146
|
+
* unique index restricted to live records — so deleting a record and creating one with the same unique value works,
|
|
147
|
+
* on every database (plan §3.6). Database-neutral: the change position (`syncSeq` / `syncAt`) is the store's own
|
|
148
|
+
* column, added by the dialect that keeps it.
|
|
149
|
+
*
|
|
150
|
+
* Refuses what would make the model lose deletions or system fields: a TTL index (records would vanish without a
|
|
151
|
+
* tombstone), a declared field of a system name, a scope field the model does not have.
|
|
152
|
+
*/
|
|
153
|
+
export function withSyncDescriptor(descriptor: ModelDescriptor, sync: SyncDescriptor): ModelDescriptor {
|
|
154
|
+
const name = descriptor.name;
|
|
155
|
+
for (const field of SYNC_SYSTEM_FIELDS) {
|
|
156
|
+
if (descriptor.fields[field]) {
|
|
157
|
+
throw new Error(`${name}: "${field}" is reserved on a synchronized model — rename the field`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const field of sync.scope) {
|
|
161
|
+
if (!descriptor.fields[field]) {
|
|
162
|
+
throw new Error(`${name}: sync.scope names "${field}", which the model does not declare`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const nestedUnique = descriptor.indexes.find(
|
|
166
|
+
(index) => index.options.unique === true && Object.keys(index.fields).some((path) => path.includes('.')),
|
|
167
|
+
);
|
|
168
|
+
if (nestedUnique) {
|
|
169
|
+
// MongoDB keeps such an index on the embedded schema, out of reach of the live-only rewrite: a tombstone (which
|
|
170
|
+
// keeps no values) would collide on it with the next deletion
|
|
171
|
+
throw new Error(
|
|
172
|
+
`${name}: a unique index inside an embedded object (${JSON.stringify(nestedUnique.fields)}) is not supported on a ` +
|
|
173
|
+
'synchronized model — declare the unique field on the model itself',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (
|
|
177
|
+
descriptor.indexes.some((index) => 'expireAfterSeconds' in index.options) ||
|
|
178
|
+
Object.values(descriptor.fields).some((field) => field.expiresAfterSeconds !== undefined)
|
|
179
|
+
) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`${name}: a synchronized model cannot expire records (TTL) — an expired record disappears without a tombstone, ` +
|
|
182
|
+
'and every client keeps it',
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const fields: Record<string, FieldDescriptor> = { ...descriptor.fields };
|
|
187
|
+
if (sync.idFormat === 'uuidv7' && fields._id) {
|
|
188
|
+
fields._id = { ...fields._id, kind: 'uuid' };
|
|
189
|
+
}
|
|
190
|
+
fields[SYNC_VERSION_FIELD] = systemField(SYNC_VERSION_FIELD, 'number', 1);
|
|
191
|
+
fields[SYNC_DELETED_FIELD] = systemField(SYNC_DELETED_FIELD, 'date', null);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
...descriptor,
|
|
195
|
+
fields,
|
|
196
|
+
indexes: descriptor.indexes.map((index) => liveUniqueIndex(index)),
|
|
197
|
+
sync,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A unique index restricted to live records. A sparse one keeps its meaning through `$exists` — MongoDB refuses
|
|
203
|
+
* `sparse` together with a `partialFilterExpression`. Every other index is returned unchanged.
|
|
204
|
+
*/
|
|
205
|
+
export function liveUniqueIndex(index: IndexDescriptor): IndexDescriptor {
|
|
206
|
+
if (index.options.unique !== true) {
|
|
207
|
+
return index;
|
|
208
|
+
}
|
|
209
|
+
const { sparse, ...options } = index.options;
|
|
210
|
+
const existing = (options.partialFilterExpression as Record<string, unknown> | undefined) ?? {};
|
|
211
|
+
const exists =
|
|
212
|
+
sparse === true ? Object.fromEntries(Object.keys(index.fields).map((path) => [path, { $exists: true }])) : {};
|
|
213
|
+
return {
|
|
214
|
+
fields: { ...index.fields },
|
|
215
|
+
options: { ...options, partialFilterExpression: { ...existing, ...exists, ...liveFilter() } },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The descriptor a SQL store works with: a synchronized model's descriptor plus its commit-ordered change position
|
|
221
|
+
* `syncSeq` (a `bigint` column the database's trigger numbers, plan §3.4) and the pull's index over it, led by the
|
|
222
|
+
* tenant every pull on a tenant-scoped model is filtered by. Any other descriptor is returned as it is.
|
|
223
|
+
*/
|
|
224
|
+
export function withSequenceField(descriptor: ModelDescriptor): ModelDescriptor {
|
|
225
|
+
if (!descriptor.sync || descriptor.fields[SYNC_SEQUENCE_FIELD]) {
|
|
226
|
+
return descriptor;
|
|
227
|
+
}
|
|
228
|
+
const field = systemField(SYNC_SEQUENCE_FIELD, 'bigint', undefined);
|
|
229
|
+
return {
|
|
230
|
+
...descriptor,
|
|
231
|
+
fields: { ...descriptor.fields, [SYNC_SEQUENCE_FIELD]: field },
|
|
232
|
+
indexes: [
|
|
233
|
+
...descriptor.indexes,
|
|
234
|
+
{
|
|
235
|
+
fields: descriptor.tenantScoped ? { tenantId: 1, [SYNC_SEQUENCE_FIELD]: 1 } : { [SYNC_SEQUENCE_FIELD]: 1 },
|
|
236
|
+
options: {},
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Whether a partial filter is exactly the live-record condition (PostgreSQL then keeps `NULLS NOT DISTINCT`) */
|
|
243
|
+
export function isLiveFilter(filter: unknown): boolean {
|
|
244
|
+
if (!filter || typeof filter !== 'object') {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
const keys = Object.keys(filter);
|
|
248
|
+
return (
|
|
249
|
+
keys.length === 1 &&
|
|
250
|
+
keys[0] === SYNC_DELETED_FIELD &&
|
|
251
|
+
(filter as Record<string, unknown>)[SYNC_DELETED_FIELD] === null
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function systemField(name: string, kind: FieldDescriptor['kind'], value: unknown): FieldDescriptor {
|
|
256
|
+
return {
|
|
257
|
+
...(value === undefined ? {} : { default: value }),
|
|
258
|
+
hasDefault: value !== undefined,
|
|
259
|
+
index: false,
|
|
260
|
+
isArray: false,
|
|
261
|
+
kind,
|
|
262
|
+
lowercase: false,
|
|
263
|
+
name,
|
|
264
|
+
required: false,
|
|
265
|
+
select: true,
|
|
266
|
+
sparse: false,
|
|
267
|
+
trim: false,
|
|
268
|
+
unique: false,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
@@ -9,7 +9,19 @@ import { convertFilterArgsToQuery } from '../helpers/filter.helper.js';
|
|
|
9
9
|
import { mergePlain, prepareServiceOptionsForCreate } from '../helpers/input.helper.js';
|
|
10
10
|
import { ServiceOptions } from '../interfaces/service-options.interface.js';
|
|
11
11
|
import type { FindOptions, Where } from '../persistence/data-store.interface.js';
|
|
12
|
+
import { getModelDescriptor } from '../persistence/model-metadata.registry.js';
|
|
13
|
+
import { getSyncAccessListener } from '../persistence/sync-access.registry.js';
|
|
12
14
|
import { UnsupportedCapabilityError } from '../persistence/unsupported-capability.error.js';
|
|
15
|
+
import { isDuplicateIdError } from '../persistence/mongoose-data-store.js';
|
|
16
|
+
import { includeTombstones, syncOfSchema } from '../persistence/mongo/sync-schema.plugin.js';
|
|
17
|
+
import type { SyncDescriptor } from '../persistence/model-descriptor.interface.js';
|
|
18
|
+
import {
|
|
19
|
+
assertClientId,
|
|
20
|
+
assertExpectedVersion,
|
|
21
|
+
insertWithServerId,
|
|
22
|
+
suppliedId,
|
|
23
|
+
VersionConflictError,
|
|
24
|
+
} from '../persistence/policies/sync.policy.js';
|
|
13
25
|
import { CoreModel } from '../models/core-model.model.js';
|
|
14
26
|
import { PaginationInfo } from '../models/pagination-info.model.js';
|
|
15
27
|
import { PlainObject } from '../types/plain-object.type.js';
|
|
@@ -74,14 +86,36 @@ export abstract class CrudService<
|
|
|
74
86
|
return this.process(
|
|
75
87
|
async (data) => {
|
|
76
88
|
const currentUserId = serviceOptions?.currentUser?.id;
|
|
89
|
+
const createId = serviceOptions?.createId;
|
|
77
90
|
if (this.usesNeutralStore) {
|
|
78
|
-
return this.
|
|
91
|
+
return this.syncChanged(
|
|
92
|
+
await this.dataStore.insert({
|
|
93
|
+
...data.input,
|
|
94
|
+
...(createId === undefined || createId === null ? {} : { id: createId }),
|
|
95
|
+
createdBy: currentUserId,
|
|
96
|
+
updatedBy: currentUserId,
|
|
97
|
+
} as any),
|
|
98
|
+
);
|
|
79
99
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
100
|
+
const build = (id?: string) =>
|
|
101
|
+
new (this.mainDbModel as MongooseModel<Document & Model>)({
|
|
102
|
+
...data.input,
|
|
103
|
+
...(id === undefined ? {} : { _id: id }),
|
|
104
|
+
createdBy: currentUserId,
|
|
105
|
+
updatedBy: currentUserId,
|
|
106
|
+
});
|
|
107
|
+
const sync = this.mongoSync;
|
|
108
|
+
if (!sync) {
|
|
109
|
+
return build(createId ?? undefined).save();
|
|
110
|
+
}
|
|
111
|
+
// A synchronized model keeps a client's id in its format and replaces a taken server id (plan §3.3, E6)
|
|
112
|
+
const supplied = createId ?? suppliedId(data.input ?? {});
|
|
113
|
+
if (supplied !== undefined && supplied !== null) {
|
|
114
|
+
return this.syncChanged(
|
|
115
|
+
await build(assertClientId(supplied, sync.idFormat, this.mainDbModel.modelName)).save(),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return this.syncChanged(await insertWithServerId(sync.idFormat, (id) => build(id).save(), isDuplicateIdError));
|
|
85
119
|
},
|
|
86
120
|
{ input, serviceOptions },
|
|
87
121
|
);
|
|
@@ -110,8 +144,8 @@ export abstract class CrudService<
|
|
|
110
144
|
/**
|
|
111
145
|
* Get distinct values of a property
|
|
112
146
|
*/
|
|
113
|
-
distinct(property: string): Promise<string[]> {
|
|
114
|
-
return this.dataStore.distinct(property) as Promise<string[]>;
|
|
147
|
+
distinct(property: string, serviceOptions?: ServiceOptions): Promise<string[]> {
|
|
148
|
+
return this.dataStore.distinct(property, undefined, tombstoneOption(serviceOptions)) as Promise<string[]>;
|
|
115
149
|
}
|
|
116
150
|
|
|
117
151
|
/**
|
|
@@ -119,13 +153,16 @@ export abstract class CrudService<
|
|
|
119
153
|
*/
|
|
120
154
|
async get(id: string, serviceOptions?: ServiceOptions): Promise<Model> {
|
|
121
155
|
if (this.usesNeutralStore) {
|
|
122
|
-
const record = await this.dataStore.findById(id,
|
|
156
|
+
const record = await this.dataStore.findById(id, {
|
|
157
|
+
...neutralSelect(serviceOptions?.select),
|
|
158
|
+
...tombstoneOption(serviceOptions),
|
|
159
|
+
});
|
|
123
160
|
if (!record) {
|
|
124
161
|
throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
|
|
125
162
|
}
|
|
126
163
|
return this.process(async () => record, { dbObject: record, serviceOptions });
|
|
127
164
|
}
|
|
128
|
-
const query = this.mainDbModel.findById(id);
|
|
165
|
+
const query = this.withTombstones(this.mainDbModel.findById(id), serviceOptions);
|
|
129
166
|
if (serviceOptions?.select) {
|
|
130
167
|
query.select(serviceOptions.select);
|
|
131
168
|
}
|
|
@@ -190,7 +227,10 @@ export abstract class CrudService<
|
|
|
190
227
|
}
|
|
191
228
|
|
|
192
229
|
// Find in DB
|
|
193
|
-
let find = this.
|
|
230
|
+
let find = this.withTombstones(
|
|
231
|
+
this.mainDbModel.find(filterQuery.filterQuery, null, filterQuery.queryOptions),
|
|
232
|
+
serviceOptions,
|
|
233
|
+
);
|
|
194
234
|
const collation = serviceOptions?.collation || ConfigService.get('mongoose.collation');
|
|
195
235
|
if (collation) {
|
|
196
236
|
find = find.collation(collation);
|
|
@@ -261,7 +301,7 @@ export abstract class CrudService<
|
|
|
261
301
|
const { options, where } = neutralQuery(filterQuery, serviceOptions);
|
|
262
302
|
const [items, totalCount] = await Promise.all([
|
|
263
303
|
this.dataStore.find(where, filter?.samples ? { ...options, sample: filter.samples } : options),
|
|
264
|
-
this.dataStore.count(where),
|
|
304
|
+
this.dataStore.count(where, tombstoneOption(serviceOptions)),
|
|
265
305
|
]);
|
|
266
306
|
const queryOptions = filterQuery.queryOptions || {};
|
|
267
307
|
return {
|
|
@@ -323,7 +363,12 @@ export abstract class CrudService<
|
|
|
323
363
|
// Find and process db items
|
|
324
364
|
const collation = serviceOptions?.collation || ConfigService.get('mongoose.collation');
|
|
325
365
|
const dbResult =
|
|
326
|
-
(
|
|
366
|
+
(
|
|
367
|
+
await this.withTombstones(
|
|
368
|
+
this.mainDbModel.aggregate(aggregation, collation ? { collation } : {}),
|
|
369
|
+
serviceOptions,
|
|
370
|
+
).exec()
|
|
371
|
+
)[0] || {};
|
|
327
372
|
dbResult.totalCount = dbResult.totalCount?.[0]?.total || 0;
|
|
328
373
|
dbResult.items = dbResult.items?.map((item) => this.mainDbModel.hydrate(item)) || [];
|
|
329
374
|
|
|
@@ -443,13 +488,17 @@ export abstract class CrudService<
|
|
|
443
488
|
return this.dataStore.findOne(where, {
|
|
444
489
|
collation: options.collation,
|
|
445
490
|
exclude: options.exclude,
|
|
491
|
+
includeTombstones: options.includeTombstones,
|
|
446
492
|
select: options.select,
|
|
447
493
|
sort: options.sort,
|
|
448
494
|
});
|
|
449
495
|
}
|
|
450
496
|
|
|
451
497
|
// Find in DB
|
|
452
|
-
let find = this.
|
|
498
|
+
let find = this.withTombstones(
|
|
499
|
+
this.mainDbModel.findOne(filterQuery.filterQuery, null, filterQuery.queryOptions),
|
|
500
|
+
serviceOptions,
|
|
501
|
+
);
|
|
453
502
|
const collation = serviceOptions?.collation || ConfigService.get('mongoose.collation');
|
|
454
503
|
if (collation) {
|
|
455
504
|
find = find.collation(collation);
|
|
@@ -657,18 +706,31 @@ export abstract class CrudService<
|
|
|
657
706
|
* Update item via ID
|
|
658
707
|
*/
|
|
659
708
|
async update(id: string, input: PlainObject<UpdateInput>, serviceOptions?: ServiceOptions): Promise<Model> {
|
|
709
|
+
const expectedVersion = this.expectedVersionOf(serviceOptions);
|
|
660
710
|
if (this.usesNeutralStore) {
|
|
661
711
|
const stored = await this.dataStore.findById(id);
|
|
662
712
|
if (!stored) {
|
|
663
713
|
throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
|
|
664
714
|
}
|
|
715
|
+
this.assertVersion(id, stored, expectedVersion);
|
|
665
716
|
return this.process(
|
|
666
717
|
async (data) => {
|
|
667
718
|
// The same write as on MongoDB: the stored record merged with the input, written back whole — minus
|
|
668
719
|
// the id, which never changes, and the update time, which the store stamps
|
|
720
|
+
const before = this.syncAccessSnapshot(stored);
|
|
669
721
|
const merged = mergePlain(stored, data.input, { updatedBy: serviceOptions?.currentUser?.id });
|
|
670
722
|
const { _id: _mongoId, id: _id, updatedAt: _updatedAt, ...patch } = merged as Record<string, any>;
|
|
671
|
-
|
|
723
|
+
const updated =
|
|
724
|
+
expectedVersion === undefined
|
|
725
|
+
? await this.dataStore.updateById(id, patch as any)
|
|
726
|
+
: // One statement with the version as its condition: of two changes made on the same version, one applies
|
|
727
|
+
((await this.dataStore.updateOne({ id, version: expectedVersion } as Where, patch as any)) ??
|
|
728
|
+
this.versionConflict(id));
|
|
729
|
+
if (updated) {
|
|
730
|
+
await this.reportSyncAccessMove(before, merged);
|
|
731
|
+
this.reportSyncChange(before, merged);
|
|
732
|
+
}
|
|
733
|
+
return updated;
|
|
672
734
|
},
|
|
673
735
|
{ dbObject: stored, input, serviceOptions },
|
|
674
736
|
);
|
|
@@ -677,15 +739,28 @@ export abstract class CrudService<
|
|
|
677
739
|
if (!dbObject) {
|
|
678
740
|
throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
|
|
679
741
|
}
|
|
742
|
+
this.assertVersion(id, dbObject, expectedVersion);
|
|
680
743
|
return this.process(
|
|
681
744
|
async (data) => {
|
|
682
745
|
const currentUserId = serviceOptions?.currentUser?.id;
|
|
746
|
+
const before = this.syncAccessSnapshot(dbObject);
|
|
683
747
|
const merged = mergePlain(dbObject, data.input, { updatedBy: currentUserId });
|
|
684
|
-
const query =
|
|
748
|
+
const query =
|
|
749
|
+
expectedVersion === undefined
|
|
750
|
+
? this.mainDbModel.findByIdAndUpdate(id, merged, { returnDocument: 'after' })
|
|
751
|
+
: this.mainDbModel.findOneAndUpdate({ _id: id, version: expectedVersion } as any, merged, {
|
|
752
|
+
returnDocument: 'after',
|
|
753
|
+
});
|
|
685
754
|
if (serviceOptions?.select) {
|
|
686
755
|
query.select(serviceOptions.select);
|
|
687
756
|
}
|
|
688
|
-
|
|
757
|
+
const updated = await query.exec();
|
|
758
|
+
if (!updated) {
|
|
759
|
+
return expectedVersion === undefined ? updated : this.versionConflict(id);
|
|
760
|
+
}
|
|
761
|
+
await this.reportSyncAccessMove(before, merged);
|
|
762
|
+
this.reportSyncChange(before, merged);
|
|
763
|
+
return updated;
|
|
689
764
|
},
|
|
690
765
|
{ dbObject, input, serviceOptions },
|
|
691
766
|
);
|
|
@@ -715,14 +790,17 @@ export abstract class CrudService<
|
|
|
715
790
|
* Delete item via ID
|
|
716
791
|
*/
|
|
717
792
|
async delete(id: string, serviceOptions?: ServiceOptions): Promise<Model> {
|
|
793
|
+
const expectedVersion = this.expectedVersionOf(serviceOptions);
|
|
718
794
|
if (this.usesNeutralStore) {
|
|
719
795
|
const stored = await this.dataStore.findById(id, neutralSelect(serviceOptions?.select));
|
|
720
796
|
if (!stored) {
|
|
721
797
|
throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
|
|
722
798
|
}
|
|
799
|
+
this.assertVersion(id, stored, expectedVersion);
|
|
723
800
|
return this.process(
|
|
724
801
|
async () => {
|
|
725
|
-
await this.
|
|
802
|
+
await this.removeOrTombstone(id, expectedVersion);
|
|
803
|
+
this.reportSyncChange(stored);
|
|
726
804
|
return stored;
|
|
727
805
|
},
|
|
728
806
|
{ dbObject: stored, serviceOptions },
|
|
@@ -736,15 +814,169 @@ export abstract class CrudService<
|
|
|
736
814
|
if (!dbObject) {
|
|
737
815
|
throw new NotFoundException(`No ${this.mainModelConstructor.name} found with ID: ${id}`);
|
|
738
816
|
}
|
|
817
|
+
this.assertVersion(id, dbObject, expectedVersion);
|
|
739
818
|
return this.process(
|
|
740
819
|
async () => {
|
|
741
|
-
|
|
820
|
+
if (this.mongoSync) {
|
|
821
|
+
// A synchronized model's delete leaves a tombstone, which is how clients learn of it
|
|
822
|
+
await this.removeOrTombstone(id, expectedVersion);
|
|
823
|
+
this.reportSyncChange(dbObject);
|
|
824
|
+
} else {
|
|
825
|
+
await this.mainDbModel.findByIdAndDelete(id).exec();
|
|
826
|
+
}
|
|
742
827
|
return dbObject;
|
|
743
828
|
},
|
|
744
829
|
{ dbObject, serviceOptions },
|
|
745
830
|
);
|
|
746
831
|
}
|
|
747
832
|
|
|
833
|
+
// -- synchronized models (`@PersistedModel({ sync })`) ------------------------------------------------------
|
|
834
|
+
|
|
835
|
+
/** The sync declaration of the main model on its MongoDB path, read from the Mongoose schema */
|
|
836
|
+
protected get mongoSync(): SyncDescriptor | undefined {
|
|
837
|
+
return this.usesNeutralStore ? undefined : syncOfSchema(this.mainDbModel?.schema);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Whether the main model is synchronized, on whichever database it lives */
|
|
841
|
+
protected get isSynchronized(): boolean {
|
|
842
|
+
return !!this.dataStore?.capabilities.changeOrder;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** A Mongoose query or aggregation that also sees tombstones when the caller asked (`includeTombstones`) */
|
|
846
|
+
protected withTombstones<Q extends object>(query: Q, serviceOptions?: ServiceOptions): Q {
|
|
847
|
+
return serviceOptions?.includeTombstones && this.mongoSync ? includeTombstones(query) : query;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** `serviceOptions.expectedVersion`, checked; only a synchronized model has a version */
|
|
851
|
+
protected expectedVersionOf(serviceOptions?: ServiceOptions): number | undefined {
|
|
852
|
+
if (serviceOptions?.expectedVersion === undefined || serviceOptions.expectedVersion === null) {
|
|
853
|
+
return undefined;
|
|
854
|
+
}
|
|
855
|
+
if (!this.isSynchronized) {
|
|
856
|
+
throw new UnsupportedCapabilityError(
|
|
857
|
+
`expectedVersion: ${this.mainModelConstructor?.name ?? this.dataStore?.modelName} is not a synchronized model ` +
|
|
858
|
+
'(@PersistedModel({ sync }))',
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
return assertExpectedVersion(serviceOptions.expectedVersion);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** The early answer to a conditional change on a record that already has another version */
|
|
865
|
+
protected assertVersion(id: string, stored: any, expectedVersion: number | undefined): void {
|
|
866
|
+
if (expectedVersion !== undefined && stored?.version !== expectedVersion) {
|
|
867
|
+
this.versionConflict(id);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
protected versionConflict(id: string): never {
|
|
872
|
+
throw new VersionConflictError(this.mainModelConstructor?.name ?? this.dataStore.modelName, id);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* The tenant and scope fields of a stored record as they are BEFORE a write — taken before `mergePlain()`, which
|
|
877
|
+
* merges the input INTO the stored object. `undefined` when nobody listens or the model is not synchronized.
|
|
878
|
+
*/
|
|
879
|
+
protected syncAccessSnapshot(stored: Record<string, any> | undefined): Record<string, any> | undefined {
|
|
880
|
+
if (!stored || !getSyncAccessListener() || !this.isSynchronized) {
|
|
881
|
+
return undefined;
|
|
882
|
+
}
|
|
883
|
+
const scope = getModelDescriptor(this.dataStore.modelName)?.sync?.scope ?? [];
|
|
884
|
+
return Object.fromEntries(
|
|
885
|
+
['tenantId', ...scope].map((field) => [field, Array.isArray(stored[field]) ? [...stored[field]] : stored[field]]),
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Tell the sync module that this write took a synchronized record away from users who could see it: moved to
|
|
891
|
+
* another tenant, or a scope field (`sync.scope`, e.g. `ownerId`) changed away from a user (offline-sync plan §3.8).
|
|
892
|
+
* Their clients then answer the next pull with a full resync and lose the local copy. A no-op without the module.
|
|
893
|
+
* A failure is logged, never thrown: the write it follows has happened.
|
|
894
|
+
*/
|
|
895
|
+
protected async reportSyncAccessMove(
|
|
896
|
+
stored: Record<string, any> | undefined,
|
|
897
|
+
written: Record<string, any>,
|
|
898
|
+
): Promise<void> {
|
|
899
|
+
const listener = getSyncAccessListener();
|
|
900
|
+
if (!listener || !stored) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const model = this.dataStore.modelName;
|
|
904
|
+
const ids = (value: unknown): string[] =>
|
|
905
|
+
value === undefined || value === null
|
|
906
|
+
? []
|
|
907
|
+
: (Array.isArray(value) ? value : [value]).flatMap((item) =>
|
|
908
|
+
item === undefined || item === null ? [] : [String(getStringIds(item))],
|
|
909
|
+
);
|
|
910
|
+
const after = (field: string) => (field in written ? written[field] : stored[field]);
|
|
911
|
+
const [tenantBefore] = ids(stored.tenantId);
|
|
912
|
+
const tenantChanged = !!tenantBefore && ids(after('tenantId'))[0] !== tenantBefore;
|
|
913
|
+
const usersLost = (getModelDescriptor(model)?.sync?.scope ?? []).flatMap((field) => {
|
|
914
|
+
const now = new Set(ids(after(field)));
|
|
915
|
+
return ids(stored[field]).filter((user) => !now.has(user));
|
|
916
|
+
});
|
|
917
|
+
if (!tenantChanged && !usersLost.length) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
try {
|
|
921
|
+
await listener.recordMoved({ model, tenantChanged, tenantId: tenantBefore, usersLost: [...new Set(usersLost)] });
|
|
922
|
+
} catch (error) {
|
|
923
|
+
Logger.error(
|
|
924
|
+
`Sync access epoch for a moved ${model} record not bumped — affected clients keep their copy until their next ` +
|
|
925
|
+
`full resync: ${(error as Error)?.message ?? error}`,
|
|
926
|
+
this.constructor.name,
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Tell the sync module that a write of a synchronized record succeeded, so the subscribers of its hints learn of it
|
|
933
|
+
* (offline-sync plan §3.9) — with the tenants the record was in before and after the write. A no-op without the
|
|
934
|
+
* module and on any other model; a failure is logged, never thrown: the write has happened.
|
|
935
|
+
*/
|
|
936
|
+
protected reportSyncChange(...records: (Record<string, any> | null | undefined)[]): void {
|
|
937
|
+
const listener = getSyncAccessListener();
|
|
938
|
+
if (!listener?.recordChanged || !this.isSynchronized) {
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const tenantIds = new Set<string>();
|
|
942
|
+
for (const record of records) {
|
|
943
|
+
const tenantId = record?.tenantId;
|
|
944
|
+
if (tenantId !== undefined && tenantId !== null && tenantId !== '') {
|
|
945
|
+
tenantIds.add(String(getStringIds(tenantId)));
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
listener.recordChanged({ model: this.dataStore.modelName, tenantIds: [...tenantIds] });
|
|
950
|
+
} catch (error) {
|
|
951
|
+
Logger.warn(
|
|
952
|
+
`Sync hint for a ${this.dataStore.modelName} write not sent — clients learn of it at their next pull: ` +
|
|
953
|
+
`${(error as Error)?.message ?? error}`,
|
|
954
|
+
this.constructor.name,
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** `reportSyncChange()` for a created record, which it answers */
|
|
960
|
+
protected syncChanged<R>(created: R): R {
|
|
961
|
+
this.reportSyncChange(created as any);
|
|
962
|
+
return created;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Delete through the store: a synchronized model's record becomes a tombstone (conditional on `expectedVersion`),
|
|
967
|
+
* any other model's is removed
|
|
968
|
+
*/
|
|
969
|
+
protected async removeOrTombstone(id: string, expectedVersion: number | undefined): Promise<void> {
|
|
970
|
+
if (expectedVersion === undefined) {
|
|
971
|
+
await this.dataStore.deleteById(id);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const removed = await this.dataStore.deleteOne({ id, version: expectedVersion } as Where);
|
|
975
|
+
if (!removed) {
|
|
976
|
+
this.versionConflict(id);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
748
980
|
/**
|
|
749
981
|
* Delete item via ID without checks or restrictions
|
|
750
982
|
* Warning: Disables the handling of rights and restrictions!
|
|
@@ -897,6 +1129,11 @@ function neutralSelect(select: unknown): Pick<FindOptions, 'exclude' | 'select'>
|
|
|
897
1129
|
};
|
|
898
1130
|
}
|
|
899
1131
|
|
|
1132
|
+
/** `includeTombstones` of the service options as a store option */
|
|
1133
|
+
function tombstoneOption(serviceOptions?: ServiceOptions): { includeTombstones?: boolean } {
|
|
1134
|
+
return serviceOptions?.includeTombstones ? { includeTombstones: true } : {};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
900
1137
|
/** The filter and query options of a CrudService call as the neutral filter and find options */
|
|
901
1138
|
function neutralQuery(
|
|
902
1139
|
query: { filterQuery?: unknown; queryOptions?: QueryOptions },
|
|
@@ -906,6 +1143,7 @@ function neutralQuery(
|
|
|
906
1143
|
const options: FindOptions = {
|
|
907
1144
|
collation: (serviceOptions?.collation || ConfigService.get('mongoose.collation')) as Record<string, unknown>,
|
|
908
1145
|
...neutralSelect(serviceOptions?.select),
|
|
1146
|
+
...tombstoneOption(serviceOptions),
|
|
909
1147
|
};
|
|
910
1148
|
const skip = queryOptions.skip ?? queryOptions.offset;
|
|
911
1149
|
const limit = queryOptions.limit ?? queryOptions.take;
|