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:
+7
@@ -0,0 +1,7 @@
|
||||
import { type MetadataEntityPropertyName } from 'src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant';
|
||||
|
||||
export const FLAT_APPLICATION_VARIABLE_EDITABLE_PROPERTIES = [
|
||||
'key',
|
||||
'description',
|
||||
'isSecret',
|
||||
] as const satisfies MetadataEntityPropertyName<'applicationVariable'>[];
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/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 { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { WorkspaceFlatApplicationVariableMapCacheService } from 'src/engine/metadata-modules/flat-application-variable/services/workspace-flat-application-variable-map-cache.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ApplicationEntity, ApplicationVariableEntity]),
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
],
|
||||
providers: [WorkspaceFlatApplicationVariableMapCacheService],
|
||||
exports: [WorkspaceFlatApplicationVariableMapCacheService],
|
||||
})
|
||||
export class FlatApplicationVariableModule {}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
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 { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
|
||||
import { type FlatApplicationVariableMaps } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable-maps.type';
|
||||
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 { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
|
||||
|
||||
@Injectable()
|
||||
@WorkspaceCache('flatApplicationVariableMaps')
|
||||
export class WorkspaceFlatApplicationVariableMapCacheService extends WorkspaceCacheProvider<FlatApplicationVariableMaps> {
|
||||
constructor(
|
||||
@InjectRepository(ApplicationVariableEntity)
|
||||
private readonly applicationVariableRepository: Repository<ApplicationVariableEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async computeForCache(
|
||||
workspaceId: string,
|
||||
): Promise<FlatApplicationVariableMaps> {
|
||||
const [applicationVariables, applications] = await Promise.all([
|
||||
this.applicationVariableRepository.find({
|
||||
where: { workspaceId },
|
||||
}),
|
||||
this.applicationRepository.find({
|
||||
where: { workspaceId },
|
||||
select: ['id', 'universalIdentifier'],
|
||||
}),
|
||||
]);
|
||||
|
||||
const applicationIdToUniversalIdentifierMap =
|
||||
createIdToUniversalIdentifierMap(applications);
|
||||
|
||||
const flatApplicationVariableMaps = createEmptyFlatEntityMaps();
|
||||
|
||||
for (const applicationVariableEntity of applicationVariables) {
|
||||
const flatApplicationVariable =
|
||||
fromApplicationVariableEntityToFlatApplicationVariable({
|
||||
entity: applicationVariableEntity,
|
||||
applicationIdToUniversalIdentifierMap,
|
||||
});
|
||||
|
||||
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
|
||||
flatEntity: flatApplicationVariable,
|
||||
flatEntityMapsToMutate: flatApplicationVariableMaps,
|
||||
});
|
||||
}
|
||||
|
||||
return flatApplicationVariableMaps;
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
|
||||
export type FlatApplicationVariableMaps =
|
||||
FlatEntityMaps<FlatApplicationVariable>;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
|
||||
import { type ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
|
||||
export type FlatApplicationVariable = FlatEntityFrom<ApplicationVariableEntity>;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
FlatEntityMapsException,
|
||||
FlatEntityMapsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
|
||||
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
import { type FromEntityToFlatEntityArgs } from 'src/engine/workspace-cache/types/from-entity-to-flat-entity-args.type';
|
||||
|
||||
export const fromApplicationVariableEntityToFlatApplicationVariable = ({
|
||||
entity: applicationVariableEntity,
|
||||
applicationIdToUniversalIdentifierMap,
|
||||
}: FromEntityToFlatEntityArgs<'applicationVariable'>): FlatApplicationVariable => {
|
||||
const applicationUniversalIdentifier =
|
||||
applicationIdToUniversalIdentifierMap.get(
|
||||
applicationVariableEntity.applicationId,
|
||||
);
|
||||
|
||||
if (!isDefined(applicationUniversalIdentifier)) {
|
||||
throw new FlatEntityMapsException(
|
||||
`Application with id ${applicationVariableEntity.applicationId} not found for applicationVariable ${applicationVariableEntity.id}`,
|
||||
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: applicationVariableEntity.id,
|
||||
key: applicationVariableEntity.key,
|
||||
value: applicationVariableEntity.value,
|
||||
description: applicationVariableEntity.description,
|
||||
isSecret: applicationVariableEntity.isSecret,
|
||||
workspaceId: applicationVariableEntity.workspaceId,
|
||||
universalIdentifier: applicationVariableEntity.universalIdentifier,
|
||||
applicationId: applicationVariableEntity.applicationId,
|
||||
createdAt: applicationVariableEntity.createdAt.toISOString(),
|
||||
updatedAt: applicationVariableEntity.updatedAt.toISOString(),
|
||||
applicationUniversalIdentifier,
|
||||
};
|
||||
};
|
||||
+8
@@ -20,6 +20,14 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
|
||||
"evaluationInputs",
|
||||
],
|
||||
},
|
||||
"applicationVariable": {
|
||||
"propertiesToCompare": [
|
||||
"key",
|
||||
"description",
|
||||
"isSecret",
|
||||
],
|
||||
"propertiesToStringify": [],
|
||||
},
|
||||
"commandMenuItem": {
|
||||
"propertiesToCompare": [
|
||||
"label",
|
||||
|
||||
+32
@@ -1542,6 +1542,38 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
universalProperty: undefined,
|
||||
},
|
||||
},
|
||||
applicationVariable: {
|
||||
key: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
value: {
|
||||
toCompare: false,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
description: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
isSecret: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
createdAt: {
|
||||
toCompare: false,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
updatedAt: {
|
||||
toCompare: false,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
},
|
||||
connectionProvider: {
|
||||
name: { toCompare: true, toStringify: false, universalProperty: undefined },
|
||||
displayName: {
|
||||
|
||||
+4
@@ -275,6 +275,10 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
|
||||
workspace: null,
|
||||
application: null,
|
||||
},
|
||||
applicationVariable: {
|
||||
workspace: null,
|
||||
application: null,
|
||||
},
|
||||
viewSort: {
|
||||
application: null,
|
||||
workspace: null,
|
||||
|
||||
+4
@@ -487,6 +487,10 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
|
||||
workspace: null,
|
||||
application: null,
|
||||
},
|
||||
applicationVariable: {
|
||||
workspace: null,
|
||||
application: null,
|
||||
},
|
||||
viewSort: {
|
||||
application: null,
|
||||
workspace: null,
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ import { type EntityTarget, type ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
|
||||
@@ -57,6 +58,7 @@ export const ALL_METADATA_ENTITY_BY_METADATA_NAME = {
|
||||
navigationMenuItem: NavigationMenuItemEntity,
|
||||
permissionFlag: PermissionFlagEntity,
|
||||
webhook: WebhookEntity,
|
||||
applicationVariable: ApplicationVariableEntity,
|
||||
viewSort: ViewSortEntity,
|
||||
connectionProvider: ConnectionProviderEntity,
|
||||
} as const satisfies Record<AllMetadataName, EntityTarget<ObjectLiteral>>;
|
||||
|
||||
+1
@@ -112,5 +112,6 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
|
||||
},
|
||||
frontComponent: {},
|
||||
webhook: {},
|
||||
applicationVariable: {},
|
||||
connectionProvider: {},
|
||||
} as const satisfies MetadataRequiredForValidation;
|
||||
|
||||
+1
@@ -50,6 +50,7 @@ export const ALL_METADATA_SERIALIZED_RELATION = {
|
||||
viewSort: {},
|
||||
frontComponent: {},
|
||||
webhook: {},
|
||||
applicationVariable: {},
|
||||
connectionProvider: {},
|
||||
} as const satisfies MetadataSerializedRelationProperties;
|
||||
|
||||
|
||||
+1
@@ -243,6 +243,7 @@ export const ALL_ONE_TO_MANY_METADATA_RELATIONS = {
|
||||
},
|
||||
frontComponent: {},
|
||||
webhook: {},
|
||||
applicationVariable: {},
|
||||
viewSort: {},
|
||||
connectionProvider: {},
|
||||
} as const satisfies OneToManyMetadataRelationsProperties;
|
||||
|
||||
+27
@@ -45,6 +45,8 @@ import { type FlatViewSortMaps } from 'src/engine/metadata-modules/flat-view-sor
|
||||
import { type FlatViewSort } from 'src/engine/metadata-modules/flat-view-sort/types/flat-view-sort.type';
|
||||
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
|
||||
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
|
||||
import { type FlatApplicationVariableMaps } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable-maps.type';
|
||||
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
import { type FlatWebhookMaps } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook-maps.type';
|
||||
import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type';
|
||||
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
|
||||
@@ -80,6 +82,7 @@ import { type UniversalFlatViewFilter } from 'src/engine/workspace-manager/works
|
||||
import { type UniversalFlatViewGroup } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-group.type';
|
||||
import { type UniversalFlatViewSort } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-sort.type';
|
||||
import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view.type';
|
||||
import { type UniversalFlatApplicationVariable } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-application-variable.type';
|
||||
import { type UniversalFlatWebhook } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-webhook.type';
|
||||
import {
|
||||
type FlatCreateAgentAction,
|
||||
@@ -297,6 +300,14 @@ import {
|
||||
type UniversalDeleteViewAction,
|
||||
type UniversalUpdateViewAction,
|
||||
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view/types/workspace-migration-view-action.type';
|
||||
import {
|
||||
type FlatCreateApplicationVariableAction,
|
||||
type FlatDeleteApplicationVariableAction,
|
||||
type FlatUpdateApplicationVariableAction,
|
||||
type UniversalCreateApplicationVariableAction,
|
||||
type UniversalDeleteApplicationVariableAction,
|
||||
type UniversalUpdateApplicationVariableAction,
|
||||
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/application-variable/types/workspace-migration-application-variable-action.type';
|
||||
import {
|
||||
type FlatCreateWebhookAction,
|
||||
type FlatDeleteWebhookAction,
|
||||
@@ -742,6 +753,22 @@ export type AllFlatEntityTypesByMetadataName = {
|
||||
universalFlatEntity: UniversalFlatWebhook;
|
||||
entity: MetadataEntity<'webhook'>;
|
||||
};
|
||||
applicationVariable: {
|
||||
flatEntityMaps: FlatApplicationVariableMaps;
|
||||
universalActions: {
|
||||
create: UniversalCreateApplicationVariableAction;
|
||||
update: UniversalUpdateApplicationVariableAction;
|
||||
delete: UniversalDeleteApplicationVariableAction;
|
||||
};
|
||||
flatActions: {
|
||||
create: FlatCreateApplicationVariableAction;
|
||||
update: FlatUpdateApplicationVariableAction;
|
||||
delete: FlatDeleteApplicationVariableAction;
|
||||
};
|
||||
flatEntity: FlatApplicationVariable;
|
||||
universalFlatEntity: UniversalFlatApplicationVariable;
|
||||
entity: MetadataEntity<'applicationVariable'>;
|
||||
};
|
||||
connectionProvider: {
|
||||
flatEntityMaps: FlatConnectionProviderMaps;
|
||||
universalActions: {
|
||||
|
||||
+2
@@ -2,6 +2,8 @@
|
||||
|
||||
exports[`getMetadataRelatedMetadataNames should return related metadata names for agent 1`] = `[]`;
|
||||
|
||||
exports[`getMetadataRelatedMetadataNames should return related metadata names for applicationVariable 1`] = `[]`;
|
||||
|
||||
exports[`getMetadataRelatedMetadataNames should return related metadata names for commandMenuItem 1`] = `
|
||||
[
|
||||
"objectMetadata",
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with children first (most manyToOne relations first) 1`] = `
|
||||
[
|
||||
@@ -18,6 +18,7 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with
|
||||
"rowLevelPermissionPredicateGroup",
|
||||
"viewGroup",
|
||||
"agent",
|
||||
"applicationVariable",
|
||||
"connectionProvider",
|
||||
"frontComponent",
|
||||
"logicFunction",
|
||||
|
||||
Reference in New Issue
Block a user