20215 convert application variable to a syncable entity (#20269)

##  Summary

- Converts applicationVariable from a bespoke sync path to a proper
SyncableEntity,
unifying it with the workspace migration pipeline used by all other
manifest-managed
  entities (agent, skill, frontComponent, webhook, etc.)
- Removes the upsertManyApplicationVariableEntities method and its
direct-DB-mutation
approach in favor of the standard validate → build → run action handler
pipeline
- Adds universalIdentifier, deletedAt columns and makes applicationId
NOT NULL via an
  instance command migration

##  Motivation

Before this change, applicationVariable was the only manifest-managed
entity that bypassed
ApplicationManifestMigrationService.syncMetadataFromManifest(). It used
a bespoke service
method called directly from syncApplication(), creating two mental
models, two validation
styles, and two cache invalidation patterns. Now there's one unified
pipeline for all
  manifest entities.

##  What changed

###  Entity refactor:
- ApplicationVariableEntity now extends SyncableEntity (gains
universalIdentifier,
  non-nullable applicationId with CASCADE, soft-delete via deletedAt)

###  New flat entity layer (flat-application-variable/):
- Type, maps type, editable properties constant, entity-to-flat
converter, cache service,
  module

###  New migration pipeline wiring:
- Manifest converter
(fromApplicationVariableManifestToUniversalFlatApplicationVariable)
  - Validator service (FlatApplicationVariableValidatorService)
- Builder service
(WorkspaceMigrationApplicationVariableActionsBuilderService)
  - Create/Update/Delete action handlers with secret encryption hooks
- Registered in orchestrator, builder module, runner module, and all
type registries

###  Removed bespoke path:
- Deleted upsertManyApplicationVariableEntities from
ApplicationVariableEntityService
  - Removed its call from ApplicationSyncService.syncApplication()
- Kept update() (operator-set value at runtime) and getDisplayValue()
(runtime display)

###  Database migration:
- Instance command to add columns, backfill universalIdentifier, enforce
NOT NULL
  constraints, and update indexes

##  Test plan

  - npx nx typecheck twenty-server passes (0 errors)
- Unit tests pass (application-variable.service.spec.ts,
build-env-var.spec.ts)
- Install an app with applicationVariables in its manifest → variables
appear with correct
  universalIdentifier
- Update app manifest (add/remove/modify a variable) → migration
pipeline handles diff
  correctly
- Operator-set value via update endpoint persists correctly with
encryption
  - Uninstall app → variables cascade-deleted
  - app dev --once on example app syncs without errors
This commit is contained in:
martmull
2026-05-06 10:23:53 +02:00
committed by GitHub
parent 2a97e77303
commit 617f571400
75 changed files with 1065 additions and 507 deletions
@@ -16,7 +16,6 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util';
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
@@ -32,7 +31,6 @@ export class ApplicationSyncService {
constructor(
private readonly applicationService: ApplicationService,
private readonly applicationVariableService: ApplicationVariableEntityService,
private readonly applicationManifestMigrationService: ApplicationManifestMigrationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
@@ -145,14 +143,6 @@ export class ApplicationSyncService {
);
}
await this.applicationVariableService.upsertManyApplicationVariableEntities(
{
applicationVariables: manifest.application.applicationVariables,
applicationId: application.id,
workspaceId,
},
);
const resolvedRegistrationId =
applicationRegistrationId ?? application.applicationRegistrationId;
@@ -0,0 +1,31 @@
import { type UniversalFlatApplicationVariable } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-application-variable.type';
export const fromApplicationVariableManifestToUniversalFlatApplicationVariable =
({
key,
universalIdentifier,
description,
value,
isSecret,
applicationUniversalIdentifier,
now,
}: {
key: string;
universalIdentifier: string;
description?: string;
value?: string;
isSecret?: boolean;
applicationUniversalIdentifier: string;
now: string;
}): UniversalFlatApplicationVariable => {
return {
universalIdentifier,
applicationUniversalIdentifier,
key,
value: isSecret ? '' : (value ?? ''), // We protect secret variable by not syncing its value at all
description: description ?? '',
isSecret: isSecret ?? false,
createdAt: now,
updatedAt: now,
};
};
@@ -4,6 +4,7 @@ import { isDefined } from 'twenty-shared/utils';
import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-index-for-flat-field-metadata.util';
import { fromApplicationVariableManifestToUniversalFlatApplicationVariable } from 'src/engine/core-modules/application/application-manifest/converters/from-application-variable-manifest-to-universal-flat-application-variable.util';
import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util';
import { fromConnectionProviderManifestToUniversalFlatConnectionProvider } from 'src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util';
import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util';
@@ -431,6 +432,28 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
}
}
for (const [key, applicationVariableManifest] of Object.entries(
manifest.application.applicationVariables ?? {},
)) {
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity:
fromApplicationVariableManifestToUniversalFlatApplicationVariable({
key,
universalIdentifier: applicationVariableManifest.universalIdentifier,
value:
'value' in applicationVariableManifest
? applicationVariableManifest.value
: undefined,
description: applicationVariableManifest.description,
isSecret: applicationVariableManifest.isSecret,
applicationUniversalIdentifier,
now,
}),
universalFlatEntityMapsToMutate:
allUniversalFlatEntityMaps.flatApplicationVariableMaps,
});
}
for (const commandMenuItemManifest of manifest.commandMenuItems ?? []) {
if (!isDefined(commandMenuItemManifest.frontComponentUniversalIdentifier)) {
throw new Error(
@@ -32,7 +32,6 @@ import { RotateClientSecretDTO } from 'src/engine/core-modules/application/appli
import { TransferApplicationRegistrationOwnershipInput } from 'src/engine/core-modules/application/application-registration/dtos/transfer-application-registration-ownership.input';
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
@@ -159,220 +159,6 @@ describe('ApplicationVariableEntityService', () => {
});
});
describe('upsertManyApplicationVariableEntities', () => {
it('should encrypt secret values when creating new variables', async () => {
repository.find.mockResolvedValue([]);
repository.save.mockResolvedValue({} as any);
repository.delete.mockResolvedValue({ affected: 0 } as any);
await service.upsertManyApplicationVariableEntities({
applicationVariables: {
SECRET_KEY: {
universalIdentifier: 'secret-key-123',
value: 'my-secret',
description: 'A secret key',
isSecret: true,
},
},
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
});
expect(secretEncryptionService.encrypt).toHaveBeenCalledWith('my-secret');
expect(repository.save).toHaveBeenCalledWith([
{
key: 'SECRET_KEY',
value: 'encrypted_my-secret',
description: 'A secret key',
isSecret: true,
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
},
]);
});
it('should not encrypt non-secret values when creating new variables', async () => {
repository.find.mockResolvedValue([]);
repository.save.mockResolvedValue({} as any);
repository.delete.mockResolvedValue({ affected: 0 } as any);
await service.upsertManyApplicationVariableEntities({
applicationVariables: {
PUBLIC_URL: {
universalIdentifier: 'public-url-123',
value: 'https://example.com',
description: 'Public URL',
isSecret: false,
},
},
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
});
expect(secretEncryptionService.encrypt).not.toHaveBeenCalled();
expect(repository.save).toHaveBeenCalledWith([
{
key: 'PUBLIC_URL',
value: 'https://example.com',
description: 'Public URL',
isSecret: false,
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
},
]);
});
it('should handle undefined isSecret as false', async () => {
repository.find.mockResolvedValue([]);
repository.save.mockResolvedValue({} as any);
repository.delete.mockResolvedValue({ affected: 0 } as any);
await service.upsertManyApplicationVariableEntities({
applicationVariables: {
SOME_VAR: {
universalIdentifier: 'some-var-123',
value: 'some-value',
description: 'Some variable',
},
},
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
});
expect(secretEncryptionService.encrypt).not.toHaveBeenCalled();
expect(repository.save).toHaveBeenCalledWith([
expect.objectContaining({
isSecret: false,
}),
]);
});
it('should update existing variables without changing values', async () => {
const existingVariable = {
id: '1',
key: 'EXISTING_VAR',
value: 'existing-encrypted-value',
isSecret: true,
applicationId: mockApplicationId,
} as ApplicationVariableEntity;
repository.find.mockResolvedValue([existingVariable]);
repository.save.mockResolvedValue({} as any);
repository.delete.mockResolvedValue({ affected: 0 } as any);
await service.upsertManyApplicationVariableEntities({
applicationVariables: {
EXISTING_VAR: {
universalIdentifier: 'existing-var-123',
value: 'new-value',
description: 'Updated description',
isSecret: true,
},
},
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
});
expect(repository.save).toHaveBeenCalledWith([
{
id: '1',
description: 'Updated description',
isSecret: true,
},
]);
expect(repository.update).not.toHaveBeenCalled();
});
it('should update existing value when shouldUpdateValue is true', async () => {
const existingVariable = {
id: '1',
key: 'EXISTING_VAR',
value: 'existing-encrypted-value',
isSecret: true,
applicationId: mockApplicationId,
} as ApplicationVariableEntity;
repository.find.mockResolvedValue([existingVariable]);
repository.save.mockResolvedValue({} as any);
repository.delete.mockResolvedValue({ affected: 0 } as any);
await service.upsertManyApplicationVariableEntities({
applicationVariables: {
EXISTING_VAR: {
universalIdentifier: 'existing-var-123',
value: 'new-value',
description: 'Updated description',
isSecret: true,
},
},
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
shouldUpdateValue: true,
});
expect(repository.save).toHaveBeenCalledWith([
{
id: '1',
description: 'Updated description',
value: 'encrypted_new-value',
isSecret: true,
},
]);
});
it('should update existing value if isSecret changes', async () => {
const existingVariable = {
id: '1',
key: 'EXISTING_VAR',
value: 'existing-encrypted-value',
isSecret: true,
applicationId: mockApplicationId,
} as ApplicationVariableEntity;
repository.find.mockResolvedValue([existingVariable]);
repository.save.mockResolvedValue({} as any);
repository.delete.mockResolvedValue({ affected: 0 } as any);
await service.upsertManyApplicationVariableEntities({
applicationVariables: {
EXISTING_VAR: {
universalIdentifier: 'existing-var-123',
value: 'new-value',
description: 'Updated description',
isSecret: false,
},
},
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
});
expect(repository.save).toHaveBeenCalledWith([
{
id: '1',
description: 'Updated description',
value: 'new-value',
isSecret: false,
},
]);
expect(repository.update).not.toHaveBeenCalled();
});
it('should handle undefined applicationVariables', async () => {
await service.upsertManyApplicationVariableEntities({
applicationVariables: undefined,
applicationId: mockApplicationId,
workspaceId: mockWorkspaceId,
});
expect(repository.find).not.toHaveBeenCalled();
expect(repository.save).not.toHaveBeenCalled();
expect(repository.update).not.toHaveBeenCalled();
expect(
workspaceCacheService.invalidateAndRecompute,
).not.toHaveBeenCalled();
});
});
describe('getDisplayValue', () => {
it('should return plain value for non-secret variables', () => {
const variable = {
@@ -6,7 +6,10 @@ import {
ApplicationVariableEntityException,
ApplicationVariableEntityExceptionCode,
} from 'src/engine/core-modules/application/application-variable/application-variable.exception';
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(ApplicationVariableEntityException)
export class ApplicationVariableEntityExceptionFilter
@@ -16,6 +19,8 @@ export class ApplicationVariableEntityExceptionFilter
switch (exception.code) {
case ApplicationVariableEntityExceptionCode.APPLICATION_VARIABLE_NOT_FOUND:
throw new NotFoundError(exception);
case ApplicationVariableEntityExceptionCode.INVALID_APPLICATION_VARIABLE_INPUT:
throw new UserInputError(exception);
default:
assertUnreachable(exception.code);
}
@@ -5,41 +5,23 @@ import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({
name: 'applicationVariable',
schema: 'core',
})
@ObjectType('ApplicationVariable')
@Unique('IDX_APPLICATION_VARIABLE_KEY_APPLICATION_ID_UNIQUE', [
'key',
'applicationId',
])
export class ApplicationVariableEntity {
export class ApplicationVariableEntity extends SyncableEntity {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
@Index()
workspaceId: string;
@ManyToOne('WorkspaceEntity', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'workspaceId' })
workspace: EntityRelation<WorkspaceEntity>;
@Column({ nullable: false, type: 'text' })
key: string;
@@ -52,20 +34,6 @@ export class ApplicationVariableEntity {
@Column({ nullable: false, type: 'boolean', default: false })
isSecret: boolean;
@Column({ nullable: true, type: 'uuid' })
applicationId?: string;
@ManyToOne(
() => ApplicationEntity,
(application) => application.applicationVariables,
{
onDelete: 'CASCADE',
nullable: true,
},
)
@JoinColumn({ name: 'applicationId' })
application: EntityRelation<ApplicationEntity> | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -6,6 +6,7 @@ import { CustomException } from 'src/utils/custom-exception';
export enum ApplicationVariableEntityExceptionCode {
APPLICATION_VARIABLE_NOT_FOUND = 'APPLICATION_VARIABLE_NOT_FOUND',
INVALID_APPLICATION_VARIABLE_INPUT = 'INVALID_APPLICATION_VARIABLE_INPUT',
}
const getApplicationVariableEntityExceptionUserFriendlyMessage = (
@@ -14,6 +15,8 @@ const getApplicationVariableEntityExceptionUserFriendlyMessage = (
switch (code) {
case ApplicationVariableEntityExceptionCode.APPLICATION_VARIABLE_NOT_FOUND:
return msg`Application variable not found.`;
case ApplicationVariableEntityExceptionCode.INVALID_APPLICATION_VARIABLE_INPUT:
return msg`Invalid application variable input.`;
default:
assertUnreachable(code);
}
@@ -3,21 +3,24 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { ApplicationVariableEntityResolver } from 'src/engine/core-modules/application/application-variable/application-variable.resolver';
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
import { WorkspaceApplicationVariableMapCacheService } from 'src/engine/core-modules/application/application-variable/workspace-application-variable-map-cache.service';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { FlatApplicationVariableModule } from 'src/engine/metadata-modules/flat-application-variable/flat-application-variable.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Module({
imports: [
NestjsQueryTypeOrmModule.forFeature([ApplicationVariableEntity]),
TypeOrmModule.forFeature([ApplicationVariableEntity]),
TypeOrmModule.forFeature([ApplicationVariableEntity, ApplicationEntity]),
PermissionsModule,
WorkspaceCacheModule,
SecretEncryptionModule,
FlatApplicationVariableModule,
],
providers: [
ApplicationVariableEntityService,
@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ApplicationVariables } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { In, Not, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import {
@@ -23,14 +22,6 @@ export class ApplicationVariableEntityService {
private readonly secretEncryptionService: SecretEncryptionService,
) {}
private encryptSecretValue(value: string, isSecret: boolean): string {
if (!isSecret) {
return value;
}
return this.secretEncryptionService.encrypt(value);
}
getDisplayValue(applicationVariable: ApplicationVariableEntity): string {
if (!applicationVariable.isSecret) {
return applicationVariable.value;
@@ -42,48 +33,6 @@ export class ApplicationVariableEntityService {
});
}
// Decrypted plaintext value. Server-side only — never expose via GraphQL.
// Used by trusted server flows that need the raw secret (e.g. exchanging an
// OAuth client secret with a third-party provider).
getRawValue(applicationVariable: ApplicationVariableEntity): string {
if (!applicationVariable.isSecret) {
return applicationVariable.value;
}
return this.secretEncryptionService.decrypt(applicationVariable.value);
}
async findOneByKey({
applicationId,
key,
}: {
applicationId: string;
key: string;
}): Promise<ApplicationVariableEntity | null> {
return this.applicationVariableRepository.findOne({
where: { applicationId, key },
});
}
async getRawValueByKeyOrThrow({
applicationId,
key,
}: {
applicationId: string;
key: string;
}): Promise<string> {
const variable = await this.findOneByKey({ applicationId, key });
if (!isDefined(variable)) {
throw new ApplicationVariableEntityException(
`Application variable "${key}" not found for application ${applicationId}`,
ApplicationVariableEntityExceptionCode.APPLICATION_VARIABLE_NOT_FOUND,
);
}
return this.getRawValue(variable);
}
async update({
key,
plainTextValue,
@@ -105,10 +54,9 @@ export class ApplicationVariableEntityService {
);
}
const encryptedValue = this.encryptSecretValue(
plainTextValue,
existingVariable.isSecret,
);
const encryptedValue = existingVariable.isSecret
? this.secretEncryptionService.encrypt(plainTextValue)
: plainTextValue;
await this.applicationVariableRepository.update(
{ key, applicationId },
@@ -121,79 +69,4 @@ export class ApplicationVariableEntityService {
'applicationVariableMaps',
]);
}
async upsertManyApplicationVariableEntities({
applicationVariables,
applicationId,
workspaceId,
shouldUpdateValue = false,
}: {
applicationVariables?: ApplicationVariables;
applicationId: string;
workspaceId: string;
shouldUpdateValue?: boolean;
}) {
if (!isDefined(applicationVariables)) {
return;
}
const keys = Object.keys(applicationVariables);
const existingVariables = await this.applicationVariableRepository.find({
where: {
applicationId,
key: In(keys),
},
});
const existingVariablesByKey = new Map(
existingVariables.map((variable) => [variable.key, variable]),
);
const entitiesToSave: Partial<ApplicationVariableEntity>[] = [];
for (const [key, { value, description, isSecret }] of Object.entries(
applicationVariables,
)) {
const existingVariable = existingVariablesByKey.get(key);
const isSecretValue = isSecret ?? false;
const encryptedValue = this.encryptSecretValue(
value ?? '',
isSecretValue,
);
if (isDefined(existingVariable)) {
entitiesToSave.push({
id: existingVariable.id,
description: description ?? '',
isSecret: isSecretValue,
...(shouldUpdateValue || existingVariable.isSecret !== isSecretValue
? { value: encryptedValue }
: {}),
});
} else {
entitiesToSave.push({
key,
value: encryptedValue,
description: description ?? '',
isSecret: isSecretValue,
applicationId,
workspaceId,
});
}
}
if (entitiesToSave.length > 0) {
await this.applicationVariableRepository.save(entitiesToSave);
}
await this.applicationVariableRepository.delete({
applicationId,
key: Not(In(keys)),
});
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'applicationVariableMaps',
]);
}
}
@@ -1,6 +1,5 @@
import { type FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type';
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
import type { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
export type ApplicationVariableCacheMaps = {
byId: Partial<Record<string, FlatApplicationVariable>>;
byApplicationId: Partial<Record<string, FlatApplicationVariable[]>>;
};
export type ApplicationVariableCacheMaps =
FlatEntityMaps<FlatApplicationVariable>;
@@ -1,4 +0,0 @@
import { type ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
export type FlatApplicationVariable = FlatEntityFrom<ApplicationVariableEntity>;
@@ -1,16 +0,0 @@
import { type ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { type FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type';
export const fromApplicationVariableEntityToFlatApplicationVariable = (
entity: ApplicationVariableEntity,
): FlatApplicationVariable => ({
id: entity.id,
key: entity.key,
value: entity.value,
description: entity.description,
isSecret: entity.isSecret,
applicationId: entity.applicationId,
workspaceId: entity.workspaceId,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
});
@@ -2,14 +2,17 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type';
import { fromApplicationVariableEntityToFlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/utils/from-application-variable-entity-to-flat-application-variable.util';
import { fromApplicationVariableEntityToFlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/utils/from-application-variable-entity-to-flat-application-variable.util';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/utils/create-id-to-universal-identifier-map.util';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
@Injectable()
@WorkspaceCache('applicationVariableMaps')
@@ -17,6 +20,8 @@ export class WorkspaceApplicationVariableMapCacheService extends WorkspaceCacheP
constructor(
@InjectRepository(ApplicationVariableEntity)
private readonly applicationVariableRepository: Repository<ApplicationVariableEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {
super();
}
@@ -24,43 +29,32 @@ export class WorkspaceApplicationVariableMapCacheService extends WorkspaceCacheP
async computeForCache(
workspaceId: string,
): Promise<ApplicationVariableCacheMaps> {
const applicationVariableEntities = await this.applicationVariableRepository
.createQueryBuilder('applicationVariable')
.innerJoin('applicationVariable.application', 'application')
.where('application.workspaceId = :workspaceId', { workspaceId })
.getMany();
const [applicationVariableEntities, applications] = await Promise.all([
this.applicationVariableRepository.find({
where: { workspaceId },
}),
this.applicationRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
}),
]);
const applicationVariableMaps: ApplicationVariableCacheMaps = {
byId: {},
byApplicationId: {},
};
const applicationIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(applications);
const applicationVariableMaps = createEmptyFlatEntityMaps();
for (const entity of applicationVariableEntities) {
const flatApplicationVariable =
fromApplicationVariableEntityToFlatApplicationVariable(entity);
fromApplicationVariableEntityToFlatApplicationVariable({
entity,
applicationIdToUniversalIdentifierMap,
});
applicationVariableMaps.byId[flatApplicationVariable.id] =
flatApplicationVariable;
if (!isDefined(flatApplicationVariable.applicationId)) {
continue;
}
if (
!isDefined(
applicationVariableMaps.byApplicationId[
flatApplicationVariable.applicationId
],
)
) {
applicationVariableMaps.byApplicationId[
flatApplicationVariable.applicationId
] = [flatApplicationVariable];
continue;
}
applicationVariableMaps.byApplicationId[
flatApplicationVariable.applicationId
]?.push(flatApplicationVariable);
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity: flatApplicationVariable,
flatEntityMapsToMutate: applicationVariableMaps,
});
}
return applicationVariableMaps;