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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-19
@@ -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) {
|
||||
|
||||
+16
@@ -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],
|
||||
|
||||
+34
-22
@@ -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 () => {
|
||||
|
||||
+16
-16
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user