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
@@ -1758,6 +1758,7 @@ enum FeatureFlagKey {
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED
IS_SETTINGS_DISCOVERY_HERO_ENABLED
IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED
IS_WORKFLOW_VERSION_IN_CORE_ENABLED
}
type WorkspaceUrls {
@@ -1396,7 +1396,7 @@ export interface FeatureFlag {
__typename: 'FeatureFlag'
}
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED'
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED' | 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED'
export interface WorkspaceUrls {
customUrl?: Scalars['String']
@@ -9243,7 +9243,8 @@ export const enumFeatureFlagKey = {
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const,
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' as const,
IS_SETTINGS_DISCOVERY_HERO_ENABLED: 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' as const,
IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED: 'IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED' as const
IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED: 'IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED' as const,
IS_WORKFLOW_VERSION_IN_CORE_ENABLED: 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED' as const
}
export const enumIdentityProviderType = {
@@ -320,7 +320,8 @@ export enum FeatureFlagKey {
IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED = 'IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED',
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_WORKFLOW_VERSION_IN_CORE_ENABLED = 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED'
}
export type FindApplicationRegistrationInstalledWorkspacesInput = {
@@ -1759,7 +1759,8 @@ export enum FeatureFlagKey {
IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED = 'IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED',
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_WORKFLOW_VERSION_IN_CORE_ENABLED = 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED'
}
export type Field = {
@@ -0,0 +1,45 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.20.0', 1783512000000)
export class CreateWorkflowVersionCoreTableFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DO $$ BEGIN CREATE TYPE "core"."workflowVersion_status_enum" AS ENUM ('DRAFT', 'ACTIVE', 'DEACTIVATED', 'ARCHIVED'); EXCEPTION WHEN duplicate_object THEN null; END $$`,
);
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "core"."workflowVersion" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"triggers" jsonb,
"steps" jsonb,
"status" "core"."workflowVersion_status_enum" NOT NULL DEFAULT 'DRAFT',
"workflowId" uuid NOT NULL,
"workspaceId" uuid NOT NULL,
CONSTRAINT "PK_workflowVersion_id" PRIMARY KEY ("id"),
CONSTRAINT "FK_4316468725741b8e11e02b144f3" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE
)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_WORKFLOW_VERSION_WORKSPACE_ID"
ON "core"."workflowVersion" ("workspaceId")`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW"
ON "core"."workflowVersion" ("workflowId")
WHERE "status" = 'ACTIVE'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "core"."workflowVersion"`);
await queryRunner.query(
`DROP TYPE IF EXISTS "core"."workflowVersion_status_enum"`,
);
}
}
@@ -0,0 +1,4 @@
// Referenced by @WasIntroducedInUpgrade on WorkflowVersionEntity so upgrade
// steps running below 2.20.0 don't query the table before this command creates it.
export const CREATE_WORKFLOW_VERSION_CORE_TABLE_UPGRADE_COMMAND_NAME =
'2.20.0_CreateWorkflowVersionCoreTableFastInstanceCommand_1783512000000';
@@ -100,6 +100,7 @@ import { AddDisplayFieldsToApplicationRegistrationFastInstanceCommand } from './
import { BackfillDisplayFieldsOnApplicationRegistrationSlowInstanceCommand } from './2-19/2-19-instance-command-slow-1783073776591-backfill-display-fields-on-application-registration';
import { BackfillIsFeaturedOnApplicationRegistrationSlowInstanceCommand } from './2-19/2-19-instance-command-slow-1783120000000-backfill-is-featured-on-application-registration';
import { AllowServerScopedFileFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1783499671541-allow-server-scoped-file';
import { CreateWorkflowVersionCoreTableFastInstanceCommand } from './2-20/2-20-instance-command-fast-1783512000000-create-workflow-version-core-table';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -202,4 +203,5 @@ export const INSTANCE_COMMANDS = [
AddPendingMimeCheckToFileFastInstanceCommand,
BackfillIsFeaturedOnApplicationRegistrationSlowInstanceCommand,
AllowServerScopedFileFastInstanceCommand,
CreateWorkflowVersionCoreTableFastInstanceCommand,
];
@@ -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> &
@@ -7,4 +7,5 @@ export enum FeatureFlagKey {
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED',
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED = 'IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED',
IS_WORKFLOW_VERSION_IN_CORE_ENABLED = 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED',
}