Read feature flags from cache (#11556)

We are now storing a workspace's feature flag map in our redis cache. 
The cache is invalidated upon feature flag update through the lab
resolver.
This commit is contained in:
Marie
2025-04-14 17:31:13 +02:00
committed by GitHub
parent 15eb96337f
commit d4deca45e8
22 changed files with 323 additions and 248 deletions

View File

@ -529,6 +529,12 @@ export type FeatureFlag = {
workspaceId: Scalars['String']; workspaceId: Scalars['String'];
}; };
export type FeatureFlagDto = {
__typename?: 'FeatureFlagDTO';
key: FeatureFlagKey;
value: Scalars['Boolean'];
};
export enum FeatureFlagKey { export enum FeatureFlagKey {
IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled', IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled',
IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled', IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled',
@ -889,7 +895,7 @@ export type Mutation = {
submitFormStep: Scalars['Boolean']; submitFormStep: Scalars['Boolean'];
switchToYearlyInterval: BillingUpdateOutput; switchToYearlyInterval: BillingUpdateOutput;
track: Analytics; track: Analytics;
updateLabPublicFeatureFlag: FeatureFlag; updateLabPublicFeatureFlag: FeatureFlagDto;
updateOneField: Field; updateOneField: Field;
updateOneObject: Object; updateOneObject: Object;
updateOneRole: Role; updateOneRole: Role;
@ -2239,7 +2245,7 @@ export type Workspace = {
defaultRole?: Maybe<Role>; defaultRole?: Maybe<Role>;
deletedAt?: Maybe<Scalars['DateTime']>; deletedAt?: Maybe<Scalars['DateTime']>;
displayName?: Maybe<Scalars['String']>; displayName?: Maybe<Scalars['String']>;
featureFlags?: Maybe<Array<FeatureFlag>>; featureFlags?: Maybe<Array<FeatureFlagDto>>;
hasValidEnterpriseKey: Scalars['Boolean']; hasValidEnterpriseKey: Scalars['Boolean'];
id: Scalars['UUID']; id: Scalars['UUID'];
inviteHash?: Maybe<Scalars['String']>; inviteHash?: Maybe<Scalars['String']>;
@ -2660,7 +2666,7 @@ export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
}>; }>;
export type UpdateLabPublicFeatureFlagMutation = { __typename?: 'Mutation', updateLabPublicFeatureFlag: { __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean } }; export type UpdateLabPublicFeatureFlagMutation = { __typename?: 'Mutation', updateLabPublicFeatureFlag: { __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean } };
export type RoleFragmentFragment = { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean }; export type RoleFragmentFragment = { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean };
@ -2759,7 +2765,7 @@ export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never
export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> }; export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@ -2776,7 +2782,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } }; export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } };
export type ActivateWorkflowVersionMutationVariables = Exact<{ export type ActivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['String']; workflowVersionId: Scalars['String'];
@ -3090,10 +3096,8 @@ export const UserQueryFragmentFragmentDoc = gql`
customUrl customUrl
} }
featureFlags { featureFlags {
id
key key
value value
workspaceId
} }
metadataVersion metadataVersion
currentBillingSubscription { currentBillingSubscription {
@ -4796,7 +4800,6 @@ export type GetSystemHealthStatusQueryResult = Apollo.QueryResult<GetSystemHealt
export const UpdateLabPublicFeatureFlagDocument = gql` export const UpdateLabPublicFeatureFlagDocument = gql`
mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) { mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) {
updateLabPublicFeatureFlag(input: $input) { updateLabPublicFeatureFlag(input: $input) {
id
key key
value value
} }

View File

@ -5,7 +5,6 @@ export const UPDATE_LAB_PUBLIC_FEATURE_FLAG = gql`
$input: UpdateLabPublicFeatureFlagInput! $input: UpdateLabPublicFeatureFlagInput!
) { ) {
updateLabPublicFeatureFlag(input: $input) { updateLabPublicFeatureFlag(input: $input) {
id
key key
value value
} }

View File

@ -2,11 +2,11 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState'; import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
import { useState } from 'react'; import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { import {
FeatureFlagKey, FeatureFlagKey,
useUpdateLabPublicFeatureFlagMutation, useUpdateLabPublicFeatureFlagMutation,
} from '~/generated/graphql'; } from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
export const useLabPublicFeatureFlags = () => { export const useLabPublicFeatureFlags = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -28,7 +28,6 @@ export const useLabPublicFeatureFlags = () => {
) ?? []), ) ?? []),
{ {
...updatedFlag, ...updatedFlag,
workspaceId: currentWorkspace.id,
}, },
], ],
}); });

View File

@ -43,10 +43,8 @@ export const USER_QUERY_FRAGMENT = gql`
customUrl customUrl
} }
featureFlags { featureFlags {
id
key key
value value
workspaceId
} }
metadataVersion metadataVersion
currentBillingSubscription { currentBillingSubscription {

View File

@ -59,22 +59,16 @@ export const mockCurrentWorkspace: Workspace = {
isMicrosoftAuthEnabled: false, isMicrosoftAuthEnabled: false,
featureFlags: [ featureFlags: [
{ {
id: '1492de61-5018-4368-8923-4f1eeaf988c4',
key: FeatureFlagKey.IsAirtableIntegrationEnabled, key: FeatureFlagKey.IsAirtableIntegrationEnabled,
value: true, value: true,
workspaceId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w',
}, },
{ {
id: '1492de61-5018-4368-8923-4f1eeaf988c5',
key: FeatureFlagKey.IsPostgreSQLIntegrationEnabled, key: FeatureFlagKey.IsPostgreSQLIntegrationEnabled,
value: true, value: true,
workspaceId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w',
}, },
{ {
id: '1492de61-5018-4368-8923-4f1eeaf988c6',
key: FeatureFlagKey.IsWorkflowEnabled, key: FeatureFlagKey.IsWorkflowEnabled,
value: true, value: true,
workspaceId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w',
}, },
], ],
createdAt: '2023-04-26T10:23:42.33625+00:00', createdAt: '2023-04-26T10:23:42.33625+00:00',

View File

@ -0,0 +1,16 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Column } from 'typeorm';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@ObjectType('FeatureFlagDTO')
export class FeatureFlagDTO {
@Field(() => FeatureFlagKey)
@Column({ nullable: false, type: 'text' })
key: FeatureFlagKey;
@Field()
@Column({ nullable: false })
value: boolean;
}

View File

@ -6,6 +6,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceFeatureFlagsMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module';
@Module({ @Module({
imports: [ imports: [
@ -15,6 +16,7 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
services: [], services: [],
resolvers: [], resolvers: [],
}), }),
WorkspaceFeatureFlagsMapCacheModule,
], ],
exports: [FeatureFlagService], exports: [FeatureFlagService],
providers: [FeatureFlagService], providers: [FeatureFlagService],

View File

@ -10,6 +10,7 @@ import {
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate'; import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate'; import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate';
import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service';
jest.mock( jest.mock(
'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate', 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate',
@ -29,6 +30,11 @@ describe('FeatureFlagService', () => {
save: jest.fn(), save: jest.fn(),
}; };
const mockWorkspaceFeatureFlagsMapCacheService = {
getWorkspaceFeatureFlagsMap: jest.fn(),
recomputeFeatureFlagsMapCache: jest.fn(),
};
const workspaceId = 'workspace-id'; const workspaceId = 'workspace-id';
const featureFlag = FeatureFlagKey.IsWorkflowEnabled; const featureFlag = FeatureFlagKey.IsWorkflowEnabled;
@ -47,6 +53,10 @@ describe('FeatureFlagService', () => {
provide: getRepositoryToken(FeatureFlag, 'core'), provide: getRepositoryToken(FeatureFlag, 'core'),
useValue: mockFeatureFlagRepository, useValue: mockFeatureFlagRepository,
}, },
{
provide: WorkspaceFeatureFlagsMapCacheService,
useValue: mockWorkspaceFeatureFlagsMapCacheService,
},
], ],
}).compile(); }).compile();
@ -60,27 +70,29 @@ describe('FeatureFlagService', () => {
describe('isFeatureEnabled', () => { describe('isFeatureEnabled', () => {
it('should return true when feature flag is enabled', async () => { it('should return true when feature flag is enabled', async () => {
// Prepare // Prepare
mockFeatureFlagRepository.findOneBy.mockResolvedValue({ mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap.mockResolvedValue(
key: featureFlag, {
value: true, [featureFlag]: true,
workspaceId, },
}); );
// Act // Act
const result = await service.isFeatureEnabled(featureFlag, workspaceId); const result = await service.isFeatureEnabled(featureFlag, workspaceId);
// Assert // Assert
expect(result).toBe(true); expect(result).toBe(true);
expect(mockFeatureFlagRepository.findOneBy).toHaveBeenCalledWith({ expect(
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap,
).toHaveBeenCalledWith({
workspaceId, workspaceId,
key: featureFlag,
value: true,
}); });
}); });
it('should return false when feature flag is not found', async () => { it('should return false when feature flag is not found', async () => {
// Prepare // Prepare
mockFeatureFlagRepository.findOneBy.mockResolvedValue(null); mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap.mockResolvedValue(
{},
);
// Act // Act
const result = await service.isFeatureEnabled(featureFlag, workspaceId); const result = await service.isFeatureEnabled(featureFlag, workspaceId);
@ -94,7 +106,6 @@ describe('FeatureFlagService', () => {
mockFeatureFlagRepository.findOneBy.mockResolvedValue({ mockFeatureFlagRepository.findOneBy.mockResolvedValue({
key: featureFlag, key: featureFlag,
value: false, value: false,
workspaceId,
}); });
// Act // Act
@ -108,21 +119,25 @@ describe('FeatureFlagService', () => {
describe('getWorkspaceFeatureFlags', () => { describe('getWorkspaceFeatureFlags', () => {
it('should return all feature flags for a workspace', async () => { it('should return all feature flags for a workspace', async () => {
// Prepare // Prepare
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap.mockResolvedValue(
{
[FeatureFlagKey.IsWorkflowEnabled]: true,
[FeatureFlagKey.IsCopilotEnabled]: false,
},
);
const mockFeatureFlags = [ const mockFeatureFlags = [
{ key: FeatureFlagKey.IsWorkflowEnabled, value: true, workspaceId }, { key: FeatureFlagKey.IsWorkflowEnabled, value: true },
{ key: FeatureFlagKey.IsCopilotEnabled, value: false, workspaceId }, { key: FeatureFlagKey.IsCopilotEnabled, value: false },
]; ];
mockFeatureFlagRepository.find.mockResolvedValue(mockFeatureFlags);
// Act // Act
const result = await service.getWorkspaceFeatureFlags(workspaceId); const result = await service.getWorkspaceFeatureFlags(workspaceId);
// Assert // Assert
expect(result).toEqual(mockFeatureFlags); expect(result).toEqual(mockFeatureFlags);
expect(mockFeatureFlagRepository.find).toHaveBeenCalledWith({ expect(
where: { workspaceId }, mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap,
}); ).toHaveBeenCalledWith({ workspaceId });
}); });
}); });

View File

@ -5,6 +5,7 @@ import { Repository } from 'typeorm';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag-dto';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { import {
@ -13,47 +14,46 @@ import {
} from 'src/engine/core-modules/feature-flag/feature-flag.exception'; } from 'src/engine/core-modules/feature-flag/feature-flag.exception';
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate'; import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate'; import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate';
import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service';
@Injectable() @Injectable()
export class FeatureFlagService { export class FeatureFlagService {
constructor( constructor(
@InjectRepository(FeatureFlag, 'core') @InjectRepository(FeatureFlag, 'core')
private readonly featureFlagRepository: Repository<FeatureFlag>, private readonly featureFlagRepository: Repository<FeatureFlag>,
private readonly workspaceFeatureFlagsMapCacheService: WorkspaceFeatureFlagsMapCacheService,
) {} ) {}
public async isFeatureEnabled( public async isFeatureEnabled(
key: FeatureFlagKey, key: FeatureFlagKey,
workspaceId: string, workspaceId: string,
): Promise<boolean> { ): Promise<boolean> {
const featureFlag = await this.featureFlagRepository.findOneBy({ const featureFlagMap = await this.getWorkspaceFeatureFlagsMap(workspaceId);
workspaceId,
key,
value: true,
});
return !!featureFlag?.value; return !!featureFlagMap[key];
} }
public async getWorkspaceFeatureFlags( public async getWorkspaceFeatureFlags(
workspaceId: string, workspaceId: string,
): Promise<FeatureFlag[]> { ): Promise<FeatureFlagDTO[]> {
return this.featureFlagRepository.find({ where: { workspaceId } }); const workspaceFeatureFlagsMap =
await this.workspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap(
{ workspaceId },
);
return Object.entries(workspaceFeatureFlagsMap).map(([key, value]) => ({
key: key as FeatureFlagKey,
value,
}));
} }
public async getWorkspaceFeatureFlagsMap( public async getWorkspaceFeatureFlagsMap(
workspaceId: string, workspaceId: string,
): Promise<FeatureFlagMap> { ): Promise<FeatureFlagMap> {
const workspaceFeatureFlags = const workspaceFeatureFlagsMap =
await this.getWorkspaceFeatureFlags(workspaceId); await this.workspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap(
{ workspaceId },
const workspaceFeatureFlagsMap = workspaceFeatureFlags.reduce( );
(result, currentFeatureFlag) => {
result[currentFeatureFlag.key] = currentFeatureFlag.value;
return result;
},
{} as FeatureFlagMap,
);
return workspaceFeatureFlagsMap; return workspaceFeatureFlagsMap;
} }
@ -62,13 +62,21 @@ export class FeatureFlagService {
keys: FeatureFlagKey[], keys: FeatureFlagKey[],
workspaceId: string, workspaceId: string,
): Promise<void> { ): Promise<void> {
await this.featureFlagRepository.upsert( if (keys.length > 0) {
keys.map((key) => ({ workspaceId, key, value: true })), await this.featureFlagRepository.upsert(
{ keys.map((key) => ({ workspaceId, key, value: true })),
conflictPaths: ['workspaceId', 'key'], {
skipUpdateIfNoValuesChanged: true, conflictPaths: ['workspaceId', 'key'],
}, skipUpdateIfNoValuesChanged: true,
); },
);
await this.workspaceFeatureFlagsMapCacheService.recomputeFeatureFlagsMapCache(
{
workspaceId: workspaceId,
},
);
}
} }
public async upsertWorkspaceFeatureFlag({ public async upsertWorkspaceFeatureFlag({
@ -120,6 +128,14 @@ export class FeatureFlagService {
workspaceId: workspaceId, workspaceId: workspaceId,
}; };
return await this.featureFlagRepository.save(featureFlagToSave); const result = await this.featureFlagRepository.save(featureFlagToSave);
await this.workspaceFeatureFlagsMapCacheService.recomputeFeatureFlagsMapCache(
{
workspaceId: workspaceId,
},
);
return result;
} }
} }

View File

@ -2,16 +2,11 @@ import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceFeatureFlagMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module';
import { LabResolver } from './lab.resolver'; import { LabResolver } from './lab.resolver';
@Module({ @Module({
imports: [ imports: [FeatureFlagModule, PermissionsModule],
FeatureFlagModule,
PermissionsModule,
WorkspaceFeatureFlagMapCacheModule,
],
providers: [LabResolver], providers: [LabResolver],
exports: [], exports: [],
}) })

View File

@ -2,7 +2,7 @@ import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag-dto';
import { FeatureFlagException } from 'src/engine/core-modules/feature-flag/feature-flag.exception'; import { FeatureFlagException } from 'src/engine/core-modules/feature-flag/feature-flag.exception';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@ -13,23 +13,19 @@ import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { WorkspaceFeatureFlagMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service';
@Resolver() @Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter) @UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter)
@UseGuards(SettingsPermissionsGuard(SettingPermissionType.WORKSPACE)) @UseGuards(SettingsPermissionsGuard(SettingPermissionType.WORKSPACE))
export class LabResolver { export class LabResolver {
constructor( constructor(private featureFlagService: FeatureFlagService) {}
private featureFlagService: FeatureFlagService,
private workspaceFeatureFlagMapCacheService: WorkspaceFeatureFlagMapCacheService,
) {}
@UseGuards(WorkspaceAuthGuard) @UseGuards(WorkspaceAuthGuard)
@Mutation(() => FeatureFlag) @Mutation(() => FeatureFlagDTO)
async updateLabPublicFeatureFlag( async updateLabPublicFeatureFlag(
@Args('input') input: UpdateLabPublicFeatureFlagInput, @Args('input') input: UpdateLabPublicFeatureFlagInput,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
): Promise<FeatureFlag> { ): Promise<FeatureFlagDTO> {
try { try {
const result = await this.featureFlagService.upsertWorkspaceFeatureFlag({ const result = await this.featureFlagService.upsertWorkspaceFeatureFlag({
workspaceId: workspace.id, workspaceId: workspace.id,
@ -38,12 +34,6 @@ export class LabResolver {
shouldBePublic: true, shouldBePublic: true,
}); });
await this.workspaceFeatureFlagMapCacheService.recomputeFeatureFlagMapCache(
{
workspaceId: workspace.id,
},
);
return result; return result;
} catch (error) { } catch (error) {
if (error instanceof FeatureFlagException) { if (error instanceof FeatureFlagException) {

View File

@ -21,8 +21,8 @@ import { BillingSubscription } from 'src/engine/core-modules/billing/entities/bi
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records'; import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag-dto';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { FileService } from 'src/engine/core-modules/file/services/file.service';
@ -160,8 +160,10 @@ export class WorkspaceResolver {
return `${paths[0]}?token=${workspaceLogoToken}`; return `${paths[0]}?token=${workspaceLogoToken}`;
} }
@ResolveField(() => [FeatureFlag], { nullable: true }) @ResolveField(() => [FeatureFlagDTO], { nullable: true })
async featureFlags(@Parent() workspace: Workspace): Promise<FeatureFlag[]> { async featureFlags(
@Parent() workspace: Workspace,
): Promise<FeatureFlagDTO[]> {
const featureFlags = await this.featureFlagService.getWorkspaceFeatureFlags( const featureFlags = await this.featureFlagService.getWorkspaceFeatureFlags(
workspace.id, workspace.id,
); );

View File

@ -1,47 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class WorkspaceFeatureFlagMapCacheService {
logger = new Logger(WorkspaceFeatureFlagMapCacheService.name);
constructor(
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly featureFlagService: FeatureFlagService,
) {}
async recomputeFeatureFlagMapCache({
workspaceId,
ignoreLock = false,
}: {
workspaceId: string;
ignoreLock?: boolean;
}): Promise<void> {
const isAlreadyCaching =
await this.workspaceCacheStorageService.getFeatureFlagMapOngoingCachingLock(
workspaceId,
);
if (!ignoreLock && isAlreadyCaching) {
return;
}
await this.workspaceCacheStorageService.addFeatureFlagMapOngoingCachingLock(
workspaceId,
);
const freshFeatureFlagMap =
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspaceId);
await this.workspaceCacheStorageService.setFeatureFlagMap(
workspaceId,
freshFeatureFlagMap,
);
await this.workspaceCacheStorageService.removeFeatureFlagMapOngoingCachingLock(
workspaceId,
);
}
}

View File

@ -1,18 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceFeatureFlagMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
WorkspaceCacheStorageModule,
FeatureFlagModule,
],
providers: [WorkspaceFeatureFlagMapCacheService],
exports: [WorkspaceFeatureFlagMapCacheService],
})
export class WorkspaceFeatureFlagMapCacheModule {}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace, FeatureFlag], 'core'),
WorkspaceCacheStorageModule,
],
providers: [WorkspaceFeatureFlagsMapCacheService],
exports: [WorkspaceFeatureFlagsMapCacheService],
})
export class WorkspaceFeatureFlagsMapCacheModule {}

View File

@ -0,0 +1,102 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { TwentyORMExceptionCode } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { getFromCacheWithRecompute } from 'src/engine/utils/get-data-from-cache-with-recompute.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class WorkspaceFeatureFlagsMapCacheService {
logger = new Logger(WorkspaceFeatureFlagsMapCacheService.name);
constructor(
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
@InjectRepository(FeatureFlag, 'core')
private readonly featureFlagRepository: Repository<FeatureFlag>,
) {}
async getWorkspaceFeatureFlagsMap({
workspaceId,
}: {
workspaceId: string;
}): Promise<FeatureFlagMap> {
const { data: workspaceFeatureFlagsMap } =
await this.getWorkspaceFeatureFlagsMapAndVersion({ workspaceId });
return workspaceFeatureFlagsMap;
}
async getWorkspaceFeatureFlagsMapAndVersion({
workspaceId,
}: {
workspaceId: string;
}) {
return getFromCacheWithRecompute<string, FeatureFlagMap>({
workspaceId,
getCacheData: () =>
this.workspaceCacheStorageService.getFeatureFlagsMap(workspaceId),
getCacheVersion: () =>
this.workspaceCacheStorageService.getFeatureFlagsMapVersionFromCache(
workspaceId,
),
recomputeCache: (params) => this.recomputeFeatureFlagsMapCache(params),
cachedEntityName: 'Feature flag map',
exceptionCode: TwentyORMExceptionCode.FEATURE_FLAG_MAP_VERSION_NOT_FOUND,
});
}
async recomputeFeatureFlagsMapCache({
workspaceId,
ignoreLock = false,
}: {
workspaceId: string;
ignoreLock?: boolean;
}): Promise<void> {
const isAlreadyCaching =
await this.workspaceCacheStorageService.getFeatureFlagsMapOngoingCachingLock(
workspaceId,
);
if (!ignoreLock && isAlreadyCaching) {
return;
}
await this.workspaceCacheStorageService.addFeatureFlagMapOngoingCachingLock(
workspaceId,
);
const freshFeatureFlagMap =
await this.getFeatureFlagsMapFromDatabase(workspaceId);
await this.workspaceCacheStorageService.setFeatureFlagsMap(
workspaceId,
freshFeatureFlagMap,
);
await this.workspaceCacheStorageService.removeFeatureFlagsMapOngoingCachingLock(
workspaceId,
);
}
private async getFeatureFlagsMapFromDatabase(workspaceId: string) {
const workspaceFeatureFlags = await this.featureFlagRepository.find({
where: { workspaceId },
});
const workspaceFeatureFlagsMap = workspaceFeatureFlags.reduce(
(result, currentFeatureFlag) => {
result[currentFeatureFlag.key] = currentFeatureFlag.value;
return result;
},
{} as FeatureFlagMap,
);
return workspaceFeatureFlagsMap;
}
}

View File

@ -64,11 +64,11 @@ export class WorkspaceDataSource extends DataSource {
this.permissionsPerRoleId = permissionsPerRoleId; this.permissionsPerRoleId = permissionsPerRoleId;
} }
setFeatureFlagMap(featureFlagMap: FeatureFlagMap) { setFeatureFlagsMap(featureFlagMap: FeatureFlagMap) {
this.featureFlagMap = featureFlagMap; this.featureFlagMap = featureFlagMap;
} }
setFeatureFlagMapVersion(featureFlagMapVersion: string) { setFeatureFlagsMapVersion(featureFlagMapVersion: string) {
this.featureFlagMapVersion = featureFlagMapVersion; this.featureFlagMapVersion = featureFlagMapVersion;
} }
} }

View File

@ -10,7 +10,7 @@ import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interface
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { WorkspaceFeatureFlagMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service'; import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { WorkspaceRolesPermissionsCacheService } from 'src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.service'; import { WorkspaceRolesPermissionsCacheService } from 'src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.service';
import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
@ -21,6 +21,7 @@ import {
import { EntitySchemaFactory } from 'src/engine/twenty-orm/factories/entity-schema.factory'; import { EntitySchemaFactory } from 'src/engine/twenty-orm/factories/entity-schema.factory';
import { PromiseMemoizer } from 'src/engine/twenty-orm/storage/promise-memoizer.storage'; import { PromiseMemoizer } from 'src/engine/twenty-orm/storage/promise-memoizer.storage';
import { CacheKey } from 'src/engine/twenty-orm/storage/types/cache-key.type'; import { CacheKey } from 'src/engine/twenty-orm/storage/types/cache-key.type';
import { getFromCacheWithRecompute } from 'src/engine/utils/get-data-from-cache-with-recompute.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
type CacheResult<T, U> = { type CacheResult<T, U> = {
@ -40,7 +41,7 @@ export class WorkspaceDatasourceFactory {
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService, private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly entitySchemaFactory: EntitySchemaFactory, private readonly entitySchemaFactory: EntitySchemaFactory,
private readonly workspaceRolesPermissionsCacheService: WorkspaceRolesPermissionsCacheService, private readonly workspaceRolesPermissionsCacheService: WorkspaceRolesPermissionsCacheService,
private readonly workspaceFeatureFlagMapCacheService: WorkspaceFeatureFlagMapCacheService, private readonly workspaceFeatureFlagsMapCacheService: WorkspaceFeatureFlagsMapCacheService,
) {} ) {}
public async create( public async create(
@ -55,7 +56,9 @@ export class WorkspaceDatasourceFactory {
); );
const { data: cachedFeatureFlagMap, version: cachedFeatureFlagMapVersion } = const { data: cachedFeatureFlagMap, version: cachedFeatureFlagMapVersion } =
await this.getFeatureFlagMapFromCache({ workspaceId }); await this.workspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMapAndVersion(
{ workspaceId },
);
const isPermissionsV2Enabled = const isPermissionsV2Enabled =
cachedFeatureFlagMap[FeatureFlagKey.IsPermissionsV2Enabled]; cachedFeatureFlagMap[FeatureFlagKey.IsPermissionsV2Enabled];
@ -201,7 +204,7 @@ export class WorkspaceDatasourceFactory {
}); });
} }
await this.updateWorkspaceDataSourceFeatureFlagMapIfNeeded({ await this.updateWorkspaceDataSourceFeatureFlagsMapIfNeeded({
workspaceDataSource, workspaceDataSource,
cachedFeatureFlagMapVersion, cachedFeatureFlagMapVersion,
cachedFeatureFlagMap, cachedFeatureFlagMap,
@ -210,47 +213,6 @@ export class WorkspaceDatasourceFactory {
return workspaceDataSource; return workspaceDataSource;
} }
private async getFromCacheWithRecompute<T, U>({
workspaceId,
getCacheData,
getCacheVersion,
recomputeCache,
cachedEntityName,
exceptionCode,
}: {
workspaceId: string;
getCacheData: (workspaceId: string) => Promise<U | undefined>;
getCacheVersion: (workspaceId: string) => Promise<T | undefined>;
recomputeCache: (params: { workspaceId: string }) => Promise<void>;
cachedEntityName: string;
exceptionCode: TwentyORMExceptionCode;
}): Promise<CacheResult<T, U>> {
let cachedVersion: T | undefined;
let cachedData: U | undefined;
cachedVersion = await getCacheVersion(workspaceId);
cachedData = await getCacheData(workspaceId);
if (!isDefined(cachedData) || !isDefined(cachedVersion)) {
await recomputeCache({ workspaceId });
cachedData = await getCacheData(workspaceId);
cachedVersion = await getCacheVersion(workspaceId);
if (!isDefined(cachedData) || !isDefined(cachedVersion)) {
throw new TwentyORMException(
`${cachedEntityName} not found after recompute for workspace ${workspaceId}`,
exceptionCode,
);
}
}
return {
version: cachedVersion,
data: cachedData,
};
}
private async getRolesPermissionsFromCache({ private async getRolesPermissionsFromCache({
workspaceId, workspaceId,
isPermissionsV2Enabled, isPermissionsV2Enabled,
@ -267,7 +229,7 @@ export class WorkspaceDatasourceFactory {
return { version: undefined, data: undefined }; return { version: undefined, data: undefined };
} }
return this.getFromCacheWithRecompute< return getFromCacheWithRecompute<
string | undefined, string | undefined,
ObjectRecordsPermissionsByRoleId | undefined ObjectRecordsPermissionsByRoleId | undefined
>({ >({
@ -287,28 +249,6 @@ export class WorkspaceDatasourceFactory {
}); });
} }
private async getFeatureFlagMapFromCache({
workspaceId,
}: {
workspaceId: string;
}): Promise<CacheResult<string, FeatureFlagMap>> {
return this.getFromCacheWithRecompute<string, FeatureFlagMap>({
workspaceId,
getCacheData: () =>
this.workspaceCacheStorageService.getFeatureFlagMap(workspaceId),
getCacheVersion: () =>
this.workspaceCacheStorageService.getFeatureFlagMapVersionFromCache(
workspaceId,
),
recomputeCache: (params) =>
this.workspaceFeatureFlagMapCacheService.recomputeFeatureFlagMapCache(
params,
),
cachedEntityName: 'Feature flag map',
exceptionCode: TwentyORMExceptionCode.FEATURE_FLAG_MAP_VERSION_NOT_FOUND,
});
}
private updateWorkspaceDataSourceIfNeeded<T>({ private updateWorkspaceDataSourceIfNeeded<T>({
workspaceDataSource, workspaceDataSource,
currentVersion, currentVersion,
@ -355,7 +295,7 @@ export class WorkspaceDatasourceFactory {
}); });
} }
private async updateWorkspaceDataSourceFeatureFlagMapIfNeeded({ private async updateWorkspaceDataSourceFeatureFlagsMapIfNeeded({
workspaceDataSource, workspaceDataSource,
cachedFeatureFlagMapVersion, cachedFeatureFlagMapVersion,
cachedFeatureFlagMap, cachedFeatureFlagMap,
@ -369,9 +309,9 @@ export class WorkspaceDatasourceFactory {
currentVersion: workspaceDataSource.featureFlagMapVersion, currentVersion: workspaceDataSource.featureFlagMapVersion,
newVersion: cachedFeatureFlagMapVersion, newVersion: cachedFeatureFlagMapVersion,
newData: cachedFeatureFlagMap, newData: cachedFeatureFlagMap,
setData: (data) => workspaceDataSource.setFeatureFlagMap(data), setData: (data) => workspaceDataSource.setFeatureFlagsMap(data),
setVersion: (version) => setVersion: (version) =>
workspaceDataSource.setFeatureFlagMapVersion(version), workspaceDataSource.setFeatureFlagsMapVersion(version),
}); });
} }

View File

@ -6,7 +6,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity'; import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { WorkspaceFeatureFlagMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module'; import { WorkspaceFeatureFlagsMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module'; import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspaceRolesPermissionsCacheModule } from 'src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.module'; import { WorkspaceRolesPermissionsCacheModule } from 'src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.module';
import { entitySchemaFactories } from 'src/engine/twenty-orm/factories'; import { entitySchemaFactories } from 'src/engine/twenty-orm/factories';
@ -27,7 +27,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
WorkspaceMetadataCacheModule, WorkspaceMetadataCacheModule,
PermissionsModule, PermissionsModule,
WorkspaceRolesPermissionsCacheModule, WorkspaceRolesPermissionsCacheModule,
WorkspaceFeatureFlagMapCacheModule, WorkspaceFeatureFlagsMapCacheModule,
FeatureFlagModule, FeatureFlagModule,
], ],
providers: [ providers: [

View File

@ -0,0 +1,54 @@
import { isDefined } from 'twenty-shared/utils';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
type CacheResult<T, U> = {
version: T;
data: U;
};
const getFromCacheWithRecompute = async <T, U>({
workspaceId,
getCacheData,
getCacheVersion,
recomputeCache,
cachedEntityName,
exceptionCode,
}: {
workspaceId: string;
getCacheData: (workspaceId: string) => Promise<U | undefined>;
getCacheVersion: (workspaceId: string) => Promise<T | undefined>;
recomputeCache: (params: { workspaceId: string }) => Promise<void>;
cachedEntityName: string;
exceptionCode: TwentyORMExceptionCode;
}): Promise<CacheResult<T, U>> => {
let cachedVersion: T | undefined;
let cachedData: U | undefined;
cachedVersion = await getCacheVersion(workspaceId);
cachedData = await getCacheData(workspaceId);
if (!isDefined(cachedData) || !isDefined(cachedVersion)) {
await recomputeCache({ workspaceId });
cachedData = await getCacheData(workspaceId);
cachedVersion = await getCacheVersion(workspaceId);
if (!isDefined(cachedData) || !isDefined(cachedVersion)) {
throw new TwentyORMException(
`${cachedEntityName} not found after recompute for workspace ${workspaceId}`,
exceptionCode,
);
}
}
return {
version: cachedVersion,
data: cachedData,
};
};
export { CacheResult, getFromCacheWithRecompute };

View File

@ -26,9 +26,9 @@ export enum WorkspaceCacheKeys {
MetadataRolesPermissions = 'metadata:roles-permissions', MetadataRolesPermissions = 'metadata:roles-permissions',
MetadataRolesPermissionsVersion = 'metadata:roles-permissions-version', MetadataRolesPermissionsVersion = 'metadata:roles-permissions-version',
MetadataRolesPermissionsOngoingCachingLock = 'metadata:roles-permissions-ongoing-caching-lock', MetadataRolesPermissionsOngoingCachingLock = 'metadata:roles-permissions-ongoing-caching-lock',
MetadataFeatureFlagMap = 'metadata:feature-flag-map', FeatureFlagMap = 'feature-flag-map',
MetadataFeatureFlagMapVersion = 'metadata:feature-flag-map-version', FeatureFlagMapVersion = 'feature-flag-map-version',
MetadataFeatureFlagMapOngoingCachingLock = 'metadata:feature-flag-map-ongoing-caching-lock', FeatureFlagMapOngoingCachingLock = 'feature-flag-map-ongoing-caching-lock',
} }
const TTL_INFINITE = 0; const TTL_INFINITE = 0;
@ -254,19 +254,19 @@ export class WorkspaceCacheStorageService {
); );
} }
getFeatureFlagMapVersionFromCache( getFeatureFlagsMapVersionFromCache(
workspaceId: string, workspaceId: string,
): Promise<string | undefined> { ): Promise<string | undefined> {
return this.cacheStorageService.get<string>( return this.cacheStorageService.get<string>(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapVersion}:${workspaceId}`, `${WorkspaceCacheKeys.FeatureFlagMapVersion}:${workspaceId}`,
); );
} }
async setFeatureFlagMapVersion(workspaceId: string): Promise<string> { async setFeatureFlagsMapVersion(workspaceId: string): Promise<string> {
const featureFlagMapVersion = crypto.randomUUID(); const featureFlagMapVersion = crypto.randomUUID();
await this.cacheStorageService.set<string>( await this.cacheStorageService.set<string>(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapVersion}:${workspaceId}`, `${WorkspaceCacheKeys.FeatureFlagMapVersion}:${workspaceId}`,
featureFlagMapVersion, featureFlagMapVersion,
TTL_INFINITE, TTL_INFINITE,
); );
@ -274,7 +274,7 @@ export class WorkspaceCacheStorageService {
return featureFlagMapVersion; return featureFlagMapVersion;
} }
async setFeatureFlagMap( async setFeatureFlagsMap(
workspaceId: string, workspaceId: string,
featureFlagMap: FeatureFlagMap, featureFlagMap: FeatureFlagMap,
): Promise<{ ): Promise<{
@ -282,41 +282,41 @@ export class WorkspaceCacheStorageService {
}> { }> {
const [, newFeatureFlagMapVersion] = await Promise.all([ const [, newFeatureFlagMapVersion] = await Promise.all([
this.cacheStorageService.set<FeatureFlagMap>( this.cacheStorageService.set<FeatureFlagMap>(
`${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`, `${WorkspaceCacheKeys.FeatureFlagMap}:${workspaceId}`,
featureFlagMap, featureFlagMap,
TTL_INFINITE, TTL_INFINITE,
), ),
this.setFeatureFlagMapVersion(workspaceId), this.setFeatureFlagsMapVersion(workspaceId),
]); ]);
return { newFeatureFlagMapVersion }; return { newFeatureFlagMapVersion };
} }
getFeatureFlagMap(workspaceId: string): Promise<FeatureFlagMap | undefined> { getFeatureFlagsMap(workspaceId: string): Promise<FeatureFlagMap | undefined> {
return this.cacheStorageService.get<FeatureFlagMap>( return this.cacheStorageService.get<FeatureFlagMap>(
`${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`, `${WorkspaceCacheKeys.FeatureFlagMap}:${workspaceId}`,
); );
} }
addFeatureFlagMapOngoingCachingLock(workspaceId: string) { addFeatureFlagMapOngoingCachingLock(workspaceId: string) {
return this.cacheStorageService.set<boolean>( return this.cacheStorageService.set<boolean>(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`, `${WorkspaceCacheKeys.FeatureFlagMapOngoingCachingLock}:${workspaceId}`,
true, true,
1_000 * 60, // 1 minute 1_000 * 60, // 1 minute
); );
} }
removeFeatureFlagMapOngoingCachingLock(workspaceId: string) { removeFeatureFlagsMapOngoingCachingLock(workspaceId: string) {
return this.cacheStorageService.del( return this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`, `${WorkspaceCacheKeys.FeatureFlagMapOngoingCachingLock}:${workspaceId}`,
); );
} }
getFeatureFlagMapOngoingCachingLock( getFeatureFlagsMapOngoingCachingLock(
workspaceId: string, workspaceId: string,
): Promise<boolean | undefined> { ): Promise<boolean | undefined> {
return this.cacheStorageService.get<boolean>( return this.cacheStorageService.get<boolean>(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`, `${WorkspaceCacheKeys.FeatureFlagMapOngoingCachingLock}:${workspaceId}`,
); );
} }
@ -353,15 +353,15 @@ export class WorkspaceCacheStorageService {
); );
await this.cacheStorageService.del( await this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`, `${WorkspaceCacheKeys.FeatureFlagMap}:${workspaceId}`,
); );
await this.cacheStorageService.del( await this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapVersion}:${workspaceId}`, `${WorkspaceCacheKeys.FeatureFlagMapVersion}:${workspaceId}`,
); );
await this.cacheStorageService.del( await this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`, `${WorkspaceCacheKeys.FeatureFlagMapOngoingCachingLock}:${workspaceId}`,
); );
// TODO: remove this after the feature flag is droped // TODO: remove this after the feature flag is droped

View File

@ -456,7 +456,6 @@ describe('workspace permissions', () => {
$input: UpdateLabPublicFeatureFlagInput! $input: UpdateLabPublicFeatureFlagInput!
) { ) {
updateLabPublicFeatureFlag(input: $input) { updateLabPublicFeatureFlag(input: $input) {
id
key key
value value
} }
@ -490,7 +489,6 @@ describe('workspace permissions', () => {
$input: UpdateLabPublicFeatureFlagInput! $input: UpdateLabPublicFeatureFlagInput!
) { ) {
updateLabPublicFeatureFlag(input: $input) { updateLabPublicFeatureFlag(input: $input) {
id
key key
value value
} }