Compute output schema on frontend (#16530)

Fixes https://github.com/twentyhq/core-team-issues/issues/1382

Current issue : all step output schemas are computed and stored on
backend side. Which means that, when the database schema is updated -
like a field creation - steps needs to be deleted an recreated. Which is
invisible to users.

Solution : schema generation is moved on frontend side

1. Coming on the page the first time, the schema is populated for all
steps except a few ones that are handled differently (Code, Webhook,
http node, Agent)

2. A separated state allow to determine if a step needs a recomputation.

3. The user only needs a refresh to see the whole schema re-computed

Follow-up:
- check if remaining backend steps could be moved to runtime
computation. But Code will still require storage.
- Clean backend service that is not used anymore
This commit is contained in:
Thomas Trompette
2025-12-15 16:20:35 +01:00
committed by GitHub
parent 2e104c8e76
commit 95e0793f81
43 changed files with 3152 additions and 209 deletions
@@ -1,10 +1,11 @@
import { type WorkflowTrigger } from '@/workflow/types/Workflow';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger';
import { act, renderHook } from '@testing-library/react';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
const mockUpdateOneRecord = jest.fn();
const mockGetUpdatableWorkflowVersion = jest.fn();
const mockComputeStepOutputSchema = jest.fn();
const mockMarkStepForRecomputation = jest.fn();
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
useUpdateOneRecord: jest.fn(() => ({
@@ -18,9 +19,9 @@ jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({
})),
}));
jest.mock('@/workflow/hooks/useComputeStepOutputSchema', () => ({
useComputeStepOutputSchema: jest.fn(() => ({
computeStepOutputSchema: mockComputeStepOutputSchema,
jest.mock('@/workflow/workflow-variables/hooks/useStepsOutputSchema', () => ({
useStepsOutputSchema: jest.fn(() => ({
markStepForRecomputation: mockMarkStepForRecomputation,
})),
}));
@@ -39,11 +40,8 @@ describe('useUpdateWorkflowVersionTrigger', () => {
jest.clearAllMocks();
});
it('updates the trigger with computed output schema', async () => {
it('updates the trigger and marks it for recomputation for frontend-computed types', async () => {
mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id');
mockComputeStepOutputSchema.mockResolvedValue({
data: { computeStepOutputSchema: { field1: 'string' } },
});
const { result } = renderHook(() => useUpdateWorkflowVersionTrigger());
@@ -52,33 +50,10 @@ describe('useUpdateWorkflowVersionTrigger', () => {
});
expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalled();
expect(mockComputeStepOutputSchema).toHaveBeenCalledWith({
step: trigger,
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
stepId: TRIGGER_STEP_ID,
workflowVersionId: 'version-id',
});
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
idToUpdate: 'version-id',
updateOneRecordInput: {
trigger: {
...trigger,
settings: { ...trigger.settings, outputSchema: { field1: 'string' } },
},
},
});
});
it('skips output schema computation when disabled', async () => {
mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id');
const { result } = renderHook(() => useUpdateWorkflowVersionTrigger());
await act(async () => {
await result.current.updateTrigger(trigger, {
computeOutputSchema: false,
});
});
expect(mockComputeStepOutputSchema).not.toHaveBeenCalled();
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
idToUpdate: 'version-id',
updateOneRecordInput: {
@@ -86,4 +61,33 @@ describe('useUpdateWorkflowVersionTrigger', () => {
},
});
});
it('marks for recomputation for all trigger types', async () => {
const triggerTypes = ['DATABASE_EVENT', 'MANUAL', 'CRON', 'WEBHOOK'];
for (const triggerType of triggerTypes) {
mockMarkStepForRecomputation.mockClear();
mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id');
const testTrigger: WorkflowTrigger = {
name: `${triggerType} Trigger`,
type: triggerType as any,
settings: {
outputSchema: {},
},
nextStepIds: [],
};
const { result } = renderHook(() => useUpdateWorkflowVersionTrigger());
await act(async () => {
await result.current.updateTrigger(testTrigger);
});
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
stepId: TRIGGER_STEP_ID,
workflowVersionId: 'version-id',
});
}
});
});
@@ -1,12 +1,14 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useComputeStepOutputSchema } from '@/workflow/hooks/useComputeStepOutputSchema';
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import {
type WorkflowTrigger,
type WorkflowVersion,
} from '@/workflow/types/Workflow';
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
export const useUpdateWorkflowVersionTrigger = () => {
const { updateOneRecord: updateOneWorkflowVersion } =
useUpdateOneRecord<WorkflowVersion>({
@@ -16,34 +18,22 @@ export const useUpdateWorkflowVersionTrigger = () => {
const { getUpdatableWorkflowVersion } =
useGetUpdatableWorkflowVersionOrThrow();
const { computeStepOutputSchema } = useComputeStepOutputSchema();
const { markStepForRecomputation } = useStepsOutputSchema();
const updateTrigger = async (
updatedTrigger: WorkflowTrigger,
options: { computeOutputSchema: boolean } = { computeOutputSchema: true },
) => {
const updateTrigger = async (updatedTrigger: WorkflowTrigger) => {
const workflowVersionId = await getUpdatableWorkflowVersion();
if (options.computeOutputSchema) {
const outputSchema = (
await computeStepOutputSchema({
step: updatedTrigger,
workflowVersionId,
})
)?.data?.computeStepOutputSchema;
updatedTrigger.settings = {
...updatedTrigger.settings,
outputSchema: outputSchema || {},
};
}
await updateOneWorkflowVersion({
idToUpdate: workflowVersionId,
updateOneRecordInput: {
trigger: updatedTrigger,
},
});
markStepForRecomputation({
stepId: TRIGGER_STEP_ID,
workflowVersionId,
});
};
return {
@@ -0,0 +1,124 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { getManualTriggerDefaultSettings } from '../getManualTriggerDefaultSettings';
const mockObjectMetadataItems: ObjectMetadataItem[] = [
{
id: 'company-id',
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
icon: 'IconBuilding',
fields: [],
createdAt: new Date(),
} as unknown as ObjectMetadataItem,
];
describe('getManualTriggerDefaultSettings', () => {
describe('GLOBAL availability', () => {
it('should return correct settings for GLOBAL type', () => {
const result = getManualTriggerDefaultSettings({
availabilityType: 'GLOBAL',
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
});
expect(result).toEqual({
objectType: undefined,
availability: {
type: 'GLOBAL',
locations: undefined,
},
outputSchema: {},
icon: COMMAND_MENU_DEFAULT_ICON,
isPinned: false,
});
});
it('should use custom icon when provided', () => {
const result = getManualTriggerDefaultSettings({
availabilityType: 'GLOBAL',
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
icon: 'IconCustom',
});
expect(result.icon).toBe('IconCustom');
});
it('should use isPinned when provided', () => {
const result = getManualTriggerDefaultSettings({
availabilityType: 'GLOBAL',
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
isPinned: true,
});
expect(result.isPinned).toBe(true);
});
});
describe('SINGLE_RECORD availability', () => {
it('should return correct settings for SINGLE_RECORD type', () => {
const result = getManualTriggerDefaultSettings({
availabilityType: 'SINGLE_RECORD',
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
});
expect(result).toEqual({
objectType: 'company',
availability: {
type: 'SINGLE_RECORD',
objectNameSingular: 'company',
},
outputSchema: {},
icon: COMMAND_MENU_DEFAULT_ICON,
isPinned: false,
});
});
it('should use the first object metadata item', () => {
const multipleObjects: ObjectMetadataItem[] = [
...mockObjectMetadataItems,
{
id: 'person-id',
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Person',
labelPlural: 'People',
icon: 'IconUser',
fields: [],
} as unknown as ObjectMetadataItem,
];
const result = getManualTriggerDefaultSettings({
availabilityType: 'SINGLE_RECORD',
activeNonSystemObjectMetadataItems: multipleObjects,
});
expect(result.objectType).toBe('company');
expect(
(result.availability as { objectNameSingular: string })
.objectNameSingular,
).toBe('company');
});
});
describe('BULK_RECORDS availability', () => {
it('should return correct settings for BULK_RECORDS type', () => {
const result = getManualTriggerDefaultSettings({
availabilityType: 'BULK_RECORDS',
activeNonSystemObjectMetadataItems: mockObjectMetadataItems,
});
expect(result).toEqual({
objectType: 'company',
availability: {
type: 'BULK_RECORDS',
objectNameSingular: 'company',
},
outputSchema: {},
icon: COMMAND_MENU_DEFAULT_ICON,
isPinned: false,
});
});
});
});
@@ -0,0 +1,146 @@
import { type WorkflowAction } from '@/workflow/types/Workflow';
import { getRootStepIds } from '../getRootStepIds';
describe('getRootStepIds', () => {
it('should return empty array for empty steps', () => {
const result = getRootStepIds([]);
expect(result).toEqual([]);
});
it('should return single step id when only one step exists', () => {
const steps: WorkflowAction[] = [
{
id: 'step-1',
name: 'Step 1',
type: 'CREATE_RECORD',
valid: true,
settings: {} as any,
},
];
const result = getRootStepIds(steps);
expect(result).toEqual(['step-1']);
});
it('should return root step ids (steps not referenced by other steps)', () => {
const steps: WorkflowAction[] = [
{
id: 'step-1',
name: 'Root Step',
type: 'CREATE_RECORD',
valid: true,
settings: {} as any,
nextStepIds: ['step-2'],
},
{
id: 'step-2',
name: 'Child Step',
type: 'UPDATE_RECORD',
valid: true,
settings: {} as any,
},
];
const result = getRootStepIds(steps);
expect(result).toEqual(['step-1']);
});
it('should return multiple root steps when there are parallel branches', () => {
const steps: WorkflowAction[] = [
{
id: 'step-1',
name: 'Root 1',
type: 'CREATE_RECORD',
valid: true,
settings: {} as any,
nextStepIds: ['step-3'],
},
{
id: 'step-2',
name: 'Root 2',
type: 'UPDATE_RECORD',
valid: true,
settings: {} as any,
nextStepIds: ['step-3'],
},
{
id: 'step-3',
name: 'Child',
type: 'SEND_EMAIL',
valid: true,
settings: {} as any,
},
];
const result = getRootStepIds(steps);
expect(result).toEqual(['step-1', 'step-2']);
});
it('should handle steps with undefined nextStepIds', () => {
const steps: WorkflowAction[] = [
{
id: 'step-1',
name: 'Step 1',
type: 'CREATE_RECORD',
valid: true,
settings: {} as any,
nextStepIds: undefined,
},
{
id: 'step-2',
name: 'Step 2',
type: 'UPDATE_RECORD',
valid: true,
settings: {} as any,
},
];
const result = getRootStepIds(steps);
expect(result).toEqual(['step-1', 'step-2']);
});
it('should handle complex workflow with multiple levels', () => {
const steps: WorkflowAction[] = [
{
id: 'root',
name: 'Root',
type: 'CREATE_RECORD',
valid: true,
settings: {} as any,
nextStepIds: ['level-1-a', 'level-1-b'],
},
{
id: 'level-1-a',
name: 'Level 1 A',
type: 'UPDATE_RECORD',
valid: true,
settings: {} as any,
nextStepIds: ['level-2'],
},
{
id: 'level-1-b',
name: 'Level 1 B',
type: 'SEND_EMAIL',
valid: true,
settings: {} as any,
nextStepIds: ['level-2'],
},
{
id: 'level-2',
name: 'Level 2',
type: 'DELETE_RECORD',
valid: true,
settings: {} as any,
},
];
const result = getRootStepIds(steps);
expect(result).toEqual(['root']);
});
});