Compare commits

..
Author SHA1 Message Date
sonarly-bot 24fc02a884 fix: stop masking transient Postgres errors as validation errors
https://sonarly.com/issue/41962?type=bug

Person create/delete mutations can fail with a generic INTERNAL_SERVER_ERROR “Data validation error.” instead of the real database failure, blocking core CRM record management for affected workspaces.

Fix: I first checked recent history as requested (`git log --all --oneline --since='30 days ago' -- <affected files>`) and did not find an existing commit that fixed this exact root cause in `computeTwentyORMException`.

Then I implemented the code fix in the ORM exception adapter:
- Added an explicit set of Postgres data-validation error codes (e.g. FK/NOT NULL/CHECK violations and related data-shape errors).
- Kept the generic `Data validation error.` masking only for that validation subset.
- For other recognized Postgres errors (including transient concurrency errors like deadlock/serialization), preserved the original DB message when building `PostgresException`.

This removes the broad masking behavior that was rewriting all recognized Postgres failures to the same validation message, while preserving safe masking where it still makes sense.

I also added focused tests to lock this behavior:
- invalid text representation -> `TwentyORMExceptionCode.INVALID_INPUT`
- validation-class Postgres error -> still generic `Data validation error.`
- transient deadlock error -> preserves original message (`deadlock detected`)

Authored by Sonarly by autonomous analysis (run 47884).
2026-06-01 18:48:24 +00:00
6 changed files with 218 additions and 151 deletions
@@ -0,0 +1,75 @@
import * as Sentry from '@sentry/node';
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
import { PostgresException } from 'src/engine/api/graphql/workspace-query-runner/utils/postgres-exception';
import { ExceptionHandlerSentryDriver } from 'src/engine/core-modules/exception-handler/drivers/sentry.driver';
jest.mock('@sentry/node', () => ({
withScope: jest.fn(),
captureException: jest.fn(),
}));
describe('ExceptionHandlerSentryDriver', () => {
const setExtra = jest.fn();
const setUser = jest.fn();
const addBreadcrumb = jest.fn();
const setContext = jest.fn();
const setTag = jest.fn();
const setFingerprint = jest.fn();
const setLevel = jest.fn();
const scope = {
setExtra,
setUser,
addBreadcrumb,
setContext,
setTag,
setFingerprint,
setLevel,
};
beforeEach(() => {
jest.clearAllMocks();
(Sentry.withScope as jest.Mock).mockImplementation((callback) => {
callback(scope);
});
(Sentry.captureException as jest.Mock).mockReturnValue('event-id');
});
it('should capture retryable postgres errors as warning and annotate metadata', () => {
const driver = new ExceptionHandlerSentryDriver();
const exception = new PostgresException(
'deadlock detected',
POSTGRESQL_ERROR_CODES.DEADLOCK_DETECTED,
);
driver.captureExceptions([exception]);
expect(setTag).toHaveBeenCalledWith('postgresSqlErrorCode', '40P01');
expect(setTag).toHaveBeenCalledWith('postgresSqlErrorType', 'retryable');
expect(setContext).toHaveBeenCalledWith('postgres', {
code: '40P01',
isRetryable: true,
});
expect(setLevel).toHaveBeenCalledWith('warning');
});
it('should keep non-retryable postgres errors at error level', () => {
const driver = new ExceptionHandlerSentryDriver();
const exception = new PostgresException(
'relation does not exist',
POSTGRESQL_ERROR_CODES.UNDEFINED_TABLE,
);
driver.captureExceptions([exception]);
expect(setTag).toHaveBeenCalledWith('postgresSqlErrorCode', '42P01');
expect(setTag).toHaveBeenCalledWith(
'postgresSqlErrorType',
'non-retryable',
);
expect(setLevel).toHaveBeenCalledWith('error');
expect(setLevel).not.toHaveBeenCalledWith('warning');
});
});
@@ -7,11 +7,18 @@ import {
import { type ExceptionHandlerOptions } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-options.interface';
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
import { PostgresException } from 'src/engine/api/graphql/workspace-query-runner/utils/postgres-exception';
import { type ExceptionHandlerDriverInterface } from 'src/engine/core-modules/exception-handler/interfaces';
import { MessageImportDriverException } from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { CustomException } from 'src/utils/custom-exception';
const RETRYABLE_POSTGRES_ERROR_CODES = new Set([
POSTGRESQL_ERROR_CODES.DEADLOCK_DETECTED,
POSTGRESQL_ERROR_CODES.SERIALIZATION_FAILURE,
POSTGRESQL_ERROR_CODES.LOCK_NOT_AVAILABLE,
]);
export class ExceptionHandlerSentryDriver implements ExceptionHandlerDriverInterface {
captureExceptions(
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
@@ -48,6 +55,8 @@ export class ExceptionHandlerSentryDriver implements ExceptionHandlerDriverInter
}
for (const exception of exceptions) {
scope.setLevel('error');
const errorPath = (exception.path ?? [])
.map((v: string | number) => (typeof v === 'number' ? '$index' : v))
.join(' > ');
@@ -84,7 +93,24 @@ export class ExceptionHandlerSentryDriver implements ExceptionHandlerDriverInter
}
if (exception instanceof PostgresException) {
const isRetryablePostgresError = RETRYABLE_POSTGRES_ERROR_CODES.has(
exception.code,
);
scope.setTag('postgresSqlErrorCode', exception.code);
scope.setTag(
'postgresSqlErrorType',
isRetryablePostgresError ? 'retryable' : 'non-retryable',
);
scope.setContext('postgres', {
code: exception.code,
isRetryable: isRetryablePostgresError,
});
if (isRetryablePostgresError) {
scope.setLevel('warning');
}
const fingerPrint = [exception.code];
const genericOperationName = getGenericOperationName(
options?.operation?.name,
@@ -93,6 +119,7 @@ export class ExceptionHandlerSentryDriver implements ExceptionHandlerDriverInter
if (isDefined(genericOperationName)) {
fingerPrint.push(genericOperationName);
}
scope.setFingerprint(fingerPrint);
exception.name = exception.message;
}
@@ -0,0 +1,72 @@
import { QueryFailedError } from 'typeorm';
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
import { PostgresException } from 'src/engine/api/graphql/workspace-query-runner/utils/postgres-exception';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { computeTwentyORMException } from '../compute-twenty-orm-exception';
const createQueryFailedError = ({
message,
code,
}: {
message: string;
code: string;
}) => {
const queryFailedError = new QueryFailedError(
'SELECT 1',
[],
new Error(message),
);
Object.assign(queryFailedError, { code });
return queryFailedError;
};
describe('computeTwentyORMException', () => {
it('should map invalid text representation to INVALID_INPUT', async () => {
const queryFailedError = createQueryFailedError({
message: 'invalid input value for enum "task_status_enum": "NOT_A_STATUS"',
code: POSTGRESQL_ERROR_CODES.INVALID_TEXT_REPRESENTATION,
});
const exception = await computeTwentyORMException(queryFailedError);
expect(exception).toBeInstanceOf(TwentyORMException);
expect((exception as TwentyORMException).code).toBe(
TwentyORMExceptionCode.INVALID_INPUT,
);
});
it('should keep a generic data validation message for validation postgres errors', async () => {
const queryFailedError = createQueryFailedError({
message: 'insert or update on table "person" violates foreign key constraint',
code: POSTGRESQL_ERROR_CODES.FOREIGN_KEY_VIOLATION,
});
await expect(computeTwentyORMException(queryFailedError)).rejects.toMatchObject(
new PostgresException(
'Data validation error.',
POSTGRESQL_ERROR_CODES.FOREIGN_KEY_VIOLATION,
),
);
});
it('should preserve transient postgres error messages', async () => {
const queryFailedError = createQueryFailedError({
message: 'deadlock detected',
code: POSTGRESQL_ERROR_CODES.DEADLOCK_DETECTED,
});
await expect(computeTwentyORMException(queryFailedError)).rejects.toMatchObject(
new PostgresException(
'deadlock detected',
POSTGRESQL_ERROR_CODES.DEADLOCK_DETECTED,
),
);
});
});
@@ -18,6 +18,16 @@ interface QueryFailedErrorWithCode extends QueryFailedError {
code?: string;
}
const POSTGRESQL_DATA_VALIDATION_ERROR_CODES = new Set([
POSTGRESQL_ERROR_CODES.NOT_NULL_VIOLATION,
POSTGRESQL_ERROR_CODES.FOREIGN_KEY_VIOLATION,
POSTGRESQL_ERROR_CODES.CHECK_VIOLATION,
POSTGRESQL_ERROR_CODES.STRING_DATA_RIGHT_TRUNCATION,
POSTGRESQL_ERROR_CODES.NUMERIC_VALUE_OUT_OF_RANGE,
POSTGRESQL_ERROR_CODES.INVALID_DATETIME_FORMAT,
POSTGRESQL_ERROR_CODES.INVALID_PARAMETER_VALUE,
]);
export const computeTwentyORMException = async (
error: Error,
objectMetadata?: FlatObjectMetadata,
@@ -62,7 +72,11 @@ export const computeTwentyORMException = async (
isDefined(errorCode) &&
Object.values(POSTGRESQL_ERROR_CODES).includes(errorCode)
) {
throw new PostgresException('Data validation error.', errorCode);
if (POSTGRESQL_DATA_VALIDATION_ERROR_CODES.has(errorCode)) {
throw new PostgresException('Data validation error.', errorCode);
}
throw new PostgresException(error.message, errorCode);
}
throw error;
}
@@ -189,50 +189,6 @@ describe('WorkflowDatabaseEventTriggerListener', () => {
expect(messageQueueService.add).not.toHaveBeenCalled();
});
it('should trigger workflow when configured fields only match updated fields by case', async () => {
mockRepository.find.mockResolvedValue([
{
...mockEventListeners[0],
settings: {
eventName: databaseEventName,
fields: ['Field1'],
},
},
]);
await listener.handleObjectRecordUpdateEvent(mockPayload);
expect(messageQueueService.add).toHaveBeenCalledTimes(1);
});
it('should trigger workflow when updated fields are stale but configured field value changed', async () => {
mockRepository.find.mockResolvedValue([
{
...mockEventListeners[0],
settings: {
eventName: databaseEventName,
fields: ['field1'],
},
},
]);
await listener.handleObjectRecordUpdateEvent({
...mockPayload,
events: [
{
...mockPayload.events[0],
properties: {
updatedFields: ['differentFieldName'],
before: { field1: 'old-value' },
after: { field1: 'new-value' },
},
},
],
});
expect(messageQueueService.add).toHaveBeenCalledTimes(1);
});
it('should handle create events correctly', async () => {
const createPayload: WorkspaceEventBatch<any> = {
...mockPayload,
@@ -9,7 +9,7 @@ import {
type ObjectRecordUpsertEvent,
} from 'twenty-shared/database-events';
import { type ObjectRecord } from 'twenty-shared/types';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import { In, Raw } from 'typeorm';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
@@ -314,8 +314,10 @@ export class WorkflowDatabaseEventTriggerListener {
const databaseEventName = payload.name;
if (!workspaceId || !databaseEventName) {
this.logger.warn(
`Ignoring database event batch with missing metadata (workspaceId=${workspaceId}, eventName=${databaseEventName}, eventsCount=${payload.events.length})`,
this.logger.error(
`Missing workspaceId or eventName in payload ${JSON.stringify(
payload,
)}`,
);
return true;
@@ -356,49 +358,27 @@ export class WorkflowDatabaseEventTriggerListener {
},
});
let matchedEventCount = 0;
let filteredEventCount = 0;
let enqueuedJobCount = 0;
for (const eventListener of eventListeners) {
for (const eventPayload of payload.events) {
matchedEventCount += 1;
const shouldTriggerJob = this.shouldTriggerJob({
eventPayload,
eventListener,
action,
});
if (!shouldTriggerJob) {
filteredEventCount += 1;
continue;
if (shouldTriggerJob) {
await this.messageQueueService.add<WorkflowTriggerJobData>(
WorkflowTriggerJob.name,
{
workspaceId,
workflowId: eventListener.workflowId,
payload: eventPayload,
},
{ retryLimit: 3 },
);
}
enqueuedJobCount += 1;
await this.messageQueueService.add<WorkflowTriggerJobData>(
WorkflowTriggerJob.name,
{
workspaceId,
workflowId: eventListener.workflowId,
payload: eventPayload,
},
{ retryLimit: 3 },
);
}
}
if (
payload.events.length > 0 &&
eventListeners.length > 0 &&
enqueuedJobCount === 0
) {
this.logger.warn(
`Database event batch produced no workflow jobs (workspaceId=${workspaceId}, eventName=${databaseEventName}, action=${action}, listeners=${eventListeners.length}, events=${payload.events.length}, matched=${matchedEventCount}, filtered=${filteredEventCount})`,
);
}
}, authContext);
}
@@ -415,85 +395,28 @@ export class WorkflowDatabaseEventTriggerListener {
const settings = eventListener.settings as UpdateEventTriggerSettings;
const updateEventPayload = eventPayload as ObjectRecordUpdateEvent;
return this.shouldTriggerJobForFieldFilteredEvent({
fields: settings.fields,
eventPayload: updateEventPayload,
});
return (
!settings.fields ||
settings.fields.length === 0 ||
settings.fields.some((field) =>
updateEventPayload?.properties?.updatedFields?.includes(field),
)
);
}
if (action === DatabaseEventAction.UPSERTED) {
const settings = eventListener.settings as UpsertEventTriggerSettings;
const upsertEventPayload = eventPayload as ObjectRecordUpsertEvent;
return this.shouldTriggerJobForFieldFilteredEvent({
fields: settings.fields,
eventPayload: upsertEventPayload,
});
return (
!settings.fields ||
settings.fields.length === 0 ||
settings.fields.some((field) =>
upsertEventPayload?.properties?.updatedFields?.includes(field),
)
);
}
return true;
}
private shouldTriggerJobForFieldFilteredEvent({
fields,
eventPayload,
}: {
fields?: string[];
eventPayload: ObjectRecordUpdateEvent | ObjectRecordUpsertEvent;
}) {
if (!isNonEmptyArray(fields)) {
return true;
}
const updatedFields = eventPayload.properties.updatedFields ?? [];
if (
this.hasIntersection({
left: fields,
right: updatedFields,
})
) {
return true;
}
return fields.some((field) =>
this.didFieldValueChange({
field,
before: eventPayload.properties.before as Record<string, unknown>,
after: eventPayload.properties.after as Record<string, unknown>,
}),
);
}
private hasIntersection({
left,
right,
}: {
left: string[];
right: string[];
}) {
const normalizedRight = new Set(
right.map((field) => field.trim().toLowerCase()),
);
return left.some((field) =>
normalizedRight.has(field.trim().toLowerCase()),
);
}
private didFieldValueChange({
field,
before,
after,
}: {
field: string;
before?: Record<string, unknown>;
after?: Record<string, unknown>;
}) {
if (!before || !after) {
return false;
}
return !Object.is(before[field], after[field]);
}
}