e3be1f4971
## 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>
136 lines
4.5 KiB
TypeScript
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,
|
|
};
|
|
};
|