fix(server): surface nested QueryFailedError detail in upgrade error formatting (#21948)
## 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.
This commit is contained in:
+16
-3
@@ -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)"
|
||||
`;
|
||||
|
||||
+34
@@ -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: [
|
||||
|
||||
+38
-10
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user