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
@@ -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",
@@ -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: {
@@ -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,
@@ -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,
@@ -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>>;
@@ -112,5 +112,6 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
},
frontComponent: {},
webhook: {},
applicationVariable: {},
connectionProvider: {},
} as const satisfies MetadataRequiredForValidation;
@@ -50,6 +50,7 @@ export const ALL_METADATA_SERIALIZED_RELATION = {
viewSort: {},
frontComponent: {},
webhook: {},
applicationVariable: {},
connectionProvider: {},
} as const satisfies MetadataSerializedRelationProperties;
@@ -243,6 +243,7 @@ export const ALL_ONE_TO_MANY_METADATA_RELATIONS = {
},
frontComponent: {},
webhook: {},
applicationVariable: {},
viewSort: {},
connectionProvider: {},
} as const satisfies OneToManyMetadataRelationsProperties;
@@ -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,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",
@@ -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",