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:
Paul Rastoin
2026-07-02 14:39:48 +02:00
committed by GitHub
parent 632114e5e2
commit 47689e676b
26 changed files with 956 additions and 19 deletions
@@ -20,6 +20,7 @@ import { FlatEntityToCreateDeleteUpdate } from 'src/engine/metadata-modules/flat
import { MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
import { MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getFlatEntityMapsExceptionContext } from 'src/engine/metadata-modules/flat-entity/utils/get-flat-entity-maps-exception-context.util';
import { getMetadataRelatedMetadataNamesForValidation } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names-for-validation.util';
import { getSubFlatEntityMapsByApplicationIdsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util';
import { MetadataEventEmitter } from 'src/engine/subscriptions/metadata-event/metadata-event-emitter';
@@ -380,6 +381,7 @@ export class WorkspaceMigrationValidateBuildAndRunService {
throw new WorkspaceMigrationV2Exception(
error.message,
WorkspaceMigrationV2ExceptionCode.BUILDER_INTERNAL_SERVER_ERROR,
{ context: getFlatEntityMapsExceptionContext(error) },
);
});
const buildMs = performance.now() - buildStart;
@@ -124,8 +124,16 @@ export const addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThro
)
) {
throw new FlatEntityMapsException(
`Should never occur, invalid flat entity typing. flat ${relatedMetadataName} should contain ${universalFlatEntityForeignKeyAggregator}`,
`Should never occur, invalid flat entity typing. flat ${relatedMetadataName} should contain ${universalFlatEntityForeignKeyAggregator} (metadataName: ${metadataName}, universalIdentifier: ${universalFlatEntity.universalIdentifier})`,
FlatEntityMapsExceptionCode.ENTITY_MALFORMED,
{
context: {
universalIdentifier: universalFlatEntity.universalIdentifier,
metadataName,
relatedMetadataName,
operation: 'add',
},
},
);
}
@@ -28,8 +28,14 @@ export const addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThr
)
) {
throw new FlatEntityMapsException(
'addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists',
`addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists (universalIdentifier: ${universalFlatEntity.universalIdentifier})`,
FlatEntityMapsExceptionCode.ENTITY_ALREADY_EXISTS,
{
context: {
universalIdentifier: universalFlatEntity.universalIdentifier,
operation: 'add',
},
},
);
}
@@ -116,8 +116,16 @@ export const deleteUniversalFlatEntityFromUniversalFlatEntityAndRelatedEntityMap
)
) {
throw new FlatEntityMapsException(
`Should never occur, invalid flat entity typing. flat ${relatedMetadataName} should contain ${universalFlatEntityForeignKeyAggregator}`,
`Should never occur, invalid flat entity typing. flat ${relatedMetadataName} should contain ${universalFlatEntityForeignKeyAggregator} (metadataName: ${metadataName}, universalIdentifier: ${universalFlatEntity.universalIdentifier})`,
FlatEntityMapsExceptionCode.ENTITY_MALFORMED,
{
context: {
universalIdentifier: universalFlatEntity.universalIdentifier,
metadataName,
relatedMetadataName,
operation: 'delete',
},
},
);
}
@@ -27,8 +27,14 @@ export const deleteUniversalFlatEntityFromUniversalFlatEntityMapsThroughMutation
if (!isDefined(entityToDelete)) {
throw new FlatEntityMapsException(
'deleteUniversalFlatEntityFromUniversalFlatEntityMapsThroughMutationOrThrow: entity to delete not found',
`deleteUniversalFlatEntityFromUniversalFlatEntityMapsThroughMutationOrThrow: entity to delete not found (universalIdentifierToDelete: ${universalIdentifierToDelete})`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
{
context: {
universalIdentifier: universalIdentifierToDelete,
operation: 'delete',
},
},
);
}
@@ -28,8 +28,16 @@ export const addFlatEntityToFlatEntityMapsThroughMutationOrThrow = <
)
) {
throw new FlatEntityMapsException(
'addFlatEntityToFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists',
`addFlatEntityToFlatEntityMapsThroughMutationOrThrow: 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',
},
},
);
}
@@ -25,8 +25,14 @@ export const deleteFlatEntityFromFlatEntityMapsThroughMutationOrThrow = <
if (!isDefined(universalIdentifierToDelete)) {
throw new FlatEntityMapsException(
'deleteFlatEntityFromFlatEntityMapsThroughMutationOrThrow: entity to delete not found',
`deleteFlatEntityFromFlatEntityMapsThroughMutationOrThrow: entity to delete not found (entityToDeleteId: ${entityToDeleteId})`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
{
context: {
id: entityToDeleteId,
operation: 'delete',
},
},
);
}
@@ -2,6 +2,7 @@ import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
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';
export const WorkspaceMigrationRunnerExceptionCode = {
@@ -65,6 +66,7 @@ type WorkspaceMigrationRunnerExceptionConstructorArgs =
message: string;
code: (typeof WorkspaceMigrationRunnerExceptionCodeOtherCode)[keyof typeof WorkspaceMigrationRunnerExceptionCodeOtherCode];
userFriendlyMessage?: MessageDescriptor;
context?: FlatEntityMapsExceptionContext;
}
| {
action: AllUniversalWorkspaceMigrationAction;
@@ -78,6 +80,7 @@ export class WorkspaceMigrationRunnerException extends CustomError {
userFriendlyMessage: MessageDescriptor;
action?: AllUniversalWorkspaceMigrationAction;
errors?: WorkspaceMigrationRunnerExecutionErrors;
context?: FlatEntityMapsExceptionContext;
constructor(args: WorkspaceMigrationRunnerExceptionConstructorArgs) {
if (args.code === WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED) {
@@ -97,6 +100,7 @@ export class WorkspaceMigrationRunnerException extends CustomError {
super(args.message);
this.code = args.code;
this.context = args.context;
}
this.userFriendlyMessage =
@@ -9,6 +9,7 @@ import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { getFlatEntityMapsExceptionContext } from 'src/engine/metadata-modules/flat-entity/utils/get-flat-entity-maps-exception-context.util';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getMetadataRelatedMetadataNamesForValidation } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names-for-validation.util';
import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util';
@@ -444,6 +445,7 @@ export class WorkspaceMigrationRunnerService {
throw new WorkspaceMigrationRunnerException({
message: error.message,
code: WorkspaceMigrationRunnerExceptionCode.INTERNAL_SERVER_ERROR,
context: getFlatEntityMapsExceptionContext(error),
});
} finally {
await queryRunner.release();