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
@@ -171,6 +171,7 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({
case 'objectPermission':
case 'fieldPermission':
case 'webhook':
case 'applicationVariable':
case 'connectionProvider': {
if (!isDefined(idByUniversalIdentifier)) {
return action;
@@ -41,6 +41,7 @@ import { WorkspaceMigrationViewFilterActionsBuilderService } from 'src/engine/wo
import { WorkspaceMigrationViewGroupActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-group/workspace-migration-view-group-actions-builder.service';
import { WorkspaceMigrationViewSortActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-sort/workspace-migration-view-sort-actions.builder.service';
import { WorkspaceMigrationViewActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view/workspace-migration-view-actions-builder.service';
import { WorkspaceMigrationApplicationVariableActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/application-variable/workspace-migration-application-variable-actions-builder.service';
import { WorkspaceMigrationWebhookActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/workspace-migration-webhook-actions-builder.service';
@Injectable()
@@ -73,6 +74,7 @@ export class WorkspaceMigrationBuildOrchestratorService {
private readonly workspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService: WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService,
private readonly workspaceMigrationFrontComponentActionsBuilderService: WorkspaceMigrationFrontComponentActionsBuilderService,
private readonly workspaceMigrationWebhookActionsBuilderService: WorkspaceMigrationWebhookActionsBuilderService,
private readonly workspaceMigrationApplicationVariableActionsBuilderService: WorkspaceMigrationApplicationVariableActionsBuilderService,
private readonly workspaceMigrationConnectionProviderActionsBuilderService: WorkspaceMigrationConnectionProviderActionsBuilderService,
) {}
@@ -125,6 +127,7 @@ export class WorkspaceMigrationBuildOrchestratorService {
const orchestratorActionsReport = structuredClone({
...createEmptyOrchestratorActionsReport(),
});
const orchestratorFailureReport = structuredClone(
EMPTY_ORCHESTRATOR_FAILURE_REPORT(),
);
@@ -166,6 +169,7 @@ export class WorkspaceMigrationBuildOrchestratorService {
flatPageLayoutTabMaps,
flatFrontComponentMaps,
flatWebhookMaps,
flatApplicationVariableMaps,
flatConnectionProviderMaps,
} = fromToAllFlatEntityMaps;
@@ -826,6 +830,34 @@ export class WorkspaceMigrationBuildOrchestratorService {
}
}
if (isDefined(flatApplicationVariableMaps)) {
const {
from: fromFlatApplicationVariableMaps,
to: toFlatApplicationVariableMaps,
} = flatApplicationVariableMaps;
const applicationVariableResult =
await this.workspaceMigrationApplicationVariableActionsBuilderService.validateAndBuild(
{
additionalCacheDataMaps,
from: fromFlatApplicationVariableMaps,
to: toFlatApplicationVariableMaps,
buildOptions,
dependencyOptimisticFlatEntityMaps: optimisticAllFlatEntityMaps,
workspaceId,
},
);
if (applicationVariableResult.status === 'fail') {
orchestratorFailureReport.applicationVariable.push(
...applicationVariableResult.errors,
);
} else {
orchestratorActionsReport.applicationVariable =
applicationVariableResult.actions;
}
}
if (isDefined(flatConnectionProviderMaps)) {
const {
from: fromFlatConnectionProviderMaps,
@@ -1031,6 +1063,11 @@ export class WorkspaceMigrationBuildOrchestratorService {
...aggregatedOrchestratorActionsReport.webhook.update,
///
// Application Variables
...aggregatedOrchestratorActionsReport.applicationVariable.delete,
...aggregatedOrchestratorActionsReport.applicationVariable.create,
...aggregatedOrchestratorActionsReport.applicationVariable.update,
// Connection providers
...aggregatedOrchestratorActionsReport.connectionProvider.delete,
...aggregatedOrchestratorActionsReport.connectionProvider.create,