Refactor workspace cache service (#16208)

## Context
We've recently introduced a new workspace cache service which now acts
as a cache access and local storage for all workspace related data,
deprecating the individual specific services.
- Better performance through multiple caching/fetching strategies
- Consistent data access patterns across the codebase
- Reduced redis queries through MGET/MSET/PIPELINE with multiple cache
keys
This commit is contained in:
Weiko
2025-12-01 17:08:21 +01:00
committed by GitHub
parent 4e0545ebc5
commit 1eb2e44058
77 changed files with 1306 additions and 1863 deletions
@@ -1,458 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
import { In } from 'typeorm';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import {
ApiKeyException,
ApiKeyExceptionCode,
} from 'src/engine/core-modules/api-key/api-key.exception';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { ApiKeyRoleService } from './api-key-role.service';
describe('ApiKeyRoleService', () => {
let service: ApiKeyRoleService;
let mockRoleTargetsRepository: any;
let mockRoleRepository: any;
let mockWorkspaceRepository: any;
let mockApiKeyRepository: any;
let mockDataSource: any;
let mockWorkspacePermissionsCacheService: any;
let mockRoleTargetService: any;
const mockWorkspaceId = 'workspace-123';
const mockApiKeyId = 'api-key-456';
const mockRoleId = 'role-789';
const mockNewRoleId = 'role-999';
const mockApiKey: ApiKeyEntity = {
id: mockApiKeyId,
name: 'Test API Key',
expiresAt: new Date('2025-12-31'),
revokedAt: undefined,
workspaceId: mockWorkspaceId,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
workspace: {} as any,
};
const mockRole: Partial<RoleEntity> = {
id: mockRoleId,
label: 'Admin',
icon: 'admin-icon',
description: 'Admin role',
isEditable: true,
workspaceId: mockWorkspaceId,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
canUpdateAllSettings: true,
canAccessAllTools: true,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canBeAssignedToAgents: false,
canBeAssignedToUsers: true,
canBeAssignedToApiKeys: true,
};
const mockNewRole: Partial<RoleEntity> = {
...mockRole,
id: mockNewRoleId,
label: 'Member',
};
const mockRoleTarget = {
id: 'role-target-123',
roleId: mockRoleId,
apiKeyId: mockApiKeyId,
workspaceId: mockWorkspaceId,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
role: mockRole,
apiKey: mockApiKey,
} as RoleTargetsEntity;
beforeEach(async () => {
mockRoleTargetsRepository = {
save: jest.fn(),
delete: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
};
mockRoleRepository = {
findOne: jest.fn(),
};
mockWorkspaceRepository = {
findOne: jest.fn(),
};
mockApiKeyRepository = {
findOne: jest.fn(),
};
mockDataSource = {
transaction: jest.fn(),
};
mockWorkspacePermissionsCacheService = {
recomputeApiKeyRoleMapCache: jest.fn(),
getApiKeyRoleMapFromCache: jest.fn(),
};
mockRoleTargetService = {
create: jest.fn(),
delete: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApiKeyRoleService,
{
provide: getRepositoryToken(RoleTargetsEntity),
useValue: mockRoleTargetsRepository,
},
{
provide: getRepositoryToken(RoleEntity),
useValue: mockRoleRepository,
},
{
provide: getRepositoryToken(WorkspaceEntity),
useValue: mockWorkspaceRepository,
},
{
provide: getRepositoryToken(ApiKeyEntity),
useValue: mockApiKeyRepository,
},
{
provide: getDataSourceToken(),
useValue: mockDataSource,
},
{
provide: WorkspacePermissionsCacheService,
useValue: mockWorkspacePermissionsCacheService,
},
{
provide: RoleTargetService,
useValue: mockRoleTargetService,
},
],
}).compile();
service = module.get<ApiKeyRoleService>(ApiKeyRoleService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('assignRoleToApiKey', () => {
it('should assign a new role to API key using roleTargetService', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(mockNewRole);
mockRoleTargetsRepository.findOne.mockResolvedValue(null);
mockRoleTargetService.create.mockResolvedValue(undefined);
await service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: mockNewRoleId,
workspaceId: mockWorkspaceId,
});
expect(mockRoleTargetService.create).toHaveBeenCalledWith({
createRoleTargetInput: {
roleId: mockNewRoleId,
targetId: mockApiKeyId,
targetMetadataForeignKey: 'apiKeyId',
},
workspaceId: mockWorkspaceId,
});
});
it('should skip assignment if role is already assigned', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(mockRole);
mockRoleTargetsRepository.findOne.mockResolvedValue(mockRoleTarget);
await service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: mockRoleId,
workspaceId: mockWorkspaceId,
});
expect(mockRoleTargetService.create).not.toHaveBeenCalled();
});
it('should throw exception if API key not found', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(null);
await expect(
service.assignRoleToApiKey({
apiKeyId: 'non-existent',
roleId: mockRoleId,
workspaceId: mockWorkspaceId,
}),
).rejects.toThrow(ApiKeyException);
await expect(
service.assignRoleToApiKey({
apiKeyId: 'non-existent',
roleId: mockRoleId,
workspaceId: mockWorkspaceId,
}),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.API_KEY_NOT_FOUND,
});
});
it('should throw exception if role not found', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(null);
await expect(
service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: 'non-existent-role',
workspaceId: mockWorkspaceId,
}),
).rejects.toThrow(ApiKeyException);
await expect(
service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: 'non-existent-role',
workspaceId: mockWorkspaceId,
}),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.API_KEY_NOT_FOUND,
});
});
});
describe('getRoleIdForApiKey', () => {
it('should return role ID from cache', async () => {
const mockCacheData = {
data: {
[mockApiKeyId]: mockRoleId,
},
};
mockWorkspacePermissionsCacheService.getApiKeyRoleMapFromCache.mockResolvedValue(
mockCacheData,
);
const result = await service.getRoleIdForApiKey(
mockApiKeyId,
mockWorkspaceId,
);
expect(
mockWorkspacePermissionsCacheService.getApiKeyRoleMapFromCache,
).toHaveBeenCalledWith({
workspaceId: mockWorkspaceId,
});
expect(result).toBe(mockRoleId);
});
it('should throw exception if API key has no role in cache', async () => {
const mockCacheData = {
data: {},
};
mockWorkspacePermissionsCacheService.getApiKeyRoleMapFromCache.mockResolvedValue(
mockCacheData,
);
await expect(
service.getRoleIdForApiKey(mockApiKeyId, mockWorkspaceId),
).rejects.toThrow(ApiKeyException);
await expect(
service.getRoleIdForApiKey(mockApiKeyId, mockWorkspaceId),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.API_KEY_NO_ROLE_ASSIGNED,
});
});
});
describe('getRolesByApiKeys', () => {
it('should return empty map for empty API key IDs', async () => {
const result = await service.getRolesByApiKeys({
apiKeyIds: [],
workspaceId: mockWorkspaceId,
});
expect(result).toEqual(new Map());
expect(mockRoleTargetsRepository.find).not.toHaveBeenCalled();
});
it('should return roles map for given API key IDs', async () => {
const mockApiKeyIds = [mockApiKeyId, 'another-api-key'];
const mockRoleTargets = [
{
apiKeyId: mockApiKeyId,
role: mockRole,
},
{
apiKeyId: 'another-api-key',
role: mockNewRole,
},
];
mockRoleTargetsRepository.find.mockResolvedValue(mockRoleTargets);
const result = await service.getRolesByApiKeys({
apiKeyIds: mockApiKeyIds,
workspaceId: mockWorkspaceId,
});
expect(mockRoleTargetsRepository.find).toHaveBeenCalledWith({
where: {
apiKeyId: In(mockApiKeyIds),
workspaceId: mockWorkspaceId,
},
relations: ['role'],
});
expect(result.size).toBe(2);
expect(result.get(mockApiKeyId)).toEqual({
id: mockRole.id,
label: mockRole.label,
icon: mockRole.icon,
description: mockRole.description,
isEditable: mockRole.isEditable,
roleTargets: mockRole.roleTargets,
canUpdateAllSettings: true,
canAccessAllTools: true,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canBeAssignedToAgents: false,
canBeAssignedToUsers: true,
canBeAssignedToApiKeys: true,
standardId: undefined,
});
});
it('should handle role targets with missing apiKeyId or role gracefully', async () => {
const mockRoleTargets = [
{
apiKeyId: null,
role: mockRole,
},
{
apiKeyId: mockApiKeyId,
role: null,
},
{
apiKeyId: 'valid-api-key',
role: mockRole,
},
];
mockRoleTargetsRepository.find.mockResolvedValue(mockRoleTargets);
const result = await service.getRolesByApiKeys({
apiKeyIds: [mockApiKeyId, 'valid-api-key'],
workspaceId: mockWorkspaceId,
});
expect(result.size).toBe(1);
expect(result.has('valid-api-key')).toBe(true);
});
});
describe('validateAssignRoleInput', () => {
it('should validate successful role assignment inputs', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(mockNewRole);
mockRoleTargetsRepository.findOne.mockResolvedValue(null);
const validateMethod = (service as any).validateAssignRoleInput;
const result = await validateMethod.call(service, {
apiKeyId: mockApiKeyId,
workspaceId: mockWorkspaceId,
roleId: mockNewRoleId,
});
expect(result.roleToAssignIsSameAsCurrentRole).toBe(false);
});
it('should detect same role assignment', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(mockRole);
mockRoleTargetsRepository.findOne.mockResolvedValue(mockRoleTarget);
const validateMethod = (service as any).validateAssignRoleInput;
const result = await validateMethod.call(service, {
apiKeyId: mockApiKeyId,
workspaceId: mockWorkspaceId,
roleId: mockRoleId,
});
expect(result.roleToAssignIsSameAsCurrentRole).toBe(true);
});
});
describe('error handling', () => {
it('should handle roleTargetService failures gracefully', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(mockNewRole);
mockRoleTargetsRepository.findOne.mockResolvedValue(null);
mockRoleTargetService.create.mockRejectedValue(
new Error('Role target creation failed'),
);
await expect(
service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: mockNewRoleId,
workspaceId: mockWorkspaceId,
}),
).rejects.toThrow('Role target creation failed');
});
it('should throw exception if role cannot be assigned to API keys', async () => {
const roleNotForApiKeys = {
...mockNewRole,
canBeAssignedToApiKeys: false,
};
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(roleNotForApiKeys);
mockRoleTargetsRepository.findOne.mockResolvedValue(null);
await expect(
service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: mockNewRoleId,
workspaceId: mockWorkspaceId,
}),
).rejects.toThrow(ApiKeyException);
await expect(
service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: mockNewRoleId,
workspaceId: mockWorkspaceId,
}),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS,
});
});
});
});
@@ -13,7 +13,7 @@ import { type RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class ApiKeyRoleService {
@@ -25,7 +25,7 @@ export class ApiKeyRoleService {
@InjectRepository(ApiKeyEntity)
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly roleTargetService: RoleTargetService,
) {}
@@ -62,12 +62,12 @@ export class ApiKeyRoleService {
apiKeyId: string,
workspaceId: string,
): Promise<string> {
const apiKeyRoleMap =
await this.workspacePermissionsCacheService.getApiKeyRoleMapFromCache({
workspaceId,
});
const { apiKeyRoleMap } = await this.workspaceCacheService.getOrRecompute(
workspaceId,
['apiKeyRoleMap'],
);
const roleId = apiKeyRoleMap.data[apiKeyId];
const roleId = apiKeyRoleMap[apiKeyId];
if (!roleId) {
throw new ApiKeyException(
@@ -15,6 +15,7 @@ import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { ApiKeyController } from './controllers/api-key.controller';
@@ -28,6 +29,7 @@ import { ApiKeyController } from './controllers/api-key.controller';
]),
JwtModule,
WorkspacePermissionsCacheModule,
WorkspaceCacheModule,
WorkspaceCacheStorageModule,
FeatureFlagModule,
RoleTargetModule,
@@ -7,11 +7,13 @@ import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Module({
imports: [
TypeOrmModule.forFeature([ApplicationEntity, AgentEntity, WorkspaceEntity]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceCacheModule,
],
exports: [ApplicationService, WorkspaceFlatApplicationMapCacheService],
providers: [ApplicationService, WorkspaceFlatApplicationMapCacheService],
@@ -11,17 +11,16 @@ import {
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/core-modules/application/constants/twenty-standard-applications';
import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules/application/services/workspace-flat-application-map-cache.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ALL_FLAT_ENTITY_MAPS_PROPERTIES } from 'src/engine/metadata-modules/flat-entity/constant/all-flat-entity-maps-properties.constant';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class ApplicationService {
constructor(
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceFlatApplicationMapCacheService: WorkspaceFlatApplicationMapCacheService,
private readonly workspaceCacheService: WorkspaceCacheService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {}
@@ -50,12 +49,11 @@ export class ApplicationService {
);
}
const flatApplicationMaps =
await this.workspaceFlatApplicationMapCacheService.getExistingOrRecomputeFlatMaps(
{
workspaceId: workspace.id,
},
);
const { flatApplicationMaps } =
await this.workspaceCacheService.getOrRecompute(workspace.id, [
'flatApplicationMaps',
]);
const twentyStandardApplicationId =
flatApplicationMaps.idByUniversalIdentifier[
TWENTY_STANDARD_APPLICATION.universalIdentifier
@@ -169,9 +167,9 @@ export class ApplicationService {
);
if (!skipCacheInvalidation) {
await this.workspaceFlatApplicationMapCacheService.invalidateCache({
workspaceId,
});
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatApplicationMaps',
]);
}
return twentyStandardApplication;
@@ -221,9 +219,9 @@ export class ApplicationService {
const savedApplication = await this.applicationRepository.save(application);
await this.workspaceFlatApplicationMapCacheService.invalidateCache({
workspaceId: data.workspaceId,
});
await this.workspaceCacheService.invalidateAndRecompute(data.workspaceId, [
'flatApplicationMaps',
]);
return savedApplication;
}
@@ -234,9 +232,10 @@ export class ApplicationService {
): Promise<ApplicationEntity> {
await this.applicationRepository.update({ id }, data);
await this.workspaceFlatApplicationMapCacheService.invalidateCache({
workspaceId: data.workspaceId as string,
});
await this.workspaceCacheService.invalidateAndRecompute(
data.workspaceId as string,
['flatApplicationMaps'],
);
const updatedApplication = await this.findById(id);
@@ -262,14 +261,13 @@ export class ApplicationService {
workspaceId,
});
await this.workspaceFlatApplicationMapCacheService.invalidateCache({
workspaceId,
});
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatApplicationMaps',
]);
await this.workspaceManyOrAllFlatEntityMapsCacheService.invalidateFlatEntityMaps(
{
workspaceId,
},
await this.workspaceCacheService.invalidateAndRecompute(
workspaceId,
ALL_FLAT_ENTITY_MAPS_PROPERTIES,
);
}
}
@@ -1,38 +1,28 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AllMetadataName } from 'twenty-shared/metadata';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
import { fromApplicationEntityToFlatApplication } from 'src/engine/core-modules/application/utils/from-application-entity-to-flat-application.util';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { MetadataToFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/types/metadata-to-flat-entity-maps-key';
import { WorkspaceFlatMapCache } from 'src/engine/workspace-flat-map-cache/decorators/workspace-flat-map-cache.decorator';
import { WorkspaceFlatMapCacheService } from 'src/engine/workspace-flat-map-cache/services/workspace-flat-map-cache.service';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
@Injectable()
@WorkspaceFlatMapCache(
'flatApplicationMaps' as MetadataToFlatEntityMapsKey<AllMetadataName>,
) // TODO prastoin introduce SyncableMetadata notion
export class WorkspaceFlatApplicationMapCacheService extends WorkspaceFlatMapCacheService<FlatApplicationCacheMaps> {
@WorkspaceCache('flatApplicationMaps')
export class WorkspaceFlatApplicationMapCacheService extends WorkspaceCacheProvider<FlatApplicationCacheMaps> {
constructor(
@InjectCacheStorage(CacheStorageNamespace.EngineWorkspace)
cacheStorageService: CacheStorageService,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {
super(cacheStorageService);
super();
}
protected async computeFlatMap({
workspaceId,
}: {
workspaceId: string;
}): Promise<FlatApplicationCacheMaps> {
async computeForCache(
workspaceId: string,
): Promise<FlatApplicationCacheMaps> {
const applicationEntities = await this.applicationRepository.find({
where: {
workspaceId,
@@ -59,6 +59,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 { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
@@ -115,6 +116,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
SubdomainManagerModule,
DomainServerConfigModule,
ApplicationModule,
WorkspaceCacheModule,
],
controllers: [
GoogleAuthController,
@@ -11,7 +11,6 @@ import { v4 } from 'uuid';
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules/application/services/workspace-flat-application-map-cache.service';
import {
AuthException,
AuthExceptionCode,
@@ -39,6 +38,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { isWorkEmail } from 'src/utils/is-work-email';
@@ -60,7 +60,7 @@ export class SignInUpService {
private readonly subdomainManagerService: SubdomainManagerService,
private readonly userService: UserService,
private readonly metricsService: MetricsService,
private readonly workspaceFlatApplicationMapCacheService: WorkspaceFlatApplicationMapCacheService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationService: ApplicationService,
@InjectDataSource()
private readonly dataSource: DataSource,
@@ -526,9 +526,9 @@ export class SignInUpService {
);
await queryRunner.commitTransaction();
await this.workspaceFlatApplicationMapCacheService.invalidateCache({
workspaceId,
});
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatApplicationMaps',
]);
return { user, workspace };
} catch (error) {
@@ -28,6 +28,22 @@ export class CacheStorageService {
return this.cache.del(this.getKey(key));
}
async mdel(keys: string[]): Promise<void> {
if (keys.length === 0) {
return;
}
if (this.isRedisCache()) {
const prefixedKeys = keys.map((k) => this.getKey(k));
await (this.cache as RedisCache).store.client.del(prefixedKeys);
return;
}
await Promise.all(keys.map((k) => this.del(k)));
}
async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]> {
if (this.isRedisCache()) {
const prefixedKeys = keys.map((k) => this.getKey(k));
@@ -8,6 +8,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
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';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Module({
imports: [
@@ -19,6 +20,7 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
}),
WorkspaceFeatureFlagsMapCacheModule,
WorkspacePermissionsCacheModule,
WorkspaceCacheModule,
],
exports: [FeatureFlagService],
providers: [FeatureFlagService],
@@ -10,8 +10,8 @@ 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';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
jest.mock(
'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate',
@@ -31,9 +31,9 @@ describe('FeatureFlagService', () => {
save: jest.fn(),
};
const mockWorkspaceFeatureFlagsMapCacheService = {
getWorkspaceFeatureFlagsMap: jest.fn(),
recomputeFeatureFlagsMapCache: jest.fn(),
const mockWorkspaceCacheService = {
getOrRecompute: jest.fn(),
invalidateAndRecompute: jest.fn(),
};
const mockWorkspacePermissionsCacheService = {
@@ -59,8 +59,8 @@ describe('FeatureFlagService', () => {
useValue: mockFeatureFlagRepository,
},
{
provide: WorkspaceFeatureFlagsMapCacheService,
useValue: mockWorkspaceFeatureFlagsMapCacheService,
provide: WorkspaceCacheService,
useValue: mockWorkspaceCacheService,
},
{
provide: WorkspacePermissionsCacheService,
@@ -79,29 +79,28 @@ describe('FeatureFlagService', () => {
describe('isFeatureEnabled', () => {
it('should return true when feature flag is enabled', async () => {
// Prepare
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap.mockResolvedValue(
{
mockWorkspaceCacheService.getOrRecompute.mockResolvedValue({
featureFlagsMap: {
[featureFlag]: true,
},
);
});
// Act
const result = await service.isFeatureEnabled(featureFlag, workspaceId);
// Assert
expect(result).toBe(true);
expect(
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap,
).toHaveBeenCalledWith({
expect(mockWorkspaceCacheService.getOrRecompute).toHaveBeenCalledWith(
workspaceId,
});
['featureFlagsMap'],
);
});
it('should return false when feature flag is not found', async () => {
// Prepare
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap.mockResolvedValue(
{},
);
mockWorkspaceCacheService.getOrRecompute.mockResolvedValue({
featureFlagsMap: {},
});
// Act
const result = await service.isFeatureEnabled(featureFlag, workspaceId);
@@ -128,11 +127,11 @@ describe('FeatureFlagService', () => {
describe('getWorkspaceFeatureFlags', () => {
it('should return all feature flags for a workspace', async () => {
// Prepare
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap.mockResolvedValue(
{
mockWorkspaceCacheService.getOrRecompute.mockResolvedValue({
featureFlagsMap: {
[FeatureFlagKey.IS_AI_ENABLED]: false,
},
);
});
const mockFeatureFlags = [
{ key: FeatureFlagKey.IS_AI_ENABLED, value: false },
];
@@ -142,9 +141,10 @@ describe('FeatureFlagService', () => {
// Assert
expect(result).toEqual(mockFeatureFlags);
expect(
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap,
).toHaveBeenCalledWith({ workspaceId });
expect(mockWorkspaceCacheService.getOrRecompute).toHaveBeenCalledWith(
workspaceId,
['featureFlagsMap'],
);
});
});
@@ -173,6 +173,9 @@ describe('FeatureFlagService', () => {
const keys = [FeatureFlagKey.IS_AI_ENABLED];
mockFeatureFlagRepository.upsert.mockResolvedValue({});
mockWorkspaceCacheService.invalidateAndRecompute.mockResolvedValue(
undefined,
);
// Act
await service.enableFeatureFlags(keys, workspaceId);
@@ -185,6 +188,9 @@ describe('FeatureFlagService', () => {
skipUpdateIfNoValuesChanged: true,
},
);
expect(
mockWorkspaceCacheService.invalidateAndRecompute,
).toHaveBeenCalledWith(workspaceId, ['featureFlagsMap']);
});
});
@@ -199,6 +205,9 @@ describe('FeatureFlagService', () => {
};
mockFeatureFlagRepository.save.mockResolvedValue(mockFeatureFlag);
mockWorkspaceCacheService.invalidateAndRecompute.mockResolvedValue(
undefined,
);
(
featureFlagValidator.assertIsFeatureFlagKey as jest.Mock
@@ -218,6 +227,9 @@ describe('FeatureFlagService', () => {
value,
workspaceId,
});
expect(
mockWorkspaceCacheService.invalidateAndRecompute,
).toHaveBeenCalledWith(workspaceId, ['featureFlagsMap']);
});
it('should throw an exception when feature flag key is invalid', async () => {
@@ -14,14 +14,14 @@ 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';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class FeatureFlagService {
constructor(
@InjectRepository(FeatureFlagEntity)
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
private readonly workspaceFeatureFlagsMapCacheService: WorkspaceFeatureFlagsMapCacheService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
public async isFeatureEnabled(
@@ -36,10 +36,10 @@ export class FeatureFlagService {
public async getWorkspaceFeatureFlags(
workspaceId: string,
): Promise<FeatureFlagDTO[]> {
const workspaceFeatureFlagsMap =
await this.workspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap(
{ workspaceId },
);
const { featureFlagsMap: workspaceFeatureFlagsMap } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'featureFlagsMap',
]);
return Object.entries(workspaceFeatureFlagsMap).map(([key, value]) => ({
key: key as FeatureFlagKey,
@@ -50,10 +50,10 @@ export class FeatureFlagService {
public async getWorkspaceFeatureFlagsMap(
workspaceId: string,
): Promise<FeatureFlagMap> {
const workspaceFeatureFlagsMap =
await this.workspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap(
{ workspaceId },
);
const { featureFlagsMap: workspaceFeatureFlagsMap } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'featureFlagsMap',
]);
return workspaceFeatureFlagsMap;
}
@@ -71,9 +71,9 @@ export class FeatureFlagService {
},
);
await this.workspaceFeatureFlagsMapCacheService.recomputeFeatureFlagsMapCache(
{ workspaceId },
);
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'featureFlagsMap',
]);
}
}
@@ -126,9 +126,9 @@ export class FeatureFlagService {
const result = await this.featureFlagRepository.save(featureFlagToSave);
await this.workspaceFeatureFlagsMapCacheService.recomputeFeatureFlagsMapCache(
{ workspaceId },
);
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'featureFlagsMap',
]);
return result;
}