feat(workflow) - Add validation layer (#21422)
Add workflow validation framework and consolidate output schema types/search logic into twenty-shared This PR introduces a comprehensive workflow validation system that catches configuration errors at build-time, and consolidates the fragmented output-schema type definitions and variable-search logic from the front-end into twenty-shared **Workflow validation** — A new system that checks workflows for errors before activation: graph connectivity (unreachable steps, dangling references), step parameter schemas (via Zod), variable references (typos, wrong step order), and workspace metadata (non-existent objects). Returns structured errors/warnings with "did you mean?" suggestions. Runs automatically after create_complete_workflow and update_workflow_version_step, and is also available as a standalone validate_workflow tool. **Output schema consolidation** — Moves all output schema types and the variable-search logic from scattered front-end files into twenty-shared, replacing ~800 lines of duplicated per-schema-type code with a single unified searchVariableInOutputSchema dispatcher. To do : - validation on CODE and AGENT step --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+180
@@ -0,0 +1,180 @@
|
||||
import { computeCursorArgFilter } from '@/object-record/graphql/utils/computeCursorArgFilter';
|
||||
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
|
||||
|
||||
describe('computeCursorArgFilter', () => {
|
||||
it('should append an id tie-breaker when ordering does not include id', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ createdAt: 'AscNullsFirst' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { createdAt: '2024-01-01', id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ createdAt: { gt: '2024-01-01' } },
|
||||
{
|
||||
and: [
|
||||
{ createdAt: { eq: '2024-01-01' } },
|
||||
{ id: { gt: 'record-1' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not append an id field when it is already part of the ordering', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ id: 'AscNullsFirst' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { gt: 'record-1' } }] });
|
||||
});
|
||||
|
||||
it('should use lt operator for ascending order with backward pagination', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ id: 'AscNullsLast' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { lt: 'record-1' } }] });
|
||||
});
|
||||
|
||||
it('should use lt operator for descending order with forward pagination', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ id: 'DescNullsFirst' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { lt: 'record-1' } }] });
|
||||
});
|
||||
|
||||
it('should use gt operator for descending order with backward pagination', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ id: 'DescNullsLast' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { gt: 'record-1' } }] });
|
||||
});
|
||||
|
||||
it('should resolve nested composite sub-fields and read their cursor value', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst' } },
|
||||
];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { name: { firstName: 'John' }, id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ name: { firstName: { gt: 'John' } } },
|
||||
{
|
||||
and: [
|
||||
{ name: { firstName: { eq: 'John' } } },
|
||||
{ id: { gt: 'record-1' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to undefined cursor value for missing composite parent', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst' } },
|
||||
];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ name: { firstName: { gt: undefined } } },
|
||||
{
|
||||
and: [
|
||||
{ name: { firstName: { eq: undefined } } },
|
||||
{ id: { gt: 'record-1' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore nested values that are not order-by directions', () => {
|
||||
const orderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst', metadata: 'not-a-direction' } },
|
||||
] as unknown as RecordGqlOperationOrderBy;
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { name: { firstName: 'John' }, id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ name: { firstName: { gt: 'John' } } },
|
||||
{
|
||||
and: [
|
||||
{ name: { firstName: { eq: 'John' } } },
|
||||
{ id: { gt: 'record-1' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should build cumulative equality prefixes across multiple fields', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ score: 'DescNullsLast' },
|
||||
{ id: 'AscNullsFirst' },
|
||||
];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { score: 42, id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ score: { lt: 42 } },
|
||||
{
|
||||
and: [{ score: { eq: 42 } }, { id: { gt: 'record-1' } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to the id tie-breaker when there are no order-by fields', () => {
|
||||
const orderBy = [{}] as unknown as RecordGqlOperationOrderBy;
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { gt: 'record-1' } }] });
|
||||
});
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { extractOrderByFieldNames } from '@/object-record/graphql/utils/extractOrderByFieldNames';
|
||||
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
|
||||
|
||||
describe('extractOrderByFieldNames', () => {
|
||||
it('should always include the id field', () => {
|
||||
expect(extractOrderByFieldNames([])).toEqual({ id: true });
|
||||
});
|
||||
|
||||
it('should extract top-level field names', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ createdAt: 'AscNullsFirst' },
|
||||
{ name: 'DescNullsLast' },
|
||||
];
|
||||
|
||||
expect(extractOrderByFieldNames(orderBy)).toEqual({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
name: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract nested composite sub-field names', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst', lastName: 'DescNullsLast' } },
|
||||
];
|
||||
|
||||
expect(extractOrderByFieldNames(orderBy)).toEqual({
|
||||
id: true,
|
||||
name: { firstName: true, lastName: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore nested values that are not directions', () => {
|
||||
const orderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst', meta: 'Unknown' } },
|
||||
] as unknown as RecordGqlOperationOrderBy;
|
||||
|
||||
expect(extractOrderByFieldNames(orderBy)).toEqual({
|
||||
id: true,
|
||||
name: { firstName: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { isOrderByDirection } from '@/object-record/graphql/utils/isOrderByDirection';
|
||||
|
||||
describe('isOrderByDirection', () => {
|
||||
it('should return true for valid order by directions', () => {
|
||||
expect(isOrderByDirection('AscNullsFirst')).toBe(true);
|
||||
expect(isOrderByDirection('AscNullsLast')).toBe(true);
|
||||
expect(isOrderByDirection('DescNullsFirst')).toBe(true);
|
||||
expect(isOrderByDirection('DescNullsLast')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unknown strings', () => {
|
||||
expect(isOrderByDirection('Asc')).toBe(false);
|
||||
expect(isOrderByDirection('random')).toBe(false);
|
||||
expect(isOrderByDirection('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-string values', () => {
|
||||
expect(isOrderByDirection(undefined)).toBe(false);
|
||||
expect(isOrderByDirection(null)).toBe(false);
|
||||
expect(isOrderByDirection(42)).toBe(false);
|
||||
expect(isOrderByDirection({ foo: 'AscNullsFirst' })).toBe(false);
|
||||
});
|
||||
});
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { reverseOrderBy } from '@/object-record/graphql/utils/reverseOrderBy';
|
||||
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
|
||||
|
||||
describe('reverseOrderBy', () => {
|
||||
it('should reverse top-level directions', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ createdAt: 'AscNullsFirst' },
|
||||
{ name: 'DescNullsLast' },
|
||||
];
|
||||
|
||||
expect(reverseOrderBy(orderBy)).toEqual([
|
||||
{ createdAt: 'DescNullsLast' },
|
||||
{ name: 'AscNullsFirst' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reverse all direction variants', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ a: 'AscNullsFirst' },
|
||||
{ b: 'AscNullsLast' },
|
||||
{ c: 'DescNullsFirst' },
|
||||
{ d: 'DescNullsLast' },
|
||||
];
|
||||
|
||||
expect(reverseOrderBy(orderBy)).toEqual([
|
||||
{ a: 'DescNullsLast' },
|
||||
{ b: 'DescNullsFirst' },
|
||||
{ c: 'AscNullsLast' },
|
||||
{ d: 'AscNullsFirst' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reverse nested composite field directions', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst', lastName: 'DescNullsLast' } },
|
||||
];
|
||||
|
||||
expect(reverseOrderBy(orderBy)).toEqual([
|
||||
{ name: { firstName: 'DescNullsLast', lastName: 'AscNullsFirst' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should leave unknown values untouched', () => {
|
||||
const orderBy = [
|
||||
{ name: 'Unknown' },
|
||||
] as unknown as RecordGqlOperationOrderBy;
|
||||
|
||||
expect(reverseOrderBy(orderBy)).toEqual([{ name: 'Unknown' }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user