From 003ad62f66a369a9dff629a6adb5672ee2d49219 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Mon, 22 Jun 2026 13:32:21 +0200 Subject: [PATCH] fix(server): surface nested QueryFailedError detail in upgrade error formatting (#21948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context While upgrading, a workspace migration failed with: ``` [Runner] [install-perf] migration failed after 20 action(s): Migration action 'create' for 'pageLayoutWidget' (universalIdentifier: f473b435-...) failed ``` The action names the failure but not *why* — unique violation? FK? which key/row? The real cause is captured but was getting flattened away before it reached anyone reading it. ## Root cause of the bad diagnostics When a migration action fails, the action handler captures the real error (typically a TypeORM `QueryFailedError` from `repository.insert`) into `WorkspaceMigrationRunnerException.errors.{metadata,workspaceSchema,actionTranspilation}` and re-throws it intact. Caller-side formatters surface it — but `formatUpgradeErrorForStorage` (read by the `upgrade-status` command) flattened a nested `QueryFailedError` to just its `.message`, dropping the PostgreSQL `code`, `detail` (the exact failing key/value) and `query`. ## What this PR does `formatUpgradeErrorForStorage` now **recurses** into nested causes, so a wrapped `QueryFailedError` keeps its full driver detail. Surfacing/logging stays a caller concern (the runner already produces and re-throws the structured exception) — this PR only fixes the formatter that was dropping detail. Added a unit test for the `create pageLayoutWidget` unique-violation case. ### Stored upgrade error — before ``` Metadata error: duplicate key value violates unique constraint "IDX_..." ``` ### After ``` Metadata error: [QueryFailedError] duplicate key value violates unique constraint "IDX_..." PostgreSQL code: 23505 Detail: Key (universalIdentifier)=(f473b435-...) already exists. Query: INSERT INTO "core"."pageLayoutWidget" VALUES ($1) ``` ## Scope Diagnostics only — it surfaces the cause, it does not change migration behavior. The underlying `create pageLayoutWidget` failure (likely a unique/FK violation when upgrading existing workspaces, downstream of #21673) is a separate follow-up once the exact cause is captured. ## Note / possible follow-up `workspaceMigrationRunnerExceptionFormatter` (the GraphQL/app-install surfacing path) has the same flattening issue — it reads `error.errors.metadata.code`, but for a `QueryFailedError` the pg code lives on `driverError.code`, so it falls back to `INTERNAL_SERVER_ERROR` and loses `detail`. Left out of scope here; happy to fix in a follow-up if wanted. ## Tests - New unit test for a `QueryFailedError` nested in an `EXECUTION_FAILED` exception; snapshots updated. - `oxlint`, `oxfmt --check`, `nx typecheck twenty-server`, and the affected jest suites pass. --- ...pgrade-error-for-storage.util.spec.ts.snap | 19 ++++++-- ...mat-upgrade-error-for-storage.util.spec.ts | 34 +++++++++++++ .../format-upgrade-error-for-storage.util.ts | 48 +++++++++++++++---- 3 files changed, 88 insertions(+), 13 deletions(-) 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 d08776ee4f..17aba7bc51 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 @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`formatUpgradeErrorForStorage should format a CustomError with code 1`] = ` "[CustomError] Workspace not found @@ -34,8 +34,10 @@ exports[`formatUpgradeErrorForStorage should format a WorkspaceMigrationRunnerEx "[WorkspaceMigrationRunnerException] Migration action 'create' for 'objectMetadata' (universalIdentifier: test-object-uid) failed Code: EXECUTION_FAILED Action: create on objectMetadata -Metadata error: column "label" cannot be null -Schema error: table already exists" +Metadata error: + [Error] column "label" cannot be null +Schema error: + [Error] table already exists" `; exports[`formatUpgradeErrorForStorage should format a WorkspaceMigrationRunnerException with INTERNAL_SERVER_ERROR 1`] = ` @@ -50,3 +52,14 @@ exports[`formatUpgradeErrorForStorage should format a number value 1`] = `"42"`; exports[`formatUpgradeErrorForStorage should format a string value 1`] = `"raw string error"`; 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 +Code: EXECUTION_FAILED +Action: create on pageLayoutWidget +Metadata error: + [QueryFailedError] duplicate key value violates unique constraint "IDX_PAGE_LAYOUT_WIDGET_UNIVERSAL_ID" + PostgreSQL code: 23505 + Detail: Key (universalIdentifier)=(f473b435-e2d4-4928-8d90-1db0094389f7) already exists. + Query: INSERT INTO "core"."pageLayoutWidget" VALUES ($1)" +`; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/format-upgrade-error-for-storage.util.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/format-upgrade-error-for-storage.util.spec.ts index da3969fafe..dfd1ce92d2 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/format-upgrade-error-for-storage.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/format-upgrade-error-for-storage.util.spec.ts @@ -72,6 +72,40 @@ describe('formatUpgradeErrorForStorage', () => { expect(stripStack(formatUpgradeErrorForStorage(error))).toMatchSnapshot(); }); + it('should surface driver details of a QueryFailedError nested in an EXECUTION_FAILED', () => { + const driverError = new Error( + 'duplicate key value violates unique constraint "IDX_PAGE_LAYOUT_WIDGET_UNIVERSAL_ID"', + ); + + Object.assign(driverError, { + code: '23505', + detail: + 'Key (universalIdentifier)=(f473b435-e2d4-4928-8d90-1db0094389f7) already exists.', + }); + + const action = { + type: 'create', + metadataName: 'pageLayoutWidget', + flatEntity: { + universalIdentifier: 'f473b435-e2d4-4928-8d90-1db0094389f7', + }, + } as unknown as AllUniversalWorkspaceMigrationAction; + + const error = new WorkspaceMigrationRunnerException({ + action, + errors: { + metadata: new QueryFailedError( + 'INSERT INTO "core"."pageLayoutWidget" VALUES ($1)', + [], + driverError, + ), + }, + code: WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED, + }); + + expect(stripStack(formatUpgradeErrorForStorage(error))).toMatchSnapshot(); + }); + it('should format a WorkspaceMigrationBuilderException', () => { const report = { objectMetadata: [ diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/format-upgrade-error-for-storage.util.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/format-upgrade-error-for-storage.util.ts index 8c714f0fe1..364d7540ca 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/utils/format-upgrade-error-for-storage.util.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/format-upgrade-error-for-storage.util.ts @@ -1,4 +1,4 @@ -import { CustomError } from 'twenty-shared/utils'; +import { CustomError, isDefined } from 'twenty-shared/utils'; import { QueryFailedError } from 'typeorm'; import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; @@ -20,6 +20,31 @@ const joinParts = (parts: (string | null)[]): string => { return joined.slice(0, MAX_ERROR_MESSAGE_LENGTH) + '\n[truncated]'; }; +const indent = (text: string): string => + text + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + +// Recurse so a nested QueryFailedError keeps its pg code/detail, not just its message. +const buildNestedErrorParts = ({ + label, + error, +}: { + label: string; + error: unknown; +}): (string | null)[] => { + if (!isDefined(error)) { + return []; + } + + const nestedParts = buildErrorParts(error).filter((part): part is string => + Boolean(part), + ); + + return [`${label}:`, ...nestedParts.map(indent)]; +}; + const buildErrorParts = (error: unknown): (string | null)[] => { if (error instanceof QueryFailedError) { const driverError = error.driverError; @@ -40,15 +65,18 @@ const buildErrorParts = (error: unknown): (string | null)[] => { error.action ? `Action: ${error.action.type} on ${error.action.metadataName}` : null, - error.errors?.metadata - ? `Metadata error: ${error.errors.metadata.message}` - : null, - error.errors?.workspaceSchema - ? `Schema error: ${error.errors.workspaceSchema.message}` - : null, - error.errors?.actionTranspilation - ? `Transpilation error: ${error.errors.actionTranspilation.message}` - : null, + ...buildNestedErrorParts({ + label: 'Metadata error', + error: error.errors?.metadata, + }), + ...buildNestedErrorParts({ + label: 'Schema error', + error: error.errors?.workspaceSchema, + }), + ...buildNestedErrorParts({ + label: 'Transpilation error', + error: error.errors?.actionTranspilation, + }), formatStack(error.stack), ]; }