From 25d20731ac77c4c18b3884a550b4a85696e66cef Mon Sep 17 00:00:00 2001
From: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Date: Wed, 29 Jul 2026 11:15:30 +0200
Subject: [PATCH] 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)_
---
...pgrade-error-for-storage.util.spec.ts.snap | 4 +-
...rkspace-migration-runner.exception.spec.ts | 22 ++++
.../workspace-migration-runner.exception.ts | 8 +-
...ation-runner-execution-errors.util.spec.ts | 114 ++++++++++++++++++
...-migration-runner-execution-errors.util.ts | 81 +++++++++++++
...ex-duplicate-data.integration-spec.ts.snap | 51 ++++++++
...e-index-duplicate-data.integration-spec.ts | 105 ++++++++++++++++
7 files changed, 382 insertions(+), 3 deletions(-)
create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/__tests__/format-workspace-migration-runner-execution-errors.util.spec.ts
create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/format-workspace-migration-runner-execution-errors.util.ts
create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-unique-index-duplicate-data.integration-spec.ts.snap
create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-unique-index-duplicate-data.integration-spec.ts
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/__snapshots__/format-upgrade-error-for-storage.util.spec.ts.snap b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/__snapshots__/format-upgrade-error-for-storage.util.spec.ts.snap
index 17aba7bc51..05a2541aa0 100644
--- a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/__snapshots__/format-upgrade-error-for-storage.util.spec.ts.snap
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/__snapshots__/format-upgrade-error-for-storage.util.spec.ts.snap
@@ -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:
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/__tests__/workspace-migration-runner.exception.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/__tests__/workspace-migration-runner.exception.spec.ts
index 9f6b7cb491..e0b7eace84 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/__tests__/workspace-migration-runner.exception.spec.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/__tests__/workspace-migration-runner.exception.spec.ts
@@ -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",
+ );
+ });
});
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception.ts
index 8613f1e9c2..b2cf9a3d79 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception.ts
@@ -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;
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/__tests__/format-workspace-migration-runner-execution-errors.util.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/__tests__/format-workspace-migration-runner-execution-errors.util.spec.ts
new file mode 100644
index 0000000000..002db1f29d
--- /dev/null
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/__tests__/format-workspace-migration-runner-execution-errors.util.spec.ts
@@ -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');
+ });
+});
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/format-workspace-migration-runner-execution-errors.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/format-workspace-migration-runner-execution-errors.util.ts
new file mode 100644
index 0000000000..d09d72186f
--- /dev/null
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/format-workspace-migration-runner-execution-errors.util.ts
@@ -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}`;
+};
diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-unique-index-duplicate-data.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-unique-index-duplicate-data.integration-spec.ts.snap
new file mode 100644
index 0000000000..e572a9cd54
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-unique-index-duplicate-data.integration-spec.ts.snap
@@ -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,
+ "extensions": {
+ "action": {
+ "flatEntity": {
+ "applicationUniversalIdentifier": Any,
+ "createdAt": Any,
+ "indexType": "BTREE",
+ "indexWhereClause": null,
+ "isCustom": true,
+ "isSystemSideEffect": true,
+ "isUnique": true,
+ "name": "IDX_UNIQUE_85951922a2bfc9f7811d8c5cdb8",
+ "objectMetadataUniversalIdentifier": Any,
+ "universalFlatIndexFieldMetadatas": [
+ {
+ "createdAt": Any,
+ "fieldMetadataUniversalIdentifier": Any,
+ "indexMetadataUniversalIdentifier": Any,
+ "order": 0,
+ "subFieldName": null,
+ "updatedAt": Any,
+ },
+ ],
+ "universalIdentifier": Any,
+ "updatedAt": Any,
+ },
+ "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,
+ "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",
+}
+`;
diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-unique-index-duplicate-data.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-unique-index-duplicate-data.integration-spec.ts
new file mode 100644
index 0000000000..a5583d9485
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-unique-index-duplicate-data.integration-spec.ts
@@ -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);
+});