Files
twenty/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-unique-index-duplicate-data.integration-spec.ts
T
Paul Rastoin 25d20731ac 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. -->
2026-07-29 09:15:30 +00:00

106 lines
4.3 KiB
TypeScript

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);
});