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:
+13
@@ -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",
|
||||
}
|
||||
`;
|
||||
+93
@@ -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();
|
||||
});
|
||||
});
|
||||
+15
@@ -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);
|
||||
}
|
||||
|
||||
+19
-7
@@ -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
|
||||
|
||||
+175
@@ -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);
|
||||
});
|
||||
});
|
||||
+63
@@ -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,
|
||||
},
|
||||
);
|
||||
};
|
||||
+275
@@ -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;
|
||||
};
|
||||
+1
@@ -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);
|
||||
|
||||
+15
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
};
|
||||
+12
-1
@@ -2,6 +2,7 @@ import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { type WorkspaceMigrationV2ExceptionCode } from 'twenty-shared/metadata';
|
||||
|
||||
import { type FlatEntityMapsExceptionContext } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
const workspaceMigrationV2ExceptionUserFriendlyMessages: Partial<
|
||||
@@ -11,10 +12,18 @@ const workspaceMigrationV2ExceptionUserFriendlyMessages: Partial<
|
||||
const defaultUserFriendlyMessage = msg`An error occurred during workspace migration.`;
|
||||
|
||||
export class WorkspaceMigrationV2Exception extends CustomException<WorkspaceMigrationV2ExceptionCode> {
|
||||
context?: FlatEntityMapsExceptionContext;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: WorkspaceMigrationV2ExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
{
|
||||
userFriendlyMessage,
|
||||
context,
|
||||
}: {
|
||||
userFriendlyMessage?: MessageDescriptor;
|
||||
context?: FlatEntityMapsExceptionContext;
|
||||
} = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
@@ -22,5 +31,7 @@ export class WorkspaceMigrationV2Exception extends CustomException<WorkspaceMigr
|
||||
workspaceMigrationV2ExceptionUserFriendlyMessages[code] ??
|
||||
defaultUserFriendlyMessage,
|
||||
});
|
||||
|
||||
this.context = context;
|
||||
}
|
||||
}
|
||||
|
||||
+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();
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Sync application should surface a human error on flat-entity map conflicts should fail with an installation error naming the field when two fields of an object share a universalIdentifier 1`] = `
|
||||
{
|
||||
"eventId": Any<String>,
|
||||
"extensions": {
|
||||
"code": "APPLICATION_INSTALLATION_FAILED",
|
||||
"exceptionEventId": Any<String>,
|
||||
"subCode": "APPLICATION_INSTALLATION_FAILED",
|
||||
"userFriendlyMessage": "We couldn't install "Test Application". Its field "Due Date" could not be applied to your workspace.",
|
||||
},
|
||||
"message": "Installing application 'Test Application' failed [field: Due Date]: addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists (universalIdentifier: b1b2c3d4-0004-4000-a000-000000000004)",
|
||||
"name": "GraphQLError",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Sync application should surface a human error on flat-entity map conflicts should fail with an installation error naming the object when two objects share a universalIdentifier 1`] = `
|
||||
{
|
||||
"eventId": Any<String>,
|
||||
"extensions": {
|
||||
"code": "APPLICATION_INSTALLATION_FAILED",
|
||||
"exceptionEventId": Any<String>,
|
||||
"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",
|
||||
}
|
||||
`;
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
|
||||
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
|
||||
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
|
||||
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
|
||||
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
|
||||
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
|
||||
|
||||
const TEST_APP_ID = 'b1b2c3d4-0001-4000-a000-000000000001';
|
||||
const TEST_ROLE_ID = 'b1b2c3d4-0002-4000-a000-000000000002';
|
||||
const DUPLICATED_OBJECT_UNIVERSAL_IDENTIFIER =
|
||||
'b1b2c3d4-0003-4000-a000-000000000003';
|
||||
const DUPLICATED_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'b1b2c3d4-0004-4000-a000-000000000004';
|
||||
|
||||
describe('Sync application should surface a human error on flat-entity map conflicts', () => {
|
||||
beforeAll(async () => {
|
||||
await setupApplicationForSync({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
name: 'Test Flat Entity Map Conflict App',
|
||||
description: 'App for testing flat-entity map conflict error formatting',
|
||||
sourcePath: 'test-flat-entity-map-conflict',
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupApplicationAndAppRegistration({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail with an installation error naming the object when two objects share a universalIdentifier', async () => {
|
||||
const firstObject = buildDefaultObjectManifest({
|
||||
nameSingular: 'invoice',
|
||||
namePlural: 'invoices',
|
||||
labelSingular: 'Invoice',
|
||||
labelPlural: 'Invoices',
|
||||
universalIdentifier: DUPLICATED_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
|
||||
const conflictingObject = buildDefaultObjectManifest({
|
||||
nameSingular: 'invoiceDuplicate',
|
||||
namePlural: 'invoiceDuplicates',
|
||||
labelSingular: 'Invoice Duplicate',
|
||||
labelPlural: 'Invoice Duplicates',
|
||||
universalIdentifier: DUPLICATED_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
|
||||
const manifest = buildBaseManifest({
|
||||
appId: TEST_APP_ID,
|
||||
roleId: TEST_ROLE_ID,
|
||||
overrides: {
|
||||
objects: [firstObject, conflictingObject],
|
||||
},
|
||||
});
|
||||
|
||||
const { errors } = await syncApplication({
|
||||
manifest,
|
||||
expectToFail: true,
|
||||
});
|
||||
|
||||
expectOneNotInternalServerErrorSnapshot({ errors });
|
||||
}, 60000);
|
||||
|
||||
it('should fail with an installation error naming the field when two fields of an object share a universalIdentifier', async () => {
|
||||
const objectWithConflictingFields = buildDefaultObjectManifest({
|
||||
nameSingular: 'invoice',
|
||||
namePlural: 'invoices',
|
||||
labelSingular: 'Invoice',
|
||||
labelPlural: 'Invoices',
|
||||
additionalFields: [
|
||||
{
|
||||
universalIdentifier: DUPLICATED_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
name: 'dueDate',
|
||||
label: 'Due Date',
|
||||
},
|
||||
{
|
||||
universalIdentifier: DUPLICATED_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
name: 'dueDateDuplicate',
|
||||
label: 'Due Date Duplicate',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const manifest = buildBaseManifest({
|
||||
appId: TEST_APP_ID,
|
||||
roleId: TEST_ROLE_ID,
|
||||
overrides: {
|
||||
objects: [objectWithConflictingFields],
|
||||
},
|
||||
});
|
||||
|
||||
const { errors } = await syncApplication({
|
||||
manifest,
|
||||
expectToFail: true,
|
||||
});
|
||||
|
||||
expectOneNotInternalServerErrorSnapshot({ errors });
|
||||
}, 60000);
|
||||
});
|
||||
Reference in New Issue
Block a user