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
@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ApplicationExceptionFilter response error format should surface an install conflict as APPLICATION_INSTALLATION_FAILED with a human message 1`] = `
{
"extensions": {
"code": "APPLICATION_INSTALLATION_FAILED",
"subCode": "APPLICATION_INSTALLATION_FAILED",
"userFriendlyMessage": "We couldn't install "Test Application". Its object "Invoice" could not be applied to your workspace.",
},
"message": "Installing application 'Test Application' failed [object: Invoice]: addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists (universalIdentifier: b1b2c3d4-0003-4000-a000-000000000003)",
"name": "GraphQLError",
}
`;
@@ -0,0 +1,93 @@
import { i18n } from '@lingui/core';
import { type Manifest } from 'twenty-shared/application';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import { enrichApplicationManifestSyncError } from 'src/engine/core-modules/application/application-manifest/utils/enrich-application-manifest-sync-error.util';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import {
type BaseGraphQLError,
ErrorCode,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
FlatEntityMapsException,
FlatEntityMapsExceptionCode,
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
const OBJECT_UNIVERSAL_IDENTIFIER = 'b1b2c3d4-0003-4000-a000-000000000003';
const manifest = {
application: { displayName: 'Test Application' },
objects: [
{
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
labelSingular: 'Invoice',
},
],
fields: [],
} as unknown as Manifest;
const catchAsGraphQLError = (exception: ApplicationException) => {
const filter = new ApplicationExceptionFilter();
try {
filter.catch(exception);
} catch (graphqlError) {
return graphqlError as BaseGraphQLError;
}
throw new Error('ApplicationExceptionFilter did not throw');
};
describe('ApplicationExceptionFilter response error format', () => {
it('should surface an install conflict as APPLICATION_INSTALLATION_FAILED with a human message', () => {
const originalError = new FlatEntityMapsException(
'addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists (universalIdentifier: b1b2c3d4-0003-4000-a000-000000000003)',
FlatEntityMapsExceptionCode.ENTITY_ALREADY_EXISTS,
{
context: {
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
operation: 'add',
},
},
);
const enrichedError = enrichApplicationManifestSyncError({
error: originalError,
manifest,
}) as ApplicationException;
expect(enrichedError.code).toBe(
ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED,
);
const graphqlError = catchAsGraphQLError(enrichedError);
expect(graphqlError.extensions.code).toBe(
ErrorCode.APPLICATION_INSTALLATION_FAILED,
);
expect(graphqlError.context).toEqual({
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
operation: 'add',
});
expect(graphqlError.extensions.context).toBeUndefined();
expect(
JSON.parse(JSON.stringify(graphqlError.toJSON())),
).not.toHaveProperty('context');
expect({
name: graphqlError.name,
message: graphqlError.message,
extensions: {
code: graphqlError.extensions.code,
subCode: graphqlError.extensions.subCode,
userFriendlyMessage: i18n._(
graphqlError.extensions.userFriendlyMessage,
),
},
}).toMatchSnapshot();
});
});
@@ -7,6 +7,8 @@ import {
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import {
BaseGraphQLError,
ErrorCode,
ForbiddenError,
InternalServerError,
NotFoundError,
@@ -41,6 +43,19 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
case ApplicationExceptionCode.UPGRADE_FAILED:
case ApplicationExceptionCode.INVALID_SERVER_VERSION:
throw new InternalServerError(exception);
case ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED: {
const installationError = new BaseGraphQLError(
exception,
ErrorCode.APPLICATION_INSTALLATION_FAILED,
);
Object.defineProperty(installationError, 'context', {
value: exception.context,
enumerable: false,
});
throw installationError;
}
default: {
assertUnreachable(exception.code);
}
@@ -7,6 +7,7 @@ import { isDefined } from 'twenty-shared/utils';
import { PackageJson } from 'type-fest';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service';
import { enrichApplicationManifestSyncError } from 'src/engine/core-modules/application/application-manifest/utils/enrich-application-manifest-sync-error.util';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
import { ApplicationTranslationSyncService } from 'src/engine/core-modules/application/application-translation/application-translation-sync.service';
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util';
@@ -65,13 +66,24 @@ export class ApplicationSyncService {
applicationRegistrationId,
});
const syncResult =
await this.applicationManifestMigrationService.syncMetadataFromManifest({
manifest,
workspaceId,
ownerFlatApplication,
dryRun,
});
let syncResult: {
workspaceMigration: WorkspaceMigration;
hasSchemaMetadataChanged: boolean;
};
try {
syncResult =
await this.applicationManifestMigrationService.syncMetadataFromManifest(
{
manifest,
workspaceId,
ownerFlatApplication,
dryRun,
},
);
} catch (error) {
throw enrichApplicationManifestSyncError({ error, manifest });
}
if (!dryRun && isDefined(ownerFlatApplication.applicationRegistrationId)) {
// Translation sync runs after the metadata migration is already applied
@@ -0,0 +1,175 @@
import { type Manifest } from 'twenty-shared/application';
import { enrichApplicationManifestSyncError } from 'src/engine/core-modules/application/application-manifest/utils/enrich-application-manifest-sync-error.util';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import {
FlatEntityMapsException,
FlatEntityMapsExceptionCode,
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
const OBJECT_UNIVERSAL_IDENTIFIER = 'object-universal-identifier';
const FIELD_UNIVERSAL_IDENTIFIER = 'field-universal-identifier';
const NESTED_FIELD_UNIVERSAL_IDENTIFIER = 'nested-field-universal-identifier';
const ROLE_UNIVERSAL_IDENTIFIER = 'role-universal-identifier';
const VIEW_FIELD_UNIVERSAL_IDENTIFIER = 'view-field-universal-identifier';
const manifest = {
application: { displayName: 'Stripe' },
objects: [
{
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
labelSingular: 'Invoice',
fields: [
{
universalIdentifier: NESTED_FIELD_UNIVERSAL_IDENTIFIER,
label: 'Due Date',
},
],
},
],
fields: [
{
universalIdentifier: FIELD_UNIVERSAL_IDENTIFIER,
label: 'Amount',
},
],
roles: [
{
universalIdentifier: ROLE_UNIVERSAL_IDENTIFIER,
label: 'Support Agent',
},
],
viewFields: [
{
universalIdentifier: VIEW_FIELD_UNIVERSAL_IDENTIFIER,
},
],
} as unknown as Manifest;
describe('enrichApplicationManifestSyncError', () => {
it('should resolve the offending object and produce an installation exception', () => {
const error = new FlatEntityMapsException(
'addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists',
FlatEntityMapsExceptionCode.ENTITY_ALREADY_EXISTS,
{
context: {
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
operation: 'add',
},
},
);
const enriched = enrichApplicationManifestSyncError({ error, manifest });
expect(enriched).toBeInstanceOf(ApplicationException);
expect((enriched as ApplicationException).code).toBe(
ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED,
);
expect((enriched as ApplicationException).message).toContain('Stripe');
expect((enriched as ApplicationException).message).toContain('Invoice');
expect((enriched as ApplicationException).message).toContain(
'flat entity to add already exists',
);
expect((enriched as ApplicationException).context).toEqual({
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
operation: 'add',
});
});
it('should resolve the offending field by universalIdentifier', () => {
const error = new FlatEntityMapsException(
'entity malformed',
FlatEntityMapsExceptionCode.ENTITY_MALFORMED,
{ context: { universalIdentifier: FIELD_UNIVERSAL_IDENTIFIER } },
);
const enriched = enrichApplicationManifestSyncError({ error, manifest });
expect((enriched as ApplicationException).message).toContain('Amount');
});
it('should resolve a field nested in an object manifest by universalIdentifier', () => {
const error = new FlatEntityMapsException(
'flat entity to add already exists',
FlatEntityMapsExceptionCode.ENTITY_ALREADY_EXISTS,
{ context: { universalIdentifier: NESTED_FIELD_UNIVERSAL_IDENTIFIER } },
);
const enriched = enrichApplicationManifestSyncError({ error, manifest });
expect((enriched as ApplicationException).message).toContain('field');
expect((enriched as ApplicationException).message).toContain('Due Date');
});
it('should resolve a labeled non-object/field kind (role) by universalIdentifier', () => {
const error = new FlatEntityMapsException(
'flat entity to add already exists',
FlatEntityMapsExceptionCode.ENTITY_ALREADY_EXISTS,
{ context: { universalIdentifier: ROLE_UNIVERSAL_IDENTIFIER } },
);
const enriched = enrichApplicationManifestSyncError({ error, manifest });
expect((enriched as ApplicationException).message).toContain('role');
expect((enriched as ApplicationException).message).toContain(
'Support Agent',
);
});
it('should fall back to the entity kind when the resolved entity has no label', () => {
const error = new FlatEntityMapsException(
'flat entity to add already exists',
FlatEntityMapsExceptionCode.ENTITY_ALREADY_EXISTS,
{ context: { universalIdentifier: VIEW_FIELD_UNIVERSAL_IDENTIFIER } },
);
const enriched = enrichApplicationManifestSyncError({ error, manifest });
expect((enriched as ApplicationException).message).toContain('view field');
expect((enriched as ApplicationException).message).not.toContain(
'undefined',
);
});
it('should still enrich when the identifier is not in the manifest', () => {
const error = new FlatEntityMapsException(
'entity not found',
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
{ context: { universalIdentifier: 'unknown-identifier' } },
);
const enriched = enrichApplicationManifestSyncError({ error, manifest });
expect(enriched).toBeInstanceOf(ApplicationException);
expect((enriched as ApplicationException).code).toBe(
ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED,
);
expect((enriched as ApplicationException).message).toContain('Stripe');
});
it('should extract context forwarded through a wrapper exception', () => {
const wrapperError = {
message: 'wrapped failure',
context: { universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER },
};
const enriched = enrichApplicationManifestSyncError({
error: wrapperError,
manifest,
});
expect(enriched).toBeInstanceOf(ApplicationException);
expect((enriched as ApplicationException).message).toContain('Invoice');
});
it('should leave non flat-entity errors untouched', () => {
const error = new Error('some unrelated failure');
const enriched = enrichApplicationManifestSyncError({ error, manifest });
expect(enriched).toBe(error);
});
});
@@ -0,0 +1,63 @@
import { msg } from '@lingui/core/macro';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { findManifestEntityDescriptorByUniversalIdentifier } from 'src/engine/core-modules/application/application-manifest/utils/find-manifest-entity-descriptor-by-universal-identifier.util';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { getFlatEntityMapsExceptionContext } from 'src/engine/metadata-modules/flat-entity/utils/get-flat-entity-maps-exception-context.util';
export const enrichApplicationManifestSyncError = ({
error,
manifest,
}: {
error: unknown;
manifest: Manifest;
}): unknown => {
const context = getFlatEntityMapsExceptionContext(error);
if (!isDefined(context)) {
return error;
}
const applicationDisplayName = manifest.application.displayName;
const originalMessage =
error instanceof Error ? error.message : String(error);
const descriptor = isDefined(context.universalIdentifier)
? findManifestEntityDescriptorByUniversalIdentifier({
manifest,
universalIdentifier: context.universalIdentifier,
})
: undefined;
if (isDefined(descriptor)) {
const { entityKind, label } = descriptor;
const humanEntity = isDefined(label)
? `${entityKind} "${label}"`
: entityKind;
const developerDetail = isDefined(label)
? `${entityKind}: ${label}`
: entityKind;
return new ApplicationException(
`Installing application '${applicationDisplayName}' failed [${developerDetail}]: ${originalMessage}`,
ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED,
{
userFriendlyMessage: msg`We couldn't install "${applicationDisplayName}". Its ${humanEntity} could not be applied to your workspace.`,
context,
},
);
}
return new ApplicationException(
`Installing application '${applicationDisplayName}' failed: ${originalMessage}`,
ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED,
{
userFriendlyMessage: msg`We couldn't install "${applicationDisplayName}" because some of its metadata could not be applied to your workspace.`,
context,
},
);
};
@@ -0,0 +1,275 @@
import { type Manifest } from 'twenty-shared/application';
import { type AllMetadataName } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
export type ManifestEntityDescriptor = {
entityKind: string;
label?: string;
};
type ManifestEntityCandidate = {
universalIdentifier: string;
label?: string;
};
type ManifestEntityRegistryEntry = {
entityKind: string;
getCandidates: (manifest: Manifest) => ManifestEntityCandidate[];
};
const NO_MANIFEST_CANDIDATES: ManifestEntityCandidate[] = [];
const toCandidates = <T extends { universalIdentifier?: string }>(
entities: T[] | undefined,
getLabel: (entity: T) => string | undefined,
): ManifestEntityCandidate[] =>
(entities ?? [])
.filter((entity): entity is T & { universalIdentifier: string } =>
isDefined(entity.universalIdentifier),
)
.map((entity) => ({
universalIdentifier: entity.universalIdentifier,
label: getLabel(entity),
}));
const MANIFEST_ENTITY_REGISTRY: Record<
AllMetadataName,
ManifestEntityRegistryEntry
> = {
objectMetadata: {
entityKind: 'object',
getCandidates: (manifest) =>
toCandidates(manifest.objects, (object) => object.labelSingular),
},
fieldMetadata: {
entityKind: 'field',
getCandidates: (manifest) => [
...toCandidates(manifest.fields, (field) => field.label),
...(manifest.objects ?? []).flatMap((object) =>
toCandidates(object.fields, (field) => field.label),
),
],
},
role: {
entityKind: 'role',
getCandidates: (manifest) =>
toCandidates(manifest.roles, (role) => role.label),
},
permissionFlag: {
entityKind: 'permission flag',
getCandidates: (manifest) =>
toCandidates(
manifest.permissionFlags,
(permissionFlag) => permissionFlag.label,
),
},
skill: {
entityKind: 'skill',
getCandidates: (manifest) =>
toCandidates(manifest.skills, (skill) => skill.label),
},
agent: {
entityKind: 'agent',
getCandidates: (manifest) =>
toCandidates(manifest.agents, (agent) => agent.label),
},
connectionProvider: {
entityKind: 'connection provider',
getCandidates: (manifest) =>
toCandidates(
manifest.connectionProviders,
(connectionProvider) => connectionProvider.displayName,
),
},
view: {
entityKind: 'view',
getCandidates: (manifest) =>
toCandidates(manifest.views, (view) => view.name),
},
pageLayout: {
entityKind: 'page layout',
getCandidates: (manifest) =>
toCandidates(manifest.pageLayouts, (pageLayout) => pageLayout.name),
},
pageLayoutTab: {
entityKind: 'page layout tab',
getCandidates: (manifest) => [
...toCandidates(
manifest.pageLayoutTabs,
(pageLayoutTab) => pageLayoutTab.title,
),
...(manifest.pageLayouts ?? []).flatMap((pageLayout) =>
toCandidates(pageLayout.tabs, (pageLayoutTab) => pageLayoutTab.title),
),
],
},
pageLayoutWidget: {
entityKind: 'page layout widget',
getCandidates: (manifest) => [
...(manifest.pageLayoutTabs ?? []).flatMap((pageLayoutTab) =>
toCandidates(pageLayoutTab.widgets, (widget) => widget.title),
),
...(manifest.pageLayouts ?? []).flatMap((pageLayout) =>
(pageLayout.tabs ?? []).flatMap((pageLayoutTab) =>
toCandidates(pageLayoutTab.widgets, (widget) => widget.title),
),
),
],
},
commandMenuItem: {
entityKind: 'command menu item',
getCandidates: (manifest) =>
toCandidates(
manifest.commandMenuItems,
(commandMenuItem) => commandMenuItem.label,
),
},
logicFunction: {
entityKind: 'logic function',
getCandidates: (manifest) =>
toCandidates(
manifest.logicFunctions,
(logicFunction) => logicFunction.name,
),
},
frontComponent: {
entityKind: 'front component',
getCandidates: (manifest) =>
toCandidates(
manifest.frontComponents,
(frontComponent) => frontComponent.name,
),
},
navigationMenuItem: {
entityKind: 'navigation menu item',
getCandidates: (manifest) =>
toCandidates(
manifest.navigationMenuItems,
(navigationMenuItem) => navigationMenuItem.name,
),
},
index: {
entityKind: 'index',
getCandidates: (manifest) =>
toCandidates(manifest.indexes, () => undefined),
},
viewField: {
entityKind: 'view field',
getCandidates: (manifest) => [
...toCandidates(manifest.viewFields, () => undefined),
...(manifest.views ?? []).flatMap((view) =>
toCandidates(view.fields, () => undefined),
),
],
},
viewFieldGroup: {
entityKind: 'view field group',
getCandidates: (manifest) =>
(manifest.views ?? []).flatMap((view) =>
toCandidates(view.fieldGroups, (fieldGroup) => fieldGroup.name),
),
},
viewGroup: {
entityKind: 'view group',
getCandidates: (manifest) =>
(manifest.views ?? []).flatMap((view) =>
toCandidates(view.groups, () => undefined),
),
},
viewSort: {
entityKind: 'view sort',
getCandidates: (manifest) =>
(manifest.views ?? []).flatMap((view) =>
toCandidates(view.sorts, () => undefined),
),
},
viewFilter: {
entityKind: 'view filter',
getCandidates: (manifest) =>
(manifest.views ?? []).flatMap((view) =>
toCandidates(view.filters, () => undefined),
),
},
viewFilterGroup: {
entityKind: 'view filter group',
getCandidates: (manifest) =>
(manifest.views ?? []).flatMap((view) =>
toCandidates(view.filterGroups, () => undefined),
),
},
objectPermission: {
entityKind: 'object permission',
getCandidates: (manifest) =>
(manifest.roles ?? []).flatMap((role) =>
toCandidates(role.objectPermissions, () => undefined),
),
},
fieldPermission: {
entityKind: 'field permission',
getCandidates: (manifest) =>
(manifest.roles ?? []).flatMap((role) =>
toCandidates(role.fieldPermissions, () => undefined),
),
},
rowLevelPermissionPredicate: {
entityKind: 'row-level permission predicate',
getCandidates: (manifest) =>
(manifest.roles ?? []).flatMap((role) =>
toCandidates(role.rowLevelPermissionPredicates, () => undefined),
),
},
rowLevelPermissionPredicateGroup: {
entityKind: 'row-level permission predicate group',
getCandidates: (manifest) =>
(manifest.roles ?? []).flatMap((role) =>
toCandidates(role.rowLevelPermissionPredicateGroups, () => undefined),
),
},
roleTarget: {
entityKind: 'role target',
getCandidates: () => NO_MANIFEST_CANDIDATES,
},
rolePermissionFlag: {
entityKind: 'role permission flag',
getCandidates: () => NO_MANIFEST_CANDIDATES,
},
webhook: {
entityKind: 'webhook',
getCandidates: () => NO_MANIFEST_CANDIDATES,
},
applicationVariable: {
entityKind: 'application variable',
getCandidates: () => NO_MANIFEST_CANDIDATES,
},
searchFieldMetadata: {
entityKind: 'search field',
getCandidates: () => NO_MANIFEST_CANDIDATES,
},
};
const MANIFEST_ENTITY_REGISTRY_ENTRIES = Object.values(
MANIFEST_ENTITY_REGISTRY,
);
export const findManifestEntityDescriptorByUniversalIdentifier = ({
manifest,
universalIdentifier,
}: {
manifest: Manifest;
universalIdentifier: string;
}): ManifestEntityDescriptor | undefined => {
for (const {
entityKind,
getCandidates,
} of MANIFEST_ENTITY_REGISTRY_ENTRIES) {
const match = getCandidates(manifest).find(
(candidate) => candidate.universalIdentifier === universalIdentifier,
);
if (isDefined(match)) {
return { entityKind, label: match.label };
}
}
return undefined;
};
@@ -42,6 +42,7 @@ const applicationExceptionCodeToHttpStatus = (
case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED:
case ApplicationExceptionCode.UPGRADE_FAILED:
case ApplicationExceptionCode.INVALID_SERVER_VERSION:
case ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED:
return 500;
default:
return assertUnreachable(code);
@@ -2,6 +2,7 @@ import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { type FlatEntityMapsExceptionContext } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { CustomException } from 'src/utils/custom-exception';
export enum ApplicationExceptionCode {
@@ -25,6 +26,7 @@ export enum ApplicationExceptionCode {
SERVER_VERSION_INCOMPATIBLE = 'SERVER_VERSION_INCOMPATIBLE',
INVALID_APP_ENGINE_REQUIREMENT = 'INVALID_APP_ENGINE_REQUIREMENT',
INVALID_SERVER_VERSION = 'INVALID_SERVER_VERSION',
APPLICATION_INSTALLATION_FAILED = 'APPLICATION_INSTALLATION_FAILED',
}
const getApplicationExceptionUserFriendlyMessage = (
@@ -71,20 +73,32 @@ const getApplicationExceptionUserFriendlyMessage = (
return msg`The app manifest declares an invalid server version requirement.`;
case ApplicationExceptionCode.INVALID_SERVER_VERSION:
return msg`The server's APP_VERSION is not a valid semver version. Self-hosted instances must configure a valid APP_VERSION.`;
case ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED:
return msg`We couldn't install this application because some of its metadata could not be applied to your workspace.`;
default:
assertUnreachable(code);
}
};
export class ApplicationException extends CustomException<ApplicationExceptionCode> {
context?: FlatEntityMapsExceptionContext;
constructor(
message: string,
code: ApplicationExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
{
userFriendlyMessage,
context,
}: {
userFriendlyMessage?: MessageDescriptor;
context?: FlatEntityMapsExceptionContext;
} = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getApplicationExceptionUserFriendlyMessage(code),
});
this.context = context;
}
}