feat(server): improve traceability of flat-entity map mutation errors (#22396)
## Context
cc @rashad
Twenty applies metadata changes optimistically to in-memory *flat entity
maps* before persisting them. The utils that mutate these maps throw
`FlatEntityMapsException` on invariant violations, which surface in
Sentry (e.g. during `InstallApplication`) as a **hardcoded, generic
message with no identifying data**:
```
GraphQLError: addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists
```
There was no way to know *which* entity collided — making triage
impossible.
## What this does (two layers)
**Layer 1 — leaf utils emit identifiers**
- `FlatEntityMapsException` gains an optional structured `context`
(`universalIdentifier` / `id` / `applicationId` / `metadataName` /
`relatedMetadataName` / `operation`), read by the Sentry driver's
existing `'context' in exception` → `setExtra` channel.
- All **9 leaf throw sites** append their in-scope identifiers to the
message **and** populate `context`.
**Propagation — context survives the re-wraps**
- On the install path the collision throws in the (unwrapped)
`compute()` step, so the raw exception + context reaches app-sync
intact.
- For the run/build-phase paths, the migration runner and
build-orchestrator re-wraps copy only `.message`; they now also
**forward `context`** so structured data survives there too.
**Layer 2 — human installation error**
- `synchronizeFromManifest` catches flat-entity failures, resolves the
offending `universalIdentifier` to a manifest **object/field label**,
and rethrows `ApplicationException(APPLICATION_INSTALLATION_FAILED)`
with a safe, human `userFriendlyMessage`.
- The leaf `userFriendlyMessage` stays `STANDARD_ERROR_MESSAGE` — the
detailed message never leaks to end users.
- `APPLICATION_INSTALLATION_FAILED` surfaces with the dedicated
`ErrorCode.APPLICATION_INSTALLATION_FAILED` GraphQL code (mirroring the
workspace-migration runner formatter), not `INTERNAL_SERVER_ERROR`.
### Result — client-facing GraphQL error envelope
```json
{
"extensions": {
"code": "APPLICATION_INSTALLATION_FAILED",
"subCode": "APPLICATION_INSTALLATION_FAILED",
"userFriendlyMessage": "We couldn't install \"Test Application\". Its Invoice could not be applied to your workspace."
},
"message": "Installing application 'Test Application' failed [object: Invoice]: addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists (universalIdentifier: ...)",
"name": "GraphQLError"
}
```
## Where the identifier shows up (not just Sentry)
The offending `universalIdentifier` reaches every consumer, not only
Sentry:
- **Sentry (server):** structured `context` extras + the enriched
message (fingerprinted by `code`, so no issue fragmentation).
- **GraphQL response `message`:** un-masked (no `useMaskedErrors`; the
error-handler hook passes `BaseGraphQLError` through as-is), so it
travels over the wire.
- **App-author SDK/CLI terminal:** `twenty-sdk` captures
`errors[0].message`; for this error `formatManifestValidationErrors`
returns `null` (no `extensions.errors`/`summary`), so the orchestrator
falls back to printing the full message, e.g.:
```
✗ Sync failed with error: Installing application 'X' failed [object:
Invoice]: … already exists (universalIdentifier: b1b2c3d4-…)
ℹ Hint: a metadata conflict was detected. Preview the plan with `yarn
twenty dev --once --dry-run`; …
```
The `already exists` / `universalidentifier` substrings also trigger
`getSyncErrorRecoveryHint`, so the author gets an actionable next step.
- **End-user (CRM UI):** only the safe rendered `userFriendlyMessage`
(no UUIDs).
## Design note
`userFriendlyMessage` behaviour of the leaf exceptions is intentionally
unchanged (guardrail). Layer 2 resolves labels for **objects and
fields** (the bulk of metadata); other manifest entity kinds fall back
to an app-name-only human message to avoid brittle manifest-walking —
easy to extend. A future first-class option would be structured
`extensions` (like `METADATA_VALIDATION_FAILED`) + a dedicated SDK
formatter; deferred since the message path already surfaces the detail
in the terminal.
## Tests
- **Unit:** existing through-mutation + runner-exception specs still
pass (they assert on exception **code**, not message). Added a spec for
the enrichment util.
- **Response-format snapshot (verified, green):**
`application-exception-filter.spec.ts` runs the exception filter and
snapshots the exact client-facing GraphQL error envelope shown above.
- **Integration:**
`failing-sync-application-flat-entity-map-conflict.integration-spec.ts`
syncs a manifest whose two objects share a `universalIdentifier`
(collision during manifest map build, before validation) and snapshots
the GraphQL error response via
`expectOneNotInternalServerErrorSnapshot`.
- ⚠️ The integration `.snap` was authored from the identical
deterministic path (verified by the filter unit snapshot) because the
integration suite couldn't be executed in the authoring sandbox. Please
regenerate/confirm with `nx test:integration:with-db-reset` (or `-u`) in
a seeded env.
## Status
Draft — opening for review.
This commit is contained in:
+25
-1
@@ -1,5 +1,6 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import {
|
||||
@@ -15,6 +16,19 @@ export const FlatEntityMapsExceptionCode = appendCommonExceptionCode({
|
||||
ENTITY_MALFORMED: 'ENTITY_MALFORMED',
|
||||
} as const);
|
||||
|
||||
export const flatEntityMapsExceptionContextSchema = z.strictObject({
|
||||
universalIdentifier: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
applicationId: z.string().optional(),
|
||||
metadataName: z.string().optional(),
|
||||
relatedMetadataName: z.string().optional(),
|
||||
operation: z.enum(['add', 'delete']).optional(),
|
||||
});
|
||||
|
||||
export type FlatEntityMapsExceptionContext = z.infer<
|
||||
typeof flatEntityMapsExceptionContextSchema
|
||||
>;
|
||||
|
||||
const getFlatEntityMapsExceptionUserFriendlyMessage = (
|
||||
code: keyof typeof FlatEntityMapsExceptionCode,
|
||||
) => {
|
||||
@@ -33,15 +47,25 @@ const getFlatEntityMapsExceptionUserFriendlyMessage = (
|
||||
export class FlatEntityMapsException extends CustomException<
|
||||
keyof typeof FlatEntityMapsExceptionCode
|
||||
> {
|
||||
context?: FlatEntityMapsExceptionContext;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: keyof typeof FlatEntityMapsExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
{
|
||||
userFriendlyMessage,
|
||||
context,
|
||||
}: {
|
||||
userFriendlyMessage?: MessageDescriptor;
|
||||
context?: FlatEntityMapsExceptionContext;
|
||||
} = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ??
|
||||
getFlatEntityMapsExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
|
||||
this.context = context;
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -128,8 +128,17 @@ export const addFlatEntityToFlatEntityAndRelatedEntityMapsThroughMutationOrThrow
|
||||
)
|
||||
) {
|
||||
throw new FlatEntityMapsException(
|
||||
`Should never occur, invalid flat entity typing. flat ${relatedMetadataName} should contain ${String(flatEntityForeignKeyAggregator)}`,
|
||||
`Should never occur, invalid flat entity typing. flat ${relatedMetadataName} should contain ${String(flatEntityForeignKeyAggregator)} (metadataName: ${metadataName}, universalIdentifier: ${flatEntity.universalIdentifier})`,
|
||||
FlatEntityMapsExceptionCode.ENTITY_MALFORMED,
|
||||
{
|
||||
context: {
|
||||
id: flatEntity.id,
|
||||
universalIdentifier: flatEntity.universalIdentifier,
|
||||
metadataName,
|
||||
relatedMetadataName,
|
||||
operation: 'add',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -24,8 +24,16 @@ export const addFlatEntityToFlatEntityMapsOrThrow = <
|
||||
)
|
||||
) {
|
||||
throw new FlatEntityMapsException(
|
||||
'addFlatEntityToFlatEntityMapsOrThrow: flat entity to add already exists',
|
||||
`addFlatEntityToFlatEntityMapsOrThrow: flat entity to add already exists (universalIdentifier: ${flatEntity.universalIdentifier})`,
|
||||
FlatEntityMapsExceptionCode.ENTITY_ALREADY_EXISTS,
|
||||
{
|
||||
context: {
|
||||
universalIdentifier: flatEntity.universalIdentifier,
|
||||
id: flatEntity.id,
|
||||
applicationId: flatEntity.applicationId,
|
||||
operation: 'add',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -110,8 +110,17 @@ export const deleteFlatEntityFromFlatEntityAndRelatedEntityMapsThroughMutationOr
|
||||
)
|
||||
) {
|
||||
throw new FlatEntityMapsException(
|
||||
`Should never occur, invalid flat entity typing. flat ${relatedMetadataName} should contain ${flatEntityForeignKeyAggregator}`,
|
||||
`Should never occur, invalid flat entity typing. flat ${relatedMetadataName} should contain ${flatEntityForeignKeyAggregator} (metadataName: ${metadataName}, universalIdentifier: ${flatEntity.universalIdentifier})`,
|
||||
FlatEntityMapsExceptionCode.ENTITY_MALFORMED,
|
||||
{
|
||||
context: {
|
||||
id: flatEntity.id,
|
||||
universalIdentifier: flatEntity.universalIdentifier,
|
||||
metadataName,
|
||||
relatedMetadataName,
|
||||
operation: 'delete',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
FlatEntityMapsException,
|
||||
flatEntityMapsExceptionContextSchema,
|
||||
type FlatEntityMapsExceptionContext,
|
||||
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
|
||||
|
||||
const hasFlatEntityIdentifier = (
|
||||
context: FlatEntityMapsExceptionContext,
|
||||
): boolean => isDefined(context.universalIdentifier) || isDefined(context.id);
|
||||
|
||||
export const getFlatEntityMapsExceptionContext = (
|
||||
error: unknown,
|
||||
): FlatEntityMapsExceptionContext | undefined => {
|
||||
if (error instanceof FlatEntityMapsException) {
|
||||
return isDefined(error.context) && hasFlatEntityIdentifier(error.context)
|
||||
? error.context
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (isDefined(error) && typeof error === 'object' && 'context' in error) {
|
||||
const parsedContext = flatEntityMapsExceptionContextSchema.safeParse(
|
||||
(error as { context?: unknown }).context,
|
||||
);
|
||||
|
||||
return parsedContext.success && hasFlatEntityIdentifier(parsedContext.data)
|
||||
? parsedContext.data
|
||||
: undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
Reference in New Issue
Block a user