Skip to content

Commit e3be1f4

Browse files
FelixMalfaitclaude
andauthored
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>
1 parent 59107b5 commit e3be1f4

106 files changed

Lines changed: 2077 additions & 1747 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/twenty-client-sdk/src/metadata/generated/schema.graphql

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2550,7 +2550,7 @@ type ConnectedAccountDTO {
25502550
connectionParameters: ImapSmtpCaldavConnectionParameters
25512551
lastSignedInAt: DateTime
25522552
userWorkspaceId: UUID!
2553-
applicationConnectionProviderId: UUID
2553+
connectionProviderId: UUID
25542554
applicationId: UUID
25552555
name: String
25562556
visibility: String!
@@ -2581,7 +2581,7 @@ type ConnectedAccountPublicDTO {
25812581
scopes: [String!]
25822582
lastSignedInAt: DateTime
25832583
userWorkspaceId: UUID!
2584-
applicationConnectionProviderId: UUID
2584+
connectionProviderId: UUID
25852585
applicationId: UUID
25862586
name: String
25872587
visibility: String!
@@ -2870,6 +2870,7 @@ enum AllMetadataName {
28702870
fieldPermission
28712871
frontComponent
28722872
webhook
2873+
connectionProvider
28732874
}
28742875

28752876
type MinimalObjectMetadata {

packages/twenty-client-sdk/src/metadata/generated/schema.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2238,7 +2238,7 @@ export interface ConnectedAccountDTO {
22382238
connectionParameters?: ImapSmtpCaldavConnectionParameters
22392239
lastSignedInAt?: Scalars['DateTime']
22402240
userWorkspaceId: Scalars['UUID']
2241-
applicationConnectionProviderId?: Scalars['UUID']
2241+
connectionProviderId?: Scalars['UUID']
22422242
applicationId?: Scalars['UUID']
22432243
name?: Scalars['String']
22442244
visibility: Scalars['String']
@@ -2272,7 +2272,7 @@ export interface ConnectedAccountPublicDTO {
22722272
scopes?: Scalars['String'][]
22732273
lastSignedInAt?: Scalars['DateTime']
22742274
userWorkspaceId: Scalars['UUID']
2275-
applicationConnectionProviderId?: Scalars['UUID']
2275+
connectionProviderId?: Scalars['UUID']
22762276
applicationId?: Scalars['UUID']
22772277
name?: Scalars['String']
22782278
visibility: Scalars['String']
@@ -2493,7 +2493,7 @@ export interface CollectionHash {
24932493
__typename: 'CollectionHash'
24942494
}
24952495

2496-
export type AllMetadataName = 'fieldMetadata' | 'objectMetadata' | 'view' | 'viewField' | 'viewFieldGroup' | 'viewGroup' | 'viewSort' | 'rowLevelPermissionPredicate' | 'rowLevelPermissionPredicateGroup' | 'viewFilterGroup' | 'index' | 'logicFunction' | 'viewFilter' | 'role' | 'roleTarget' | 'agent' | 'skill' | 'pageLayout' | 'pageLayoutWidget' | 'pageLayoutTab' | 'commandMenuItem' | 'navigationMenuItem' | 'permissionFlag' | 'objectPermission' | 'fieldPermission' | 'frontComponent' | 'webhook'
2496+
export type AllMetadataName = 'fieldMetadata' | 'objectMetadata' | 'view' | 'viewField' | 'viewFieldGroup' | 'viewGroup' | 'viewSort' | 'rowLevelPermissionPredicate' | 'rowLevelPermissionPredicateGroup' | 'viewFilterGroup' | 'index' | 'logicFunction' | 'viewFilter' | 'role' | 'roleTarget' | 'agent' | 'skill' | 'pageLayout' | 'pageLayoutWidget' | 'pageLayoutTab' | 'commandMenuItem' | 'navigationMenuItem' | 'permissionFlag' | 'objectPermission' | 'fieldPermission' | 'frontComponent' | 'webhook' | 'connectionProvider'
24972497

24982498
export interface MinimalObjectMetadata {
24992499
id: Scalars['UUID']
@@ -5250,7 +5250,7 @@ export interface ConnectedAccountDTOGenqlSelection{
52505250
connectionParameters?: ImapSmtpCaldavConnectionParametersGenqlSelection
52515251
lastSignedInAt?: boolean | number
52525252
userWorkspaceId?: boolean | number
5253-
applicationConnectionProviderId?: boolean | number
5253+
connectionProviderId?: boolean | number
52545254
applicationId?: boolean | number
52555255
name?: boolean | number
52565256
visibility?: boolean | number
@@ -5287,7 +5287,7 @@ export interface ConnectedAccountPublicDTOGenqlSelection{
52875287
scopes?: boolean | number
52885288
lastSignedInAt?: boolean | number
52895289
userWorkspaceId?: boolean | number
5290-
applicationConnectionProviderId?: boolean | number
5290+
connectionProviderId?: boolean | number
52915291
applicationId?: boolean | number
52925292
name?: boolean | number
52935293
visibility?: boolean | number
@@ -8849,7 +8849,8 @@ export const enumAllMetadataName = {
88498849
objectPermission: 'objectPermission' as const,
88508850
fieldPermission: 'fieldPermission' as const,
88518851
frontComponent: 'frontComponent' as const,
8852-
webhook: 'webhook' as const
8852+
webhook: 'webhook' as const,
8853+
connectionProvider: 'connectionProvider' as const
88538854
}
88548855

88558856
export const enumEventLogTable = {

packages/twenty-client-sdk/src/metadata/generated/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5088,7 +5088,7 @@ export default {
50885088
"userWorkspaceId": [
50895089
3
50905090
],
5091-
"applicationConnectionProviderId": [
5091+
"connectionProviderId": [
50925092
3
50935093
],
50945094
"applicationId": [
@@ -5169,7 +5169,7 @@ export default {
51695169
"userWorkspaceId": [
51705170
3
51715171
],
5172-
"applicationConnectionProviderId": [
5172+
"connectionProviderId": [
51735173
3
51745174
],
51755175
"applicationId": [

packages/twenty-front/src/generated-metadata/graphql.ts

Lines changed: 5 additions & 4 deletions
Large diffs are not rendered by default.

packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export type ConnectedAccount = {
1212
handleAliases: string[] | null;
1313
lastSignedInAt: string | null;
1414
userWorkspaceId: string;
15-
applicationConnectionProviderId: string | null;
15+
connectionProviderId: string | null;
1616
name: string | null;
1717
// Connection-row visibility — distinct from the `scopes` array above
1818
// (those are upstream-granted OAuth permissions).

packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const useMetadataErrorHandler = () => {
4848
navigationMenuItem: t`navigation menu item`,
4949
webhook: t`webhook`,
5050
viewSort: t`view sort`,
51+
connectionProvider: t`connection provider`,
5152
} as const satisfies Record<AllMetadataName, string>;
5253

5354
const handleMetadataError = (

packages/twenty-front/src/modules/settings/accounts/graphql/queries/getMyConnectedAccounts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const GET_MY_CONNECTED_ACCOUNTS = gql`
1111
handleAliases
1212
lastSignedInAt
1313
userWorkspaceId
14-
applicationConnectionProviderId
14+
connectionProviderId
1515
name
1616
visibility
1717
lastCredentialsRefreshedAt

packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationConnectionsSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export const SettingsApplicationConnectionsSection = ({
117117
provider.oauth?.isClientCredentialsConfigured ?? false;
118118

119119
const providerConnections = connectedAccounts.filter(
120-
(account) => account.applicationConnectionProviderId === provider.id,
120+
(account) => account.connectionProviderId === provider.id,
121121
);
122122

123123
return (
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { QueryRunner } from 'typeorm';
2+
3+
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
4+
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
5+
6+
@RegisteredInstanceCommand('2.3.0', 1777896012579)
7+
export class ConnectionProviderSyncableEntityFastInstanceCommand
8+
implements FastInstanceCommand
9+
{
10+
public async up(queryRunner: QueryRunner): Promise<void> {
11+
await queryRunner.query(
12+
`CREATE TABLE "core"."connectionProvider" (
13+
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
14+
"workspaceId" uuid NOT NULL,
15+
"applicationId" uuid NOT NULL,
16+
"universalIdentifier" uuid NOT NULL,
17+
"name" varchar NOT NULL,
18+
"displayName" varchar NOT NULL,
19+
"type" varchar NOT NULL,
20+
"oauthConfig" jsonb,
21+
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
22+
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
23+
CONSTRAINT "IDX_CONNECTION_PROVIDER_NAME_APPLICATION_UNIQUE" UNIQUE ("name", "applicationId"),
24+
CONSTRAINT "PK_connectionProvider_id" PRIMARY KEY ("id")
25+
)`,
26+
);
27+
28+
await queryRunner.query(
29+
`CREATE INDEX "IDX_CONNECTION_PROVIDER_APPLICATION_ID" ON "core"."connectionProvider" ("applicationId")`,
30+
);
31+
32+
await queryRunner.query(
33+
`CREATE UNIQUE INDEX "IDX_44a4fc17a91603c38daabfd4d8" ON "core"."connectionProvider" ("workspaceId", "universalIdentifier")`,
34+
);
35+
36+
await queryRunner.query(
37+
`ALTER TABLE "core"."connectionProvider"
38+
ADD CONSTRAINT "FK_16d8e4d029dd986268d759a2257"
39+
FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id")
40+
ON DELETE CASCADE ON UPDATE NO ACTION`,
41+
);
42+
43+
await queryRunner.query(
44+
`ALTER TABLE "core"."connectionProvider"
45+
ADD CONSTRAINT "FK_a2553b431536a5b93211012f984"
46+
FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id")
47+
ON DELETE CASCADE ON UPDATE NO ACTION`,
48+
);
49+
50+
await queryRunner.query(
51+
`ALTER TABLE "core"."connectedAccount"
52+
ADD COLUMN "connectionProviderId" uuid,
53+
ADD COLUMN "applicationId" uuid,
54+
ADD COLUMN "name" varchar,
55+
ADD COLUMN "visibility" varchar NOT NULL DEFAULT 'user'`,
56+
);
57+
58+
await queryRunner.query(
59+
`CREATE INDEX "IDX_CONNECTED_ACCOUNT_CONNECTION_PROVIDER_ID" ON "core"."connectedAccount" ("connectionProviderId")`,
60+
);
61+
62+
await queryRunner.query(
63+
`CREATE INDEX "IDX_CONNECTED_ACCOUNT_APPLICATION_ID" ON "core"."connectedAccount" ("applicationId")`,
64+
);
65+
66+
await queryRunner.query(
67+
`ALTER TABLE "core"."connectedAccount"
68+
ADD CONSTRAINT "FK_40de45e67a285dafb84e510cdc6"
69+
FOREIGN KEY ("connectionProviderId") REFERENCES "core"."connectionProvider"("id")
70+
ON DELETE CASCADE ON UPDATE NO ACTION`,
71+
);
72+
73+
await queryRunner.query(
74+
`ALTER TABLE "core"."connectedAccount"
75+
ADD CONSTRAINT "FK_21b8e7d3a21ff5712c4dd4875ac"
76+
FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id")
77+
ON DELETE CASCADE ON UPDATE NO ACTION`,
78+
);
79+
}
80+
81+
public async down(queryRunner: QueryRunner): Promise<void> {
82+
await queryRunner.query(
83+
`ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT "FK_21b8e7d3a21ff5712c4dd4875ac"`,
84+
);
85+
await queryRunner.query(
86+
`ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT "FK_40de45e67a285dafb84e510cdc6"`,
87+
);
88+
await queryRunner.query(
89+
`DROP INDEX "core"."IDX_CONNECTED_ACCOUNT_APPLICATION_ID"`,
90+
);
91+
await queryRunner.query(
92+
`DROP INDEX "core"."IDX_CONNECTED_ACCOUNT_CONNECTION_PROVIDER_ID"`,
93+
);
94+
95+
await queryRunner.query(
96+
`ALTER TABLE "core"."connectedAccount"
97+
DROP COLUMN "visibility",
98+
DROP COLUMN "name",
99+
DROP COLUMN "applicationId",
100+
DROP COLUMN "connectionProviderId"`,
101+
);
102+
103+
await queryRunner.query(
104+
`ALTER TABLE "core"."connectionProvider" DROP CONSTRAINT "FK_a2553b431536a5b93211012f984"`,
105+
);
106+
await queryRunner.query(
107+
`ALTER TABLE "core"."connectionProvider" DROP CONSTRAINT "FK_16d8e4d029dd986268d759a2257"`,
108+
);
109+
await queryRunner.query(
110+
`DROP INDEX "core"."IDX_44a4fc17a91603c38daabfd4d8"`,
111+
);
112+
await queryRunner.query(
113+
`DROP INDEX "core"."IDX_CONNECTION_PROVIDER_APPLICATION_ID"`,
114+
);
115+
await queryRunner.query(`DROP TABLE "core"."connectionProvider"`);
116+
}
117+
}

packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { AddUpgradeMigrationWorkspaceIdIndexFastInstanceCommand } from 'src/data
2222
import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777455269302-add-cache-tokens-to-agent-chat-thread';
2323
import { AddLogoToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777539664664-add-logo-to-application';
2424
import { AddDeletedAtToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777682000000-add-deleted-at-to-agent-chat-thread';
25+
import { ConnectionProviderSyncableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777896012579-connection-provider-syncable-entity';
2526
import { RemoveUserDefaultAvatarUrlFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777915958318-remove-user-default-avatar-url';
2627

2728
export const INSTANCE_COMMANDS = [
@@ -47,5 +48,6 @@ export const INSTANCE_COMMANDS = [
4748
AddCacheTokensToAgentChatThreadFastInstanceCommand,
4849
AddLogoToApplicationFastInstanceCommand,
4950
AddDeletedAtToAgentChatThreadFastInstanceCommand,
51+
ConnectionProviderSyncableEntityFastInstanceCommand,
5052
RemoveUserDefaultAvatarUrlFastInstanceCommand,
5153
];

0 commit comments

Comments
 (0)