Include root cause errors in workspace migration runner exception message (#23416)

closes https://github.com/twentyhq/core-team-issues/issues/2733

```diff
- "message": "Migration action 'create' for 'index' (universalIdentifier: 67c6811e-...) failed"
+ "message": "Migration action 'create' for 'index' (universalIdentifier: 67c6811e-...) failed: [workspaceSchema] could not create unique index "IDX_UNIQUE_85951922a2..." (pg code: 23505, detail: Key ("externalId")=(DUPLICATED-VALUE) is duplicated.)"
```

## Problem

When a workspace migration action fails, the `EXECUTION_FAILED`
exception message only states which action failed:

```
Migration action 'create' for 'index' (universalIdentifier: 9e20a0f6-7a18-51c5-a422-5dc4dbd1d972) failed
```

The underlying errors (`metadata`, `workspaceSchema`,
`actionTranspilation`) are attached to the exception instance but
dropped by most surfaces: the SDK CLI only prints `errors[0].message`,
server logs only log `error.message`, and the REST/GraphQL paths lose
the postgres driver details. Debugging a failed app install (like the
stale index universalIdentifier case in the issue) requires guessing.

## Change

- New `formatWorkspaceMigrationRunnerExecutionErrors` util that builds a
compact one-line summary of the underlying execution errors, including
the postgres error code and `detail` for `QueryFailedError`, capped at
1500 chars.
- The `EXECUTION_FAILED` exception message now appends that summary:

```
Migration action 'create' for 'index' (universalIdentifier: 9e20a0f6-...) failed: [workspaceSchema] relation "IDX_..." already exists (pg code: 42P07)
```

Since every surface (CLI, server logs, Sentry, REST, GraphQL) shows
`error.message`, the root cause now propagates everywhere without
touching those surfaces.

- Since actions run inside a single transaction, when one branch fails
with `25P02 current transaction is aborted` (collateral of the other
branch's statement aborting the transaction), the summary keeps only the
real root cause. A lone 25P02 error is still shown.

## Notes

- The SDK's `getSyncErrorRecoveryHint` matching (`/migration action .*
failed/`) still works with the suffixed format.
- Commit-time failures from `DEFERRABLE INITIALLY DEFERRED` FK
constraints still bypass this path (wrapped as `INTERNAL_SERVER_ERROR`
with no action attribution) and are left as a follow-up.

## Tests

- New spec for the formatter util (labels, pg code/detail, 25P02
demotion, truncation).
- Extended `workspace-migration-runner.exception.spec.ts` with a
root-cause message assertion.
- Updated `format-upgrade-error-for-storage` snapshots (first line now
carries the enriched message).

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Mu8t74X14oYVkLrFBZHs2o)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23416?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Paul Rastoin
2026-07-29 11:15:30 +02:00
committed by GitHub
parent 6d60fac92f
commit 25d20731ac
7 changed files with 382 additions and 3 deletions
@@ -31,7 +31,7 @@ Report: {
`;
exports[`formatUpgradeErrorForStorage should format a WorkspaceMigrationRunnerException with EXECUTION_FAILED 1`] = `
"[WorkspaceMigrationRunnerException] Migration action 'create' for 'objectMetadata' (universalIdentifier: test-object-uid) failed
"[WorkspaceMigrationRunnerException] Migration action 'create' for 'objectMetadata' (universalIdentifier: test-object-uid) failed: [metadata] column "label" cannot be null; [workspaceSchema] table already exists
Code: EXECUTION_FAILED
Action: create on objectMetadata
Metadata error:
@@ -54,7 +54,7 @@ exports[`formatUpgradeErrorForStorage should format a string value 1`] = `"raw s
exports[`formatUpgradeErrorForStorage should format an undefined value 1`] = `"undefined"`;
exports[`formatUpgradeErrorForStorage should surface driver details of a QueryFailedError nested in an EXECUTION_FAILED 1`] = `
"[WorkspaceMigrationRunnerException] Migration action 'create' for 'pageLayoutWidget' (universalIdentifier: f473b435-e2d4-4928-8d90-1db0094389f7) failed
"[WorkspaceMigrationRunnerException] Migration action 'create' for 'pageLayoutWidget' (universalIdentifier: f473b435-e2d4-4928-8d90-1db0094389f7) failed: [metadata] duplicate key value violates unique constraint "IDX_PAGE_LAYOUT_WIDGET_UNIVERSAL_ID" (pg code: 23505, detail: Key (universalIdentifier)=(f473b435-e2d4-4928-8d90-1db0094389f7) already exists.)
Code: EXECUTION_FAILED
Action: create on pageLayoutWidget
Metadata error:
@@ -42,4 +42,26 @@ describe('WorkspaceMigrationRunnerException', () => {
"Migration action 'delete' for 'pageLayout' (universalIdentifier: uid-page-layout) failed",
);
});
it('includes the underlying execution errors in the message', () => {
const action = {
type: 'create',
metadataName: 'index',
flatEntity: {
universalIdentifier: '9e20a0f6-7a18-51c5-a422-5dc4dbd1d972',
},
} as unknown as AllUniversalWorkspaceMigrationAction;
const exception = new WorkspaceMigrationRunnerException({
code: WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED,
action,
errors: {
workspaceSchema: new Error('relation "IDX_abc" already exists'),
},
});
expect(exception.message).toBe(
"Migration action 'create' for 'index' (universalIdentifier: 9e20a0f6-7a18-51c5-a422-5dc4dbd1d972) failed: [workspaceSchema] relation \"IDX_abc\" already exists",
);
});
});
@@ -4,6 +4,7 @@ import { assertUnreachable, CustomError } from 'twenty-shared/utils';
import { type FlatEntityMapsExceptionContext } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { type AllUniversalWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import { formatWorkspaceMigrationRunnerExecutionErrors } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/format-workspace-migration-runner-execution-errors.util';
export const WorkspaceMigrationRunnerExceptionCode = {
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
@@ -88,9 +89,14 @@ export class WorkspaceMigrationRunnerException extends CustomError {
args.action,
);
const identifierClause = ` (universalIdentifier: ${universalIdentifier})`;
const executionErrorsSummary =
formatWorkspaceMigrationRunnerExecutionErrors(args.errors);
const causeClause = executionErrorsSummary
? `: ${executionErrorsSummary}`
: '';
super(
`Migration action '${args.action.type}' for '${args.action.metadataName}'${identifierClause} failed`,
`Migration action '${args.action.type}' for '${args.action.metadataName}'${identifierClause} failed${causeClause}`,
);
this.code = args.code;
@@ -0,0 +1,114 @@
import { QueryFailedError } from 'typeorm';
import { formatWorkspaceMigrationRunnerExecutionErrors } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/format-workspace-migration-runner-execution-errors.util';
const buildQueryFailedError = ({
message,
code,
detail,
}: {
message: string;
code?: string;
detail?: string;
}): QueryFailedError => {
const driverError = new Error(message);
Object.assign(driverError, { code, detail });
return new QueryFailedError('INSERT INTO "core"."index"', [], driverError);
};
describe('formatWorkspaceMigrationRunnerExecutionErrors', () => {
it('returns undefined when no error is set', () => {
expect(formatWorkspaceMigrationRunnerExecutionErrors({})).toBeUndefined();
});
it('formats a plain error with its origin label', () => {
expect(
formatWorkspaceMigrationRunnerExecutionErrors({
metadata: new Error('column "label" cannot be null'),
}),
).toBe('[metadata] column "label" cannot be null');
});
it('includes postgres code and detail of a QueryFailedError', () => {
expect(
formatWorkspaceMigrationRunnerExecutionErrors({
workspaceSchema: buildQueryFailedError({
message: 'relation "IDX_abc" already exists',
code: '42P07',
}),
}),
).toBe(
'[workspaceSchema] relation "IDX_abc" already exists (pg code: 42P07)',
);
expect(
formatWorkspaceMigrationRunnerExecutionErrors({
metadata: buildQueryFailedError({
message: 'duplicate key value violates unique constraint "UQ_name"',
code: '23505',
detail: 'Key (name)=(foo) already exists.',
}),
}),
).toBe(
'[metadata] duplicate key value violates unique constraint "UQ_name" (pg code: 23505, detail: Key (name)=(foo) already exists.)',
);
});
it('joins multiple errors', () => {
expect(
formatWorkspaceMigrationRunnerExecutionErrors({
metadata: new Error('column "label" cannot be null'),
workspaceSchema: new Error('table already exists'),
}),
).toBe(
'[metadata] column "label" cannot be null; [workspaceSchema] table already exists',
);
});
it('hides a 25P02 aborted-transaction error when a root cause is available', () => {
expect(
formatWorkspaceMigrationRunnerExecutionErrors({
metadata: buildQueryFailedError({
message: 'current transaction is aborted',
code: '25P02',
}),
workspaceSchema: buildQueryFailedError({
message: 'relation "IDX_abc" already exists',
code: '42P07',
}),
}),
).toBe(
'[workspaceSchema] relation "IDX_abc" already exists (pg code: 42P07)',
);
});
it('keeps a 25P02 error when it is the only one', () => {
expect(
formatWorkspaceMigrationRunnerExecutionErrors({
metadata: buildQueryFailedError({
message: 'current transaction is aborted',
code: '25P02',
}),
}),
).toBe('[metadata] current transaction is aborted (pg code: 25P02)');
});
it('truncates an oversized summary to the cap, marker included', () => {
const summary = formatWorkspaceMigrationRunnerExecutionErrors({
metadata: new Error('x'.repeat(5_000)),
});
expect(summary).toHaveLength(1_500);
expect(summary?.endsWith(' [truncated]')).toBe(true);
});
it('stringifies a non-Error rejection value', () => {
expect(
formatWorkspaceMigrationRunnerExecutionErrors({
metadata: 'plain string rejection' as unknown as Error,
}),
).toBe('[metadata] plain string rejection');
});
});
@@ -0,0 +1,81 @@
import { isDefined } from 'twenty-shared/utils';
import { QueryFailedError } from 'typeorm';
import { type WorkspaceMigrationRunnerExecutionErrors } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
const MAX_EXECUTION_ERRORS_SUMMARY_LENGTH = 1_500;
const TRUNCATION_MARKER = ' [truncated]';
const POSTGRES_TRANSACTION_ABORTED_CODE = '25P02';
// Ordered by execution phase: transpilation runs before the metadata and
// workspace schema writes.
const EXECUTION_ERROR_LABELS = [
'actionTranspilation',
'metadata',
'workspaceSchema',
] as const satisfies readonly (keyof WorkspaceMigrationRunnerExecutionErrors)[];
type ExecutionErrorEntry = {
label: (typeof EXECUTION_ERROR_LABELS)[number];
error: Error;
};
const getPostgresDriverError = (
error: Error,
): { code?: string; detail?: string } | undefined =>
error instanceof QueryFailedError
? (error.driverError as { code?: string; detail?: string } | undefined)
: undefined;
const isTransactionAbortedError = (error: Error): boolean =>
getPostgresDriverError(error)?.code === POSTGRES_TRANSACTION_ABORTED_CODE;
const formatSingleExecutionError = (error: Error): string => {
// Rejection reasons are typed as Error but nothing guarantees it at runtime.
const baseMessage = error instanceof Error ? error.message : String(error);
const driverError = getPostgresDriverError(error);
const driverErrorParts = [
isDefined(driverError?.code) ? `pg code: ${driverError.code}` : null,
isDefined(driverError?.detail) ? `detail: ${driverError.detail}` : null,
].filter(isDefined);
return driverErrorParts.length > 0
? `${baseMessage} (${driverErrorParts.join(', ')})`
: baseMessage;
};
export const formatWorkspaceMigrationRunnerExecutionErrors = (
errors: WorkspaceMigrationRunnerExecutionErrors,
): string | undefined => {
const entries = EXECUTION_ERROR_LABELS.map((label) => ({
label,
error: errors[label],
})).filter((entry): entry is ExecutionErrorEntry => isDefined(entry.error));
if (entries.length === 0) {
return undefined;
}
// A 25P02 failure is collateral noise from the statement that aborted the
// transaction; hide it whenever a root-cause error is available.
const rootCauseEntries = entries.filter(
(entry) => !isTransactionAbortedError(entry.error),
);
const relevantEntries =
rootCauseEntries.length > 0 ? rootCauseEntries : entries;
const summary = relevantEntries
.map(
(entry) => `[${entry.label}] ${formatSingleExecutionError(entry.error)}`,
)
.join('; ');
return summary.length <= MAX_EXECUTION_ERRORS_SUMMARY_LENGTH
? summary
: `${summary.slice(
0,
MAX_EXECUTION_ERRORS_SUMMARY_LENGTH - TRUNCATION_MARKER.length,
)}${TRUNCATION_MARKER}`;
};
@@ -0,0 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sync application should surface the root cause when a unique index cannot be created over duplicated data should fail with the underlying postgres error in the migration runner error message 1`] = `
{
"eventId": Any<String>,
"extensions": {
"action": {
"flatEntity": {
"applicationUniversalIdentifier": Any<String>,
"createdAt": Any<String>,
"indexType": "BTREE",
"indexWhereClause": null,
"isCustom": true,
"isSystemSideEffect": true,
"isUnique": true,
"name": "IDX_UNIQUE_85951922a2bfc9f7811d8c5cdb8",
"objectMetadataUniversalIdentifier": Any<String>,
"universalFlatIndexFieldMetadatas": [
{
"createdAt": Any<String>,
"fieldMetadataUniversalIdentifier": Any<String>,
"indexMetadataUniversalIdentifier": Any<String>,
"order": 0,
"subFieldName": null,
"updatedAt": Any<String>,
},
],
"universalIdentifier": Any<String>,
"updatedAt": Any<String>,
},
"metadataName": "index",
"type": "create",
},
"code": "APPLICATION_INSTALLATION_FAILED",
"errors": {
"metadata": {
"code": "25P02",
"message": "current transaction is aborted, commands ignored until end of transaction block",
},
"workspaceSchema": {
"code": "23505",
"message": "could not create unique index "IDX_UNIQUE_85951922a2bfc9f7811d8c5cdb8"",
},
},
"exceptionEventId": Any<String>,
"userFriendlyMessage": "Migration execution failed.",
},
"message": "Migration action 'create' for 'index' (universalIdentifier: 67c6811e-9c4e-578a-ad3a-ea4ef152c0fd) failed: [workspaceSchema] could not create unique index "IDX_UNIQUE_85951922a2bfc9f7811d8c5cdb8" (pg code: 23505, detail: Key ("externalId")=(DUPLICATED-VALUE) is duplicated.)",
"name": "GraphQLError",
}
`;
@@ -0,0 +1,105 @@
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { type Manifest } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
// Fixed identifiers keep the deterministic index name (hashed from the
// application universal identifier) stable across runs for the snapshot.
const TEST_APP_ID = '3d05deeb-e0b6-4b7a-abe9-cea3b81dc1a1';
const TEST_ROLE_ID = 'e37f849e-04a1-4fbf-b463-90b3131de79f';
const TEST_FIELD_ID = 'c2a24a3a-3960-4c4b-bd91-c22e1e1e2f31';
const TEST_OBJECT = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
universalIdentifier: '0e1f18ce-6273-4e83-bc9e-6cdeb2b57d81',
nameSingular: 'duplicatedDataObject',
namePlural: 'duplicatedDataObjects',
labelSingular: 'Duplicated Data Object',
labelPlural: 'Duplicated Data Objects',
description: 'Object used to test unique index creation over duplicated data',
});
const buildManifest = ({ isUnique }: { isUnique: boolean }): Manifest =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [TEST_OBJECT],
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'externalId',
label: 'External ID',
description: 'External identifier',
icon: 'IconId',
isUnique,
isNullable: true,
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
},
});
const createRecordWithExternalId = async (externalId: string) => {
const response = await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: TEST_OBJECT.nameSingular,
gqlFields: `
id
externalId
`,
data: { id: uuidv4(), externalId },
}),
);
return response.body.data?.[`create${capitalize(TEST_OBJECT.nameSingular)}`];
};
describe('Sync application should surface the root cause when a unique index cannot be created over duplicated data', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Unique Index Duplicate Data App',
description: 'App for testing unique index creation over duplicated data',
sourcePath: 'test-unique-index-duplicate-data',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should fail with the underlying postgres error in the migration runner error message', async () => {
await syncApplication({
manifest: buildManifest({ isUnique: false }),
expectToFail: false,
});
const firstRecord = await createRecordWithExternalId('DUPLICATED-VALUE');
const secondRecord = await createRecordWithExternalId('DUPLICATED-VALUE');
expect(firstRecord?.externalId).toBe('DUPLICATED-VALUE');
expect(secondRecord?.externalId).toBe('DUPLICATED-VALUE');
// Turning the field unique generates a create action for its backing
// unique index; the index cannot be created over duplicated data so the
// migration runner fails at workspace schema level with a 23505.
const { errors } = await syncApplication({
manifest: buildManifest({ isUnique: true }),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
});