Files
twenty/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts
T
Félix Malfait e3be1f4971 Make ConnectionProvider a true SyncableEntity (#20232)
## Summary

PR #20181 left `ConnectionProvider` in the `SyncableEntity` enum but
bypassing the standard sync pipeline — manifest sync called the bespoke
`ApplicationOAuthProviderService.upsertManyFromManifest()` instead of
going through the workspace-migration orchestrator like every other
SyncableEntity. Anything that assumed *"all SyncableEntity values flow
through the same pipeline"* (dev UI sync tracking, verification tooling)
was wrong about ConnectionProvider — that's the inconsistency this PR
closes.

This PR follows the `.cursor/skills/syncable-entity-*` guides
religiously, all six steps.

## What changes

**Step 1 — Types & Constants** (`@syncable-entity-types-and-constants`)
- Add `connectionProvider` to `ALL_METADATA_NAME` (twenty-shared)
- Make `ApplicationOAuthProviderEntity` extend `SyncableEntity` (drops
the ad-hoc columns since the base class provides them, adds `deletedAt`,
drops the old `(applicationId, universalIdentifier)` unique in favour of
SyncableEntity's `(workspaceId, universalIdentifier)`)
- `FlatConnectionProvider`, `FlatConnectionProviderMaps`,
`FLAT_CONNECTION_PROVIDER_EDITABLE_PROPERTIES`,
`UniversalFlatConnectionProvider`, six action types
- Register in **all** the central registries:
`AllFlatEntityTypesByMetadataName`,
`ALL_METADATA_ENTITY_BY_METADATA_NAME`,
`ALL_ENTITY_PROPERTIES_CONFIGURATION`, `ALL_MANY_TO_ONE_*`,
`ALL_ONE_TO_MANY_*`, `ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION`,
`ALL_METADATA_SERIALIZED_RELATION`,
`ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION`,
`WORKSPACE_CACHE_KEYS_V2` (`flatConnectionProviderMaps`),
`METADATA_EVENTS_TO_EMIT`
- `case 'connectionProvider':` in seven discriminated-union switches
(`derive-metadata-events-*`, `optimistically-apply-*`,
`enrich-create-*`)

**Step 2 — Cache & Transform** (`@syncable-entity-cache-and-transform`)
- `WorkspaceFlatConnectionProviderMapCacheService` (extends
`WorkspaceCacheProvider`, decorated with `@WorkspaceCache`,
soft-delete-aware)
- `fromConnectionProviderEntityToFlatConnectionProvider` util
- `fromConnectionProviderManifestToUniversalFlatConnectionProvider` util
- `FlatConnectionProviderModule` wires the cache service
- Wired the manifest converter into
`compute-application-manifest-all-universal-flat-entity-maps`

**Step 3 — Builder & Validation**
(`@syncable-entity-builder-and-validation`)
- `FlatConnectionProviderValidatorService` — never throws, returns error
arrays; uses indexed `byUniversalIdentifier` for the (name,
applicationUniversalIdentifier) uniqueness check (no
`Object.values().find()` on the hot path)
- `WorkspaceMigrationConnectionProviderActionsBuilderService`
- Registered in both validators-module + builder-module
- **Wired into the orchestrator** (the most-commonly-forgotten step per
the rule) — constructor inject, destructure
`flatConnectionProviderMaps`, `validateAndBuild`, append actions to the
final migration

**Step 4 — Runner & Actions** (`@syncable-entity-runner-and-actions`)
- Three handlers (create / update / delete) using the canonical
`WorkspaceMigrationRunnerActionHandler` mixin
- Registered in `WorkspaceSchemaMigrationRunnerActionHandlersModule`

**Step 5 — Integration** (`@syncable-entity-integration`)
- Delete the `upsertManyFromManifest` bypass on
`ApplicationOAuthProviderService`
- Remove the bypass call from `ApplicationSyncService` — manifest sync
now flows through the standard pipeline
- Drop `ApplicationOAuthProviderModule` from `ApplicationManifestModule`
(no longer needed)
- Import `FlatConnectionProviderModule` from
`ApplicationOAuthProviderModule` to keep the cache discoverable
- 3 new exception codes: `INVALID_CONNECTION_PROVIDER_INPUT`,
`CONNECTION_PROVIDER_NOT_FOUND`,
`CONNECTION_PROVIDER_NAME_ALREADY_EXISTS`

**Migration**
- Generated via `database:migrate:generate` (instance command
`1777896012579`): drops the old `(applicationId, universalIdentifier)`
unique constraint, adds `deletedAt` column, adds the `(workspaceId,
universalIdentifier)` unique index that `SyncableEntity` requires.
- Verified clean — a second `migrate:generate` pass produces zero drift.

**Step 6 — Tests** (`@syncable-entity-testing`)
- 3 new specs for the manifest converter (defaults, optional fields,
all-fields)
- All 32 existing OAuth-provider tests still pass
- ConnectionProvider has no end-user GraphQL CRUD (it's manifest-driven
only), so the GraphQL integration suite that other SyncableEntities ship
doesn't apply here

**Codegen**
- Regenerated GraphQL artifacts (twenty-front + twenty-client-sdk)
against the live schema

## Why this matters

Before:
- `ConnectionProvider` claimed to be a `SyncableEntity` (in the enum)
- But the entity didn't extend `SyncableEntity`
- And the manifest sync bypassed the standard pipeline
- → Verification tooling, dev UI sync tracking, anything iterating over
`ALL_METADATA_NAME` got inconsistent behaviour

After:
- `ConnectionProvider` is a `SyncableEntity` end-to-end
- Single sync path through the workspace-migration orchestrator (same as
`agent`, `skill`, `frontComponent`, `webhook`, …)
- One mental model

## Out of scope (deliberate)

- **Renaming the table** from `applicationOAuthProvider` to
`connectionProvider` — the `metadataName` is `connectionProvider` (what
consumers see in code); the table name is internal. A rename would
balloon this PR with mechanical churn unrelated to the sync-pipeline
wiring. Worth doing as a follow-up.
- **`applicationVariable` SyncableEntity conversion** — the other
manifest-sync holdout. Tracked in #20215.

## Test plan

- [ ] Migration up/down clean against fresh DB
- [ ] Install an app whose manifest declares connection providers —
providers appear in the workspace
- [ ] Re-deploy the app with one provider added, one removed, one
renamed → all reconciled correctly via the sync pipeline
- [ ] Verify the dev-UI sync-tracking page shows ConnectionProvider
entries the same way it shows agents/skills/etc
- [ ] OAuth flow still works (existing connections, new connections,
reconnect, list/get from SDK) — should be unchanged since the runtime
code path didn't move

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:04:15 +02:00

136 lines
4.5 KiB
TypeScript

import { type CombinedGraphQLErrors } from '@apollo/client/errors';
import { t } from '@lingui/core/macro';
import { classifyMetadataError } from '@/metadata-error-handler/utils/classifyMetadataError';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import {
type AllMetadataName,
WorkspaceMigrationV2ExceptionCode,
} from 'twenty-shared/metadata';
import { CrudOperationType } from 'twenty-shared/types';
export const useMetadataErrorHandler = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const TRANSLATED_OPERATION_TYPE = {
[CrudOperationType.CREATE]: t`create`,
[CrudOperationType.UPDATE]: t`update`,
[CrudOperationType.DELETE]: t`delete`,
[CrudOperationType.RESTORE]: t`restore`,
[CrudOperationType.DESTROY]: t`destroy`,
} as const satisfies Record<CrudOperationType, string>;
const TRANSLATED_METADATA_NAME = {
objectMetadata: t`object`,
fieldMetadata: t`field`,
view: t`view`,
viewField: t`view field`,
viewFieldGroup: t`view field group`,
viewGroup: t`view group`,
viewFilter: t`view filter`,
index: t`index`,
logicFunction: t`logic function`,
permissionFlag: t`permission flag`,
objectPermission: t`object permission`,
fieldPermission: t`field permission`,
role: t`role`,
roleTarget: t`role target`,
agent: t`agent`,
skill: t`skill`,
pageLayout: t`page layout`,
pageLayoutTab: t`page layout tab`,
pageLayoutWidget: t`page layout widget`,
rowLevelPermissionPredicate: t`row level permission predicate`,
rowLevelPermissionPredicateGroup: t`row level permission predicate group`,
viewFilterGroup: t`view filter group`,
commandMenuItem: t`command menu item`,
frontComponent: t`front component`,
navigationMenuItem: t`navigation menu item`,
webhook: t`webhook`,
viewSort: t`view sort`,
connectionProvider: t`connection provider`,
} as const satisfies Record<AllMetadataName, string>;
const handleMetadataError = (
error: CombinedGraphQLErrors,
options: {
primaryMetadataName: AllMetadataName;
operationType: CrudOperationType;
},
) => {
const classification = classifyMetadataError({
error,
primaryMetadataName: options.primaryMetadataName,
});
const translatedMetadataName =
TRANSLATED_METADATA_NAME[options.primaryMetadataName];
switch (classification.type) {
case 'v1':
enqueueErrorSnackBar({ apolloError: classification.error });
break;
case 'v2-validation': {
const { extensions, primaryMetadataName, relatedFailingMetadataNames } =
classification;
const targetErrors = extensions.errors[primaryMetadataName] ?? [];
if (targetErrors.length > 0) {
targetErrors.forEach((entityError) => {
entityError.errors.forEach((validationError) =>
enqueueErrorSnackBar({
message:
validationError.userFriendlyMessage ??
validationError.message,
}),
);
});
}
const translatedOperationType =
TRANSLATED_OPERATION_TYPE[options.operationType];
if (
targetErrors.length === 0 &&
relatedFailingMetadataNames.length > 0
) {
const relatedEntityNames = relatedFailingMetadataNames
.map((metadataName) => TRANSLATED_METADATA_NAME[metadataName])
.join(', ');
enqueueErrorSnackBar({
message: t`Failed to ${translatedOperationType} ${translatedMetadataName}. Related ${relatedEntityNames} validation failed. Please check your configuration and try again.`,
});
}
if (
targetErrors.length === 0 &&
relatedFailingMetadataNames.length === 0
) {
enqueueErrorSnackBar({
message: t`Failed to ${translatedOperationType} ${translatedMetadataName}. Please try again.`,
});
}
break;
}
case 'v2-internal': {
const { code } = classification;
const errorMessage =
code ===
WorkspaceMigrationV2ExceptionCode.BUILDER_INTERNAL_SERVER_ERROR
? t`An internal error occurred while validating your changes. Please contact support.`
: t`An internal error occurred while applying your changes. Please contact support and try again later.`;
enqueueErrorSnackBar({ message: errorMessage });
break;
}
}
};
return {
handleMetadataError,
};
};