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:
+2
@@ -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;
|
||||
|
||||
+9
-1
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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 =
|
||||
|
||||
+2
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user