feat(workflow): scaffold core workflowVersion entity + trigger cache (phase 0) (#21674)

## What

**Phase 0 (scaffold)** of migrating `workflowVersion` data to **core**.
Gated by `IS_WORKFLOW_VERSION_IN_CORE_ENABLED` with **no behavior
change** — nothing reads or writes the new core entity yet.

## Plan

`workflowVersion` becomes a thin **workspace shell** over a core entity
(the `dashboard`/`pageLayout` pattern), so navigation, the metadata
relations, and the record UI keep working while the heavy data
(`triggers`, `steps`) lives in core. Trigger dispatch will derive from
active core versions via a per-workspace cache, letting us **eliminate**
the denormalized `workflowAutomatedTrigger` object. `workflow` and
`workflowRun` stay as workspace objects.

Phases: **0 — scaffold (this PR)** → A — backfill + dual-write → B —
switch reads to core → C — drop the workspace `trigger`/`steps` columns
+ the `workflowAutomatedTrigger` object.

## Included
- **Core `WorkflowVersionEntity`** (`extends WorkspaceRelatedEntity`) —
stores version data, with triggers as an **array** (`triggers:
WorkflowTrigger[]`), a long-due shape change. Storage only: dispatch
reads the primary trigger, so behavior stays single-trigger for now.
- **Fast create-table instance command** for `core."workflowVersion"`
(v2.19.0).
- **`IS_WORKFLOW_VERSION_IN_CORE_ENABLED`** feature flag.
- **Per-workspace automated-trigger cache provider** deriving
CRON/DATABASE_EVENT dispatch from the active version's trigger —
groundwork for removing `workflowAutomatedTrigger`.

## Notes
- `WorkspaceRelatedEntity`, **not** `SyncableEntity`: this is user
runtime data (like `connectedAccount`/`apiKey`/`file`), not
application-manifest metadata.
- No frontend behavior; the generated `FeatureFlagKey` enums are updated
to include the new flag.
This commit is contained in:
Thomas Trompette
2026-07-08 14:50:03 +02:00
committed by GitHub
parent 6554440bb1
commit 48730df0d2
16 changed files with 238 additions and 4 deletions
@@ -0,0 +1,58 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { CREATE_WORKFLOW_VERSION_CORE_TABLE_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-20/create-workflow-version-core-table-upgrade-command-name.constant';
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
export enum WorkflowVersionStatus {
DRAFT = 'DRAFT',
ACTIVE = 'ACTIVE',
DEACTIVATED = 'DEACTIVATED',
ARCHIVED = 'ARCHIVED',
}
@Entity({ name: 'workflowVersion', schema: 'core' })
@WasIntroducedInUpgrade({
upgradeCommandName: CREATE_WORKFLOW_VERSION_CORE_TABLE_UPGRADE_COMMAND_NAME,
})
@Index('IDX_WORKFLOW_VERSION_WORKSPACE_ID', ['workspaceId'])
@Index('IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW', ['workflowId'], {
unique: true,
where: `"status" = 'ACTIVE'`,
})
export class WorkflowVersionEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'jsonb', nullable: true })
triggers: WorkflowTrigger[] | null;
@Column({ type: 'jsonb', nullable: true })
steps: WorkflowAction[] | null;
@Column({
type: 'enum',
enum: WorkflowVersionStatus,
default: WorkflowVersionStatus.DRAFT,
nullable: false,
})
status: WorkflowVersionStatus;
@Column({ type: 'uuid', nullable: false })
workflowId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import {
WorkflowVersionEntity,
WorkflowVersionStatus,
} from 'src/engine/core-modules/workflow/entities/workflow-version.entity';
import { type WorkflowAutomatedTriggerMaps } from 'src/engine/core-modules/workflow/types/workflow-automated-trigger-maps.type';
import { computeAutomatedTriggerFromWorkflowVersion } from 'src/engine/core-modules/workflow/utils/compute-automated-trigger-from-workflow-version.util';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
@Injectable()
@WorkspaceCache('workflowAutomatedTriggerMaps')
export class WorkspaceWorkflowAutomatedTriggerMapCacheService extends WorkspaceCacheProvider<WorkflowAutomatedTriggerMaps> {
constructor(
@InjectWorkspaceScopedRepository(WorkflowVersionEntity)
private readonly workflowVersionRepository: WorkspaceScopedRepository<WorkflowVersionEntity>,
) {
super();
}
async computeForCache(
workspaceId: string,
): Promise<WorkflowAutomatedTriggerMaps> {
const activeWorkflowVersions = await this.workflowVersionRepository.find(
workspaceId,
{ where: { status: WorkflowVersionStatus.ACTIVE } },
);
const byWorkflowId: WorkflowAutomatedTriggerMaps['byWorkflowId'] = {};
for (const workflowVersion of activeWorkflowVersions) {
const automatedTrigger =
computeAutomatedTriggerFromWorkflowVersion(workflowVersion);
if (isDefined(automatedTrigger)) {
byWorkflowId[workflowVersion.workflowId] = automatedTrigger;
}
}
return { byWorkflowId };
}
}
@@ -0,0 +1,13 @@
import { type AutomatedTriggerType } from 'src/modules/workflow/common/standard-objects/workflow-automated-trigger.workspace-entity';
import { type AutomatedTriggerSettings } from 'src/modules/workflow/workflow-trigger/automated-trigger/constants/automated-trigger-settings';
export type CachedWorkflowAutomatedTrigger = {
workflowId: string;
workflowVersionId: string;
type: AutomatedTriggerType;
settings: AutomatedTriggerSettings;
};
export type WorkflowAutomatedTriggerMaps = {
byWorkflowId: Record<string, CachedWorkflowAutomatedTrigger>;
};
@@ -0,0 +1,38 @@
import { type WorkflowVersionEntity } from 'src/engine/core-modules/workflow/entities/workflow-version.entity';
import { type CachedWorkflowAutomatedTrigger } from 'src/engine/core-modules/workflow/types/workflow-automated-trigger-maps.type';
import { AutomatedTriggerType } from 'src/modules/workflow/common/standard-objects/workflow-automated-trigger.workspace-entity';
import { type DatabaseEventTriggerSettings } from 'src/modules/workflow/workflow-trigger/automated-trigger/constants/automated-trigger-settings';
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
import { computeCronPatternFromSchedule } from 'src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule';
export const computeAutomatedTriggerFromWorkflowVersion = (
workflowVersion: WorkflowVersionEntity,
): CachedWorkflowAutomatedTrigger | null => {
const trigger = workflowVersion.triggers?.[0] ?? null;
if (trigger === null) {
return null;
}
switch (trigger.type) {
case WorkflowTriggerType.DATABASE_EVENT:
return {
workflowId: workflowVersion.workflowId,
workflowVersionId: workflowVersion.id,
type: AutomatedTriggerType.DATABASE_EVENT,
settings: trigger.settings as DatabaseEventTriggerSettings,
};
case WorkflowTriggerType.CRON:
return {
workflowId: workflowVersion.workflowId,
workflowVersionId: workflowVersion.id,
type: AutomatedTriggerType.CRON,
settings: { pattern: computeCronPatternFromSchedule(trigger) },
};
case WorkflowTriggerType.MANUAL:
case WorkflowTriggerType.WEBHOOK:
return null;
default:
return null;
}
};
@@ -8,6 +8,7 @@ import { WorkflowTriggerResolver } from 'src/engine/core-modules/workflow/resolv
import { WorkflowVersionEdgeResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-version-edge.resolver';
import { WorkflowVersionStepResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver';
import { WorkflowVersionResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-version.resolver';
import { WorkflowVersionCoreModule } from 'src/engine/core-modules/workflow/workflow-version-core.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
@@ -36,6 +37,7 @@ import { WorkflowTriggerModule } from 'src/modules/workflow/workflow-trigger/wor
CodeStepBuildModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ConnectedAccountMetadataModule,
WorkflowVersionCoreModule,
],
controllers: [WorkflowTriggerController],
providers: [
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkflowVersionEntity } from 'src/engine/core-modules/workflow/entities/workflow-version.entity';
import { WorkspaceWorkflowAutomatedTriggerMapCacheService } from 'src/engine/core-modules/workflow/services/workspace-workflow-automated-trigger-map-cache.service';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
@Module({
imports: [TypeOrmModule.forFeature([WorkflowVersionEntity])],
providers: [
WorkspaceWorkflowAutomatedTriggerMapCacheService,
provideWorkspaceScopedRepository(WorkflowVersionEntity),
],
exports: [TypeOrmModule, WorkspaceWorkflowAutomatedTriggerMapCacheService],
})
export class WorkflowVersionCoreModule {}
@@ -242,6 +242,7 @@ describe('WorkspaceEntityManager', () => {
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: false,
IS_SETTINGS_DISCOVERY_HERO_ENABLED: false,
IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED: false,
IS_WORKFLOW_VERSION_IN_CORE_ENABLED: false,
},
userWorkspaceRoleMap: {},
apiKeyRoleMap: {},
@@ -10,6 +10,7 @@ import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/appli
import { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
import { type CurrentBillingSubscription } from 'src/engine/core-modules/billing/types/flat-billing-subscription.type';
import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type';
import { type WorkflowAutomatedTriggerMaps } from 'src/engine/core-modules/workflow/types/workflow-automated-trigger-maps.type';
import { type FlatRoleTargetByAgentIdMaps } from 'src/engine/metadata-modules/flat-agent/types/flat-role-target-by-agent-id-maps.type';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { type UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/types/user-workspace-role-map';
@@ -62,6 +63,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = {
applicationVariableMaps: 'cache:application-variable',
graphQLResolverNameMap: 'direct-execution:graphql-resolver-name-map',
currentBillingSubscription: 'billing:subscription',
workflowAutomatedTriggerMaps: 'cache:workflow-automated-trigger',
} as const satisfies Record<WorkspaceCacheKeyName, string>;
export type AdditionalCacheDataMaps = {
@@ -79,6 +81,7 @@ export type AdditionalCacheDataMaps = {
applicationVariableMaps: ApplicationVariableCacheMaps;
graphQLResolverNameMap: Record<string, ResolverNameMapEntry>;
currentBillingSubscription: CurrentBillingSubscription;
workflowAutomatedTriggerMaps: WorkflowAutomatedTriggerMaps;
};
export type WorkspaceCacheDataMap = AllFlatEntityMaps<true> &