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:
@ -529,6 +529,12 @@ export type FeatureFlag = {
|
||||
workspaceId: Scalars['String'];
|
||||
};
|
||||
|
||||
export type FeatureFlagDto = {
|
||||
__typename?: 'FeatureFlagDTO';
|
||||
key: FeatureFlagKey;
|
||||
value: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export enum FeatureFlagKey {
|
||||
IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled',
|
||||
IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled',
|
||||
@ -889,7 +895,7 @@ export type Mutation = {
|
||||
submitFormStep: Scalars['Boolean'];
|
||||
switchToYearlyInterval: BillingUpdateOutput;
|
||||
track: Analytics;
|
||||
updateLabPublicFeatureFlag: FeatureFlag;
|
||||
updateLabPublicFeatureFlag: FeatureFlagDto;
|
||||
updateOneField: Field;
|
||||
updateOneObject: Object;
|
||||
updateOneRole: Role;
|
||||
@ -2239,7 +2245,7 @@ export type Workspace = {
|
||||
defaultRole?: Maybe<Role>;
|
||||
deletedAt?: Maybe<Scalars['DateTime']>;
|
||||
displayName?: Maybe<Scalars['String']>;
|
||||
featureFlags?: Maybe<Array<FeatureFlag>>;
|
||||
featureFlags?: Maybe<Array<FeatureFlagDto>>;
|
||||
hasValidEnterpriseKey: Scalars['Boolean'];
|
||||
id: Scalars['UUID'];
|
||||
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 };
|
||||
|
||||
@ -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 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; }>;
|
||||
|
||||
@ -2776,7 +2782,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
|
||||
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<{
|
||||
workflowVersionId: Scalars['String'];
|
||||
@ -3090,10 +3096,8 @@ export const UserQueryFragmentFragmentDoc = gql`
|
||||
customUrl
|
||||
}
|
||||
featureFlags {
|
||||
id
|
||||
key
|
||||
value
|
||||
workspaceId
|
||||
}
|
||||
metadataVersion
|
||||
currentBillingSubscription {
|
||||
@ -4796,7 +4800,6 @@ export type GetSystemHealthStatusQueryResult = Apollo.QueryResult<GetSystemHealt
|
||||
export const UpdateLabPublicFeatureFlagDocument = gql`
|
||||
mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) {
|
||||
updateLabPublicFeatureFlag(input: $input) {
|
||||
id
|
||||
key
|
||||
value
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ export const UPDATE_LAB_PUBLIC_FEATURE_FLAG = gql`
|
||||
$input: UpdateLabPublicFeatureFlagInput!
|
||||
) {
|
||||
updateLabPublicFeatureFlag(input: $input) {
|
||||
id
|
||||
key
|
||||
value
|
||||
}
|
||||
|
||||
@ -2,11 +2,11 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
useUpdateLabPublicFeatureFlagMutation,
|
||||
} from '~/generated/graphql';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useLabPublicFeatureFlags = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -28,7 +28,6 @@ export const useLabPublicFeatureFlags = () => {
|
||||
) ?? []),
|
||||
{
|
||||
...updatedFlag,
|
||||
workspaceId: currentWorkspace.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -43,10 +43,8 @@ export const USER_QUERY_FRAGMENT = gql`
|
||||
customUrl
|
||||
}
|
||||
featureFlags {
|
||||
id
|
||||
key
|
||||
value
|
||||
workspaceId
|
||||
}
|
||||
metadataVersion
|
||||
currentBillingSubscription {
|
||||
|
||||
@ -59,22 +59,16 @@ export const mockCurrentWorkspace: Workspace = {
|
||||
isMicrosoftAuthEnabled: false,
|
||||
featureFlags: [
|
||||
{
|
||||
id: '1492de61-5018-4368-8923-4f1eeaf988c4',
|
||||
key: FeatureFlagKey.IsAirtableIntegrationEnabled,
|
||||
value: true,
|
||||
workspaceId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w',
|
||||
},
|
||||
{
|
||||
id: '1492de61-5018-4368-8923-4f1eeaf988c5',
|
||||
key: FeatureFlagKey.IsPostgreSQLIntegrationEnabled,
|
||||
value: true,
|
||||
workspaceId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w',
|
||||
},
|
||||
{
|
||||
id: '1492de61-5018-4368-8923-4f1eeaf988c6',
|
||||
key: FeatureFlagKey.IsWorkflowEnabled,
|
||||
value: true,
|
||||
workspaceId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w',
|
||||
},
|
||||
],
|
||||
createdAt: '2023-04-26T10:23:42.33625+00:00',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -6,6 +6,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
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 { WorkspaceFeatureFlagsMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -15,6 +16,7 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
|
||||
services: [],
|
||||
resolvers: [],
|
||||
}),
|
||||
WorkspaceFeatureFlagsMapCacheModule,
|
||||
],
|
||||
exports: [FeatureFlagService],
|
||||
providers: [FeatureFlagService],
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
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 { 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(
|
||||
'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate',
|
||||
@ -29,6 +30,11 @@ describe('FeatureFlagService', () => {
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockWorkspaceFeatureFlagsMapCacheService = {
|
||||
getWorkspaceFeatureFlagsMap: jest.fn(),
|
||||
recomputeFeatureFlagsMapCache: jest.fn(),
|
||||
};
|
||||
|
||||
const workspaceId = 'workspace-id';
|
||||
const featureFlag = FeatureFlagKey.IsWorkflowEnabled;
|
||||
|
||||
@ -47,6 +53,10 @@ describe('FeatureFlagService', () => {
|
||||
provide: getRepositoryToken(FeatureFlag, 'core'),
|
||||
useValue: mockFeatureFlagRepository,
|
||||
},
|
||||
{
|
||||
provide: WorkspaceFeatureFlagsMapCacheService,
|
||||
useValue: mockWorkspaceFeatureFlagsMapCacheService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@ -60,27 +70,29 @@ describe('FeatureFlagService', () => {
|
||||
describe('isFeatureEnabled', () => {
|
||||
it('should return true when feature flag is enabled', async () => {
|
||||
// Prepare
|
||||
mockFeatureFlagRepository.findOneBy.mockResolvedValue({
|
||||
key: featureFlag,
|
||||
value: true,
|
||||
workspaceId,
|
||||
});
|
||||
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap.mockResolvedValue(
|
||||
{
|
||||
[featureFlag]: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await service.isFeatureEnabled(featureFlag, workspaceId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
expect(mockFeatureFlagRepository.findOneBy).toHaveBeenCalledWith({
|
||||
expect(
|
||||
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap,
|
||||
).toHaveBeenCalledWith({
|
||||
workspaceId,
|
||||
key: featureFlag,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false when feature flag is not found', async () => {
|
||||
// Prepare
|
||||
mockFeatureFlagRepository.findOneBy.mockResolvedValue(null);
|
||||
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap.mockResolvedValue(
|
||||
{},
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await service.isFeatureEnabled(featureFlag, workspaceId);
|
||||
@ -94,7 +106,6 @@ describe('FeatureFlagService', () => {
|
||||
mockFeatureFlagRepository.findOneBy.mockResolvedValue({
|
||||
key: featureFlag,
|
||||
value: false,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
// Act
|
||||
@ -108,21 +119,25 @@ describe('FeatureFlagService', () => {
|
||||
describe('getWorkspaceFeatureFlags', () => {
|
||||
it('should return all feature flags for a workspace', async () => {
|
||||
// Prepare
|
||||
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap.mockResolvedValue(
|
||||
{
|
||||
[FeatureFlagKey.IsWorkflowEnabled]: true,
|
||||
[FeatureFlagKey.IsCopilotEnabled]: false,
|
||||
},
|
||||
);
|
||||
const mockFeatureFlags = [
|
||||
{ key: FeatureFlagKey.IsWorkflowEnabled, value: true, workspaceId },
|
||||
{ key: FeatureFlagKey.IsCopilotEnabled, value: false, workspaceId },
|
||||
{ key: FeatureFlagKey.IsWorkflowEnabled, value: true },
|
||||
{ key: FeatureFlagKey.IsCopilotEnabled, value: false },
|
||||
];
|
||||
|
||||
mockFeatureFlagRepository.find.mockResolvedValue(mockFeatureFlags);
|
||||
|
||||
// Act
|
||||
const result = await service.getWorkspaceFeatureFlags(workspaceId);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockFeatureFlags);
|
||||
expect(mockFeatureFlagRepository.find).toHaveBeenCalledWith({
|
||||
where: { workspaceId },
|
||||
});
|
||||
expect(
|
||||
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap,
|
||||
).toHaveBeenCalledWith({ workspaceId });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { Repository } from 'typeorm';
|
||||
|
||||
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 { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import {
|
||||
@ -13,46 +14,45 @@ import {
|
||||
} from 'src/engine/core-modules/feature-flag/feature-flag.exception';
|
||||
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 { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class FeatureFlagService {
|
||||
constructor(
|
||||
@InjectRepository(FeatureFlag, 'core')
|
||||
private readonly featureFlagRepository: Repository<FeatureFlag>,
|
||||
private readonly workspaceFeatureFlagsMapCacheService: WorkspaceFeatureFlagsMapCacheService,
|
||||
) {}
|
||||
|
||||
public async isFeatureEnabled(
|
||||
key: FeatureFlagKey,
|
||||
workspaceId: string,
|
||||
): Promise<boolean> {
|
||||
const featureFlag = await this.featureFlagRepository.findOneBy({
|
||||
workspaceId,
|
||||
key,
|
||||
value: true,
|
||||
});
|
||||
const featureFlagMap = await this.getWorkspaceFeatureFlagsMap(workspaceId);
|
||||
|
||||
return !!featureFlag?.value;
|
||||
return !!featureFlagMap[key];
|
||||
}
|
||||
|
||||
public async getWorkspaceFeatureFlags(
|
||||
workspaceId: string,
|
||||
): Promise<FeatureFlag[]> {
|
||||
return this.featureFlagRepository.find({ where: { workspaceId } });
|
||||
): Promise<FeatureFlagDTO[]> {
|
||||
const workspaceFeatureFlagsMap =
|
||||
await this.workspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
return Object.entries(workspaceFeatureFlagsMap).map(([key, value]) => ({
|
||||
key: key as FeatureFlagKey,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
public async getWorkspaceFeatureFlagsMap(
|
||||
workspaceId: string,
|
||||
): Promise<FeatureFlagMap> {
|
||||
const workspaceFeatureFlags =
|
||||
await this.getWorkspaceFeatureFlags(workspaceId);
|
||||
|
||||
const workspaceFeatureFlagsMap = workspaceFeatureFlags.reduce(
|
||||
(result, currentFeatureFlag) => {
|
||||
result[currentFeatureFlag.key] = currentFeatureFlag.value;
|
||||
|
||||
return result;
|
||||
},
|
||||
{} as FeatureFlagMap,
|
||||
const workspaceFeatureFlagsMap =
|
||||
await this.workspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
return workspaceFeatureFlagsMap;
|
||||
@ -62,6 +62,7 @@ export class FeatureFlagService {
|
||||
keys: FeatureFlagKey[],
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
if (keys.length > 0) {
|
||||
await this.featureFlagRepository.upsert(
|
||||
keys.map((key) => ({ workspaceId, key, value: true })),
|
||||
{
|
||||
@ -69,6 +70,13 @@ export class FeatureFlagService {
|
||||
skipUpdateIfNoValuesChanged: true,
|
||||
},
|
||||
);
|
||||
|
||||
await this.workspaceFeatureFlagsMapCacheService.recomputeFeatureFlagsMapCache(
|
||||
{
|
||||
workspaceId: workspaceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async upsertWorkspaceFeatureFlag({
|
||||
@ -120,6 +128,14 @@ export class FeatureFlagService {
|
||||
workspaceId: workspaceId,
|
||||
};
|
||||
|
||||
return await this.featureFlagRepository.save(featureFlagToSave);
|
||||
const result = await this.featureFlagRepository.save(featureFlagToSave);
|
||||
|
||||
await this.workspaceFeatureFlagsMapCacheService.recomputeFeatureFlagsMapCache(
|
||||
{
|
||||
workspaceId: workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,16 +2,11 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
WorkspaceFeatureFlagMapCacheModule,
|
||||
],
|
||||
imports: [FeatureFlagModule, PermissionsModule],
|
||||
providers: [LabResolver],
|
||||
exports: [],
|
||||
})
|
||||
|
||||
@ -2,7 +2,7 @@ import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
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 { 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 { WorkspaceFeatureFlagMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service';
|
||||
|
||||
@Resolver()
|
||||
@UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter)
|
||||
@UseGuards(SettingsPermissionsGuard(SettingPermissionType.WORKSPACE))
|
||||
export class LabResolver {
|
||||
constructor(
|
||||
private featureFlagService: FeatureFlagService,
|
||||
private workspaceFeatureFlagMapCacheService: WorkspaceFeatureFlagMapCacheService,
|
||||
) {}
|
||||
constructor(private featureFlagService: FeatureFlagService) {}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@Mutation(() => FeatureFlag)
|
||||
@Mutation(() => FeatureFlagDTO)
|
||||
async updateLabPublicFeatureFlag(
|
||||
@Args('input') input: UpdateLabPublicFeatureFlagInput,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<FeatureFlag> {
|
||||
): Promise<FeatureFlagDTO> {
|
||||
try {
|
||||
const result = await this.featureFlagService.upsertWorkspaceFeatureFlag({
|
||||
workspaceId: workspace.id,
|
||||
@ -38,12 +34,6 @@ export class LabResolver {
|
||||
shouldBePublic: true,
|
||||
});
|
||||
|
||||
await this.workspaceFeatureFlagMapCacheService.recomputeFeatureFlagMapCache(
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof FeatureFlagException) {
|
||||
|
||||
@ -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 { 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 { 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 { 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 { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
@ -160,8 +160,10 @@ export class WorkspaceResolver {
|
||||
return `${paths[0]}?token=${workspaceLogoToken}`;
|
||||
}
|
||||
|
||||
@ResolveField(() => [FeatureFlag], { nullable: true })
|
||||
async featureFlags(@Parent() workspace: Workspace): Promise<FeatureFlag[]> {
|
||||
@ResolveField(() => [FeatureFlagDTO], { nullable: true })
|
||||
async featureFlags(
|
||||
@Parent() workspace: Workspace,
|
||||
): Promise<FeatureFlagDTO[]> {
|
||||
const featureFlags = await this.featureFlagService.getWorkspaceFeatureFlags(
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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 {}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -64,11 +64,11 @@ export class WorkspaceDataSource extends DataSource {
|
||||
this.permissionsPerRoleId = permissionsPerRoleId;
|
||||
}
|
||||
|
||||
setFeatureFlagMap(featureFlagMap: FeatureFlagMap) {
|
||||
setFeatureFlagsMap(featureFlagMap: FeatureFlagMap) {
|
||||
this.featureFlagMap = featureFlagMap;
|
||||
}
|
||||
|
||||
setFeatureFlagMapVersion(featureFlagMapVersion: string) {
|
||||
setFeatureFlagsMapVersion(featureFlagMapVersion: string) {
|
||||
this.featureFlagMapVersion = featureFlagMapVersion;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.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 { 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';
|
||||
@ -21,6 +21,7 @@ import {
|
||||
import { EntitySchemaFactory } from 'src/engine/twenty-orm/factories/entity-schema.factory';
|
||||
import { PromiseMemoizer } from 'src/engine/twenty-orm/storage/promise-memoizer.storage';
|
||||
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';
|
||||
|
||||
type CacheResult<T, U> = {
|
||||
@ -40,7 +41,7 @@ export class WorkspaceDatasourceFactory {
|
||||
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
||||
private readonly entitySchemaFactory: EntitySchemaFactory,
|
||||
private readonly workspaceRolesPermissionsCacheService: WorkspaceRolesPermissionsCacheService,
|
||||
private readonly workspaceFeatureFlagMapCacheService: WorkspaceFeatureFlagMapCacheService,
|
||||
private readonly workspaceFeatureFlagsMapCacheService: WorkspaceFeatureFlagsMapCacheService,
|
||||
) {}
|
||||
|
||||
public async create(
|
||||
@ -55,7 +56,9 @@ export class WorkspaceDatasourceFactory {
|
||||
);
|
||||
|
||||
const { data: cachedFeatureFlagMap, version: cachedFeatureFlagMapVersion } =
|
||||
await this.getFeatureFlagMapFromCache({ workspaceId });
|
||||
await this.workspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMapAndVersion(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
const isPermissionsV2Enabled =
|
||||
cachedFeatureFlagMap[FeatureFlagKey.IsPermissionsV2Enabled];
|
||||
@ -201,7 +204,7 @@ export class WorkspaceDatasourceFactory {
|
||||
});
|
||||
}
|
||||
|
||||
await this.updateWorkspaceDataSourceFeatureFlagMapIfNeeded({
|
||||
await this.updateWorkspaceDataSourceFeatureFlagsMapIfNeeded({
|
||||
workspaceDataSource,
|
||||
cachedFeatureFlagMapVersion,
|
||||
cachedFeatureFlagMap,
|
||||
@ -210,47 +213,6 @@ export class WorkspaceDatasourceFactory {
|
||||
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({
|
||||
workspaceId,
|
||||
isPermissionsV2Enabled,
|
||||
@ -267,7 +229,7 @@ export class WorkspaceDatasourceFactory {
|
||||
return { version: undefined, data: undefined };
|
||||
}
|
||||
|
||||
return this.getFromCacheWithRecompute<
|
||||
return getFromCacheWithRecompute<
|
||||
string | 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>({
|
||||
workspaceDataSource,
|
||||
currentVersion,
|
||||
@ -355,7 +295,7 @@ export class WorkspaceDatasourceFactory {
|
||||
});
|
||||
}
|
||||
|
||||
private async updateWorkspaceDataSourceFeatureFlagMapIfNeeded({
|
||||
private async updateWorkspaceDataSourceFeatureFlagsMapIfNeeded({
|
||||
workspaceDataSource,
|
||||
cachedFeatureFlagMapVersion,
|
||||
cachedFeatureFlagMap,
|
||||
@ -369,9 +309,9 @@ export class WorkspaceDatasourceFactory {
|
||||
currentVersion: workspaceDataSource.featureFlagMapVersion,
|
||||
newVersion: cachedFeatureFlagMapVersion,
|
||||
newData: cachedFeatureFlagMap,
|
||||
setData: (data) => workspaceDataSource.setFeatureFlagMap(data),
|
||||
setData: (data) => workspaceDataSource.setFeatureFlagsMap(data),
|
||||
setVersion: (version) =>
|
||||
workspaceDataSource.setFeatureFlagMapVersion(version),
|
||||
workspaceDataSource.setFeatureFlagsMapVersion(version),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
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 { WorkspaceRolesPermissionsCacheModule } from 'src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.module';
|
||||
import { entitySchemaFactories } from 'src/engine/twenty-orm/factories';
|
||||
@ -27,7 +27,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
||||
WorkspaceMetadataCacheModule,
|
||||
PermissionsModule,
|
||||
WorkspaceRolesPermissionsCacheModule,
|
||||
WorkspaceFeatureFlagMapCacheModule,
|
||||
WorkspaceFeatureFlagsMapCacheModule,
|
||||
FeatureFlagModule,
|
||||
],
|
||||
providers: [
|
||||
|
||||
@ -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 };
|
||||
@ -26,9 +26,9 @@ export enum WorkspaceCacheKeys {
|
||||
MetadataRolesPermissions = 'metadata:roles-permissions',
|
||||
MetadataRolesPermissionsVersion = 'metadata:roles-permissions-version',
|
||||
MetadataRolesPermissionsOngoingCachingLock = 'metadata:roles-permissions-ongoing-caching-lock',
|
||||
MetadataFeatureFlagMap = 'metadata:feature-flag-map',
|
||||
MetadataFeatureFlagMapVersion = 'metadata:feature-flag-map-version',
|
||||
MetadataFeatureFlagMapOngoingCachingLock = 'metadata:feature-flag-map-ongoing-caching-lock',
|
||||
FeatureFlagMap = 'feature-flag-map',
|
||||
FeatureFlagMapVersion = 'feature-flag-map-version',
|
||||
FeatureFlagMapOngoingCachingLock = 'feature-flag-map-ongoing-caching-lock',
|
||||
}
|
||||
|
||||
const TTL_INFINITE = 0;
|
||||
@ -254,19 +254,19 @@ export class WorkspaceCacheStorageService {
|
||||
);
|
||||
}
|
||||
|
||||
getFeatureFlagMapVersionFromCache(
|
||||
getFeatureFlagsMapVersionFromCache(
|
||||
workspaceId: string,
|
||||
): Promise<string | undefined> {
|
||||
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();
|
||||
|
||||
await this.cacheStorageService.set<string>(
|
||||
`${WorkspaceCacheKeys.MetadataFeatureFlagMapVersion}:${workspaceId}`,
|
||||
`${WorkspaceCacheKeys.FeatureFlagMapVersion}:${workspaceId}`,
|
||||
featureFlagMapVersion,
|
||||
TTL_INFINITE,
|
||||
);
|
||||
@ -274,7 +274,7 @@ export class WorkspaceCacheStorageService {
|
||||
return featureFlagMapVersion;
|
||||
}
|
||||
|
||||
async setFeatureFlagMap(
|
||||
async setFeatureFlagsMap(
|
||||
workspaceId: string,
|
||||
featureFlagMap: FeatureFlagMap,
|
||||
): Promise<{
|
||||
@ -282,41 +282,41 @@ export class WorkspaceCacheStorageService {
|
||||
}> {
|
||||
const [, newFeatureFlagMapVersion] = await Promise.all([
|
||||
this.cacheStorageService.set<FeatureFlagMap>(
|
||||
`${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`,
|
||||
`${WorkspaceCacheKeys.FeatureFlagMap}:${workspaceId}`,
|
||||
featureFlagMap,
|
||||
TTL_INFINITE,
|
||||
),
|
||||
this.setFeatureFlagMapVersion(workspaceId),
|
||||
this.setFeatureFlagsMapVersion(workspaceId),
|
||||
]);
|
||||
|
||||
return { newFeatureFlagMapVersion };
|
||||
}
|
||||
|
||||
getFeatureFlagMap(workspaceId: string): Promise<FeatureFlagMap | undefined> {
|
||||
getFeatureFlagsMap(workspaceId: string): Promise<FeatureFlagMap | undefined> {
|
||||
return this.cacheStorageService.get<FeatureFlagMap>(
|
||||
`${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`,
|
||||
`${WorkspaceCacheKeys.FeatureFlagMap}:${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
addFeatureFlagMapOngoingCachingLock(workspaceId: string) {
|
||||
return this.cacheStorageService.set<boolean>(
|
||||
`${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`,
|
||||
`${WorkspaceCacheKeys.FeatureFlagMapOngoingCachingLock}:${workspaceId}`,
|
||||
true,
|
||||
1_000 * 60, // 1 minute
|
||||
);
|
||||
}
|
||||
|
||||
removeFeatureFlagMapOngoingCachingLock(workspaceId: string) {
|
||||
removeFeatureFlagsMapOngoingCachingLock(workspaceId: string) {
|
||||
return this.cacheStorageService.del(
|
||||
`${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`,
|
||||
`${WorkspaceCacheKeys.FeatureFlagMapOngoingCachingLock}:${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
getFeatureFlagMapOngoingCachingLock(
|
||||
getFeatureFlagsMapOngoingCachingLock(
|
||||
workspaceId: string,
|
||||
): Promise<boolean | undefined> {
|
||||
return this.cacheStorageService.get<boolean>(
|
||||
`${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`,
|
||||
`${WorkspaceCacheKeys.FeatureFlagMapOngoingCachingLock}:${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -353,15 +353,15 @@ export class WorkspaceCacheStorageService {
|
||||
);
|
||||
|
||||
await this.cacheStorageService.del(
|
||||
`${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`,
|
||||
`${WorkspaceCacheKeys.FeatureFlagMap}:${workspaceId}`,
|
||||
);
|
||||
|
||||
await this.cacheStorageService.del(
|
||||
`${WorkspaceCacheKeys.MetadataFeatureFlagMapVersion}:${workspaceId}`,
|
||||
`${WorkspaceCacheKeys.FeatureFlagMapVersion}:${workspaceId}`,
|
||||
);
|
||||
|
||||
await this.cacheStorageService.del(
|
||||
`${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`,
|
||||
`${WorkspaceCacheKeys.FeatureFlagMapOngoingCachingLock}:${workspaceId}`,
|
||||
);
|
||||
|
||||
// TODO: remove this after the feature flag is droped
|
||||
|
||||
@ -456,7 +456,6 @@ describe('workspace permissions', () => {
|
||||
$input: UpdateLabPublicFeatureFlagInput!
|
||||
) {
|
||||
updateLabPublicFeatureFlag(input: $input) {
|
||||
id
|
||||
key
|
||||
value
|
||||
}
|
||||
@ -490,7 +489,6 @@ describe('workspace permissions', () => {
|
||||
$input: UpdateLabPublicFeatureFlagInput!
|
||||
) {
|
||||
updateLabPublicFeatureFlag(input: $input) {
|
||||
id
|
||||
key
|
||||
value
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user