From fb52635d2a8d1d718f6f42c53915264c17b09e3a Mon Sep 17 00:00:00 2001 From: martmull Date: Fri, 24 Jul 2026 10:41:22 +0200 Subject: [PATCH] Add defineUninstallLogicFunction hook for applications (#23227) --- .../references/develop-app/logic.md | 8 ++ .../extend/apps/config/application.mdx | 4 +- .../extend/apps/config/install-hooks.mdx | 50 +++++++++++- .../extend/apps/config/overview.mdx | 9 ++- .../extend/apps/getting-started/concepts.mdx | 6 +- .../apps/getting-started/quick-start.mdx | 2 +- .../extend/apps/logic/logic-functions.mdx | 2 +- .../developers/extend/apps/operations/cli.mdx | 3 +- .../getLogicFunctionTriggerLabel.test.ts | 9 +++ .../utils/getLogicFunctionTriggerLabel.ts | 7 ++ .../SettingsApplicationDetailContentTab.tsx | 2 + .../twenty-sdk/src/cli/commands/dev/exec.ts | 14 +++- .../src/cli/commands/dev/function/index.ts | 5 +- .../twenty-sdk/src/cli/operations/execute.ts | 8 ++ ...stub-twenty-sdk-define.plugin.spec.ts.snap | 1 + .../build/manifest/manifest-build.ts | 20 +++++ .../build/manifest/manifest-extract-config.ts | 3 + .../build/manifest/manifest-validate.ts | 1 + .../define/common/types/define-entity.type.ts | 2 + packages/twenty-sdk/src/sdk/define/index.ts | 5 ++ .../define-uninstall-logic-function.spec.ts | 80 ++++++++++++++++++ .../define-uninstall-logic-function.ts | 26 ++++++ .../uninstall-logic-function-config.ts | 14 ++++ .../logic-functions/uninstall-payload-type.ts | 7 ++ .../src/sdk/logic-function/index.ts | 5 ++ .../application-install.service.ts | 3 + .../application-manifest.module.ts | 5 ++ .../application-sync.service.ts | 81 +++++++++++++++++++ .../src/application/applicationType.ts | 2 + .../twenty-shared/src/application/index.ts | 1 + .../uninstallLogicFunctionApplicationType.ts | 3 + 31 files changed, 370 insertions(+), 18 deletions(-) create mode 100644 packages/twenty-sdk/src/sdk/define/logic-functions/__tests__/define-uninstall-logic-function.spec.ts create mode 100644 packages/twenty-sdk/src/sdk/define/logic-functions/define-uninstall-logic-function.ts create mode 100644 packages/twenty-sdk/src/sdk/define/logic-functions/uninstall-logic-function-config.ts create mode 100644 packages/twenty-sdk/src/sdk/define/logic-functions/uninstall-payload-type.ts create mode 100644 packages/twenty-shared/src/application/uninstallLogicFunctionApplicationType.ts diff --git a/packages/twenty-codex-plugin/references/develop-app/logic.md b/packages/twenty-codex-plugin/references/develop-app/logic.md index 7426c00f79..79d1431777 100644 --- a/packages/twenty-codex-plugin/references/develop-app/logic.md +++ b/packages/twenty-codex-plugin/references/develop-app/logic.md @@ -101,3 +101,11 @@ yarn twenty dev:function:exec ``` Run again after rebuilding to verify idempotency. + +## Uninstall Hook + +Use `defineUninstallLogicFunction` for best-effort cleanup of external resources when the app is uninstalled (deprovision API resources, delete remaining bots, revoke webhooks). Failures are logged and never block the uninstall. + +Uninstall hook files live alongside other logic functions (typically `src/logic-functions/uninstall.ts`). Kebab-case filename, one export per file. + +The hook runs before the app's metadata, data, and code are removed, so handlers can still query the app's objects and records. Handlers receive `UninstallPayload` (`{ version?: string }`). diff --git a/packages/twenty-docs/developers/extend/apps/config/application.mdx b/packages/twenty-docs/developers/extend/apps/config/application.mdx index 07e0d37fc9..6c5e0b46b1 100644 --- a/packages/twenty-docs/developers/extend/apps/config/application.mdx +++ b/packages/twenty-docs/developers/extend/apps/config/application.mdx @@ -9,7 +9,7 @@ Every app must have exactly one `defineApplication` call. It declares: - **Identity** — universal identifier, display name, description. - **Permissions** — which role its logic functions and front components run under. - **Variables** *(optional)* — key–value pairs exposed to your code as environment variables. -- **Pre-install / post-install hooks** *(optional)* — see [Logic Functions](/developers/extend/apps/logic/logic-functions). +- **Pre-install / post-install / uninstall hooks** *(optional)* — see [Logic Functions](/developers/extend/apps/logic/logic-functions). ```ts src/application-config.ts import { defineApplication } from 'twenty-sdk/define'; @@ -33,7 +33,7 @@ Notes: - `universalIdentifier` fields are deterministic IDs you own. Generate them once and keep them stable across syncs. - `applicationVariables` become environment variables for your functions and front components. In logic functions (server-side), they are available as `process.env.VARIABLE_NAME`. In front components, use `getApplicationVariable('VARIABLE_NAME')` from `twenty-sdk/front-component`. Variables marked with `isSecret: true` are only injected into logic functions. Front components receive only non-secret variables. - The default role is detected automatically from the role file marked with [`defineApplicationRole()`](/developers/extend/apps/config/roles) — you do not need to reference it from `defineApplication()`. -- Pre-install and post-install functions are detected automatically during the manifest build — you do not need to reference them in `defineApplication()`. +- Pre-install, post-install, and uninstall functions are detected automatically during the manifest build — you do not need to reference them in `defineApplication()`. - Passing `defaultRoleUniversalIdentifier` explicitly is still supported for backward compatibility, but is deprecated in favor of `defineApplicationRole()`. - `serverVariables` are instance-scoped configuration and secrets (e.g. API keys). Unlike `applicationVariables`, they declare no value in the manifest — the workspace operator fills them in from the app's settings, and they are injected into logic functions only once set. diff --git a/packages/twenty-docs/developers/extend/apps/config/install-hooks.mdx b/packages/twenty-docs/developers/extend/apps/config/install-hooks.mdx index de271fe720..ad722d2604 100644 --- a/packages/twenty-docs/developers/extend/apps/config/install-hooks.mdx +++ b/packages/twenty-docs/developers/extend/apps/config/install-hooks.mdx @@ -1,12 +1,12 @@ --- title: Install Hooks -description: Run logic before or after the install — seed data, back up records, validate the upgrade. +description: Run logic during the install, upgrade, or uninstall lifecycle — seed data, back up records, validate the upgrade, clean up external resources. icon: "wrench" --- -Install hooks are special logic functions that run during the install or upgrade lifecycle. They share the same handler runtime as regular [logic functions](/developers/extend/apps/logic/logic-functions) and receive an `InstallPayload` (`{ previousVersion?: string; newVersion: string }` — `previousVersion` is `undefined` on a fresh install), but they're declared with their own define functions and live outside the normal trigger model (HTTP, cron, database events). +Install hooks are special logic functions that run during the install, upgrade, or uninstall lifecycle. They share the same handler runtime as regular [logic functions](/developers/extend/apps/logic/logic-functions), but they're declared with their own define functions and live outside the normal trigger model (HTTP, cron, database events). Install hooks receive an `InstallPayload` (`{ previousVersion?: string; newVersion: string }` — `previousVersion` is `undefined` on a fresh install); the uninstall hook receives an `UninstallPayload` (`{ version?: string }` — the version being removed). -Each app may define **at most one pre-install** and **at most one post-install** function. The manifest build errors if more than one of either is detected. +Each app may define **at most one** of each hook (pre-install, post-install, uninstall). The manifest build errors if more than one of any kind is detected. ``` ┌─────────────────────────────────────────────────────────────┐ @@ -142,3 +142,47 @@ export default definePreInstallLogicFunction({ + +## Uninstall hook + +`defineUninstallLogicFunction` declares a hook that runs when a user uninstalls your app. It executes **before** the app's metadata, data, and code are removed — once the deletion migration runs there is nothing left to execute — so your handler can still query the app's objects and records. Use it for cleanup of external resources: deprovision API resources, delete remaining bots, revoke webhooks. + +Notes: + +- The hook is best-effort: it runs synchronously, but a failure is logged and **never blocks the uninstall** — cleanup must not make an app impossible to remove. +- It receives `UninstallPayload` (`{ version?: string }` — the version being removed). +- It does **not** run when a failed fresh install is rolled back — the app never finished installing. +- The hook cannot run after the app is gone, so external cleanup that depends on app data (e.g. bot IDs stored in records) belongs here, not in an external scheduled job. +- Like the install hooks, it is **not executed in dev mode** — trigger it manually instead: + +```bash filename="Terminal" +yarn twenty dev:function:exec --uninstall +``` + +```ts src/logic-functions/uninstall.ts +import { defineUninstallLogicFunction, type UninstallPayload } from 'twenty-sdk/define'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +const handler = async (_payload: UninstallPayload): Promise => { + const client = new CoreApiClient(); + const { meetingBots } = await client.query({ + meetingBots: { edges: { node: { id: true, externalBotId: true } } }, + }); + + // Delete the provider-side bots so nothing keeps recording after uninstall. + for (const { node } of meetingBots.edges) { + await fetch(`https://api.recorder.example/bots/${node.externalBotId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${process.env.RECORDER_API_KEY}` }, + }); + } +}; + +export default defineUninstallLogicFunction({ + universalIdentifier: 'b2c3d4e5-6789-01bc-def0-234567890abc', + name: 'uninstall', + description: 'Deletes remaining recorder bots when the app is uninstalled.', + timeoutSeconds: 300, + handler, +}); +``` diff --git a/packages/twenty-docs/developers/extend/apps/config/overview.mdx b/packages/twenty-docs/developers/extend/apps/config/overview.mdx index 4d642b2af4..9040347101 100644 --- a/packages/twenty-docs/developers/extend/apps/config/overview.mdx +++ b/packages/twenty-docs/developers/extend/apps/config/overview.mdx @@ -23,6 +23,11 @@ A Twenty app's **config layer** is what describes the app *to the platform* — └──────────────────────────────────┘ ┌──────────────────────────────────┐ │ Post-install hook │ after metadata migration + └──────────────────────────────────┘ + │ + ▼ (at uninstall time) + ┌──────────────────────────────────┐ + │ Uninstall hook │ before app removal └──────────────────────────────────┘ ``` @@ -36,7 +41,7 @@ A Twenty app's **config layer** is what describes the app *to the platform* — `defineRole` — declare what your app's logic functions can read and write. - `definePreInstallLogicFunction` and `definePostInstallLogicFunction` — back up data, seed defaults, validate upgrades. + `definePreInstallLogicFunction`, `definePostInstallLogicFunction`, and `defineUninstallLogicFunction` — back up data, seed defaults, validate upgrades, clean up on removal. @@ -44,7 +49,7 @@ A Twenty app's **config layer** is what describes the app *to the platform* — - **Application** is the entry point. Every app has exactly one `defineApplication()` call, and it points at one **Role** as its default. - The **Role** controls what the app's logic functions and front components can read and write. Follow least-privilege: only grant the permissions your code actually needs. -- **Install Hooks** run during install or upgrade — pre-install before the metadata migration (so it can refuse a risky upgrade), post-install after the migration (so it can seed default data against the new schema). +- **Install Hooks** run during install or upgrade — pre-install before the metadata migration (so it can refuse a risky upgrade), post-install after the migration (so it can seed default data against the new schema). The uninstall hook runs right before the app is removed, so it can clean up external resources while the app's data is still readable. Install hooks share the [logic function](/developers/extend/apps/logic/logic-functions) runtime — same handler signature, same environment variables, same typed API client — but they're declared with their own define functions and live outside the regular trigger model (HTTP, cron, database events). diff --git a/packages/twenty-docs/developers/extend/apps/getting-started/concepts.mdx b/packages/twenty-docs/developers/extend/apps/getting-started/concepts.mdx index b3137ef37d..0e78b9d712 100644 --- a/packages/twenty-docs/developers/extend/apps/getting-started/concepts.mdx +++ b/packages/twenty-docs/developers/extend/apps/getting-started/concepts.mdx @@ -69,7 +69,7 @@ your-app/ ├─────────────────────────────────────────────────────────┤ │ Install flow │ │ upload → [pre-install] → metadata migration → │ -│ generate SDK → [post-install] │ +│ generate SDK → [post-install] → … → [uninstall] │ ├─────────────────────────────────────────────────────────┤ │ Publish │ │ npm publish → appears in Twenty marketplace │ @@ -78,13 +78,13 @@ your-app/ - **`yarn twenty dev`** — watches your source files and live-syncs changes to a connected Twenty server. The typed API client is regenerated automatically when the schema changes. - **`yarn twenty dev:build`** — compiles TypeScript, bundles logic functions and front components with esbuild, and produces a manifest. -- **Pre/post-install hooks** — optional functions that run during installation. See [Install Hooks](/developers/extend/apps/config/install-hooks) for details. +- **Pre/post-install and uninstall hooks** — optional functions that run during installation or right before removal. See [Install Hooks](/developers/extend/apps/config/install-hooks) for details. ## Next steps - Application identity, default role, and install hooks. + Application identity, default role, and install and uninstall hooks. Objects, fields, and bidirectional relations. diff --git a/packages/twenty-docs/developers/extend/apps/getting-started/quick-start.mdx b/packages/twenty-docs/developers/extend/apps/getting-started/quick-start.mdx index 7550d339c8..925a96700a 100644 --- a/packages/twenty-docs/developers/extend/apps/getting-started/quick-start.mdx +++ b/packages/twenty-docs/developers/extend/apps/getting-started/quick-start.mdx @@ -149,7 +149,7 @@ Full reference: [Concepts](/developers/extend/apps/getting-started/concepts). - Application identity, default role, install hooks, public assets. + Application identity, default role, install and uninstall hooks, public assets. Objects, fields, and bidirectional relations. diff --git a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx index 80cd7f18cd..1d295b38c2 100644 --- a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx +++ b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx @@ -612,7 +612,7 @@ const handler = async (params: { parentMessageId?: string }) => { -**Install hooks** — pre-install and post-install handlers — share this runtime but are declared with their own define functions and don't take trigger settings. See [Install Hooks](/developers/extend/apps/config/install-hooks) for `definePreInstallLogicFunction` and `definePostInstallLogicFunction`. +**Install hooks** — pre-install, post-install, and uninstall handlers — share this runtime but are declared with their own define functions and don't take trigger settings. See [Install Hooks](/developers/extend/apps/config/install-hooks) for `definePreInstallLogicFunction`, `definePostInstallLogicFunction`, and `defineUninstallLogicFunction`. ## Typed API clients (twenty-client-sdk) diff --git a/packages/twenty-docs/developers/extend/apps/operations/cli.mdx b/packages/twenty-docs/developers/extend/apps/operations/cli.mdx index 9045311115..ddfa00bc8e 100644 --- a/packages/twenty-docs/developers/extend/apps/operations/cli.mdx +++ b/packages/twenty-docs/developers/extend/apps/operations/cli.mdx @@ -38,9 +38,10 @@ yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf # Pass a JSON payload yarn twenty dev:function:exec -n create-new-post-card -p '{"name": "Hello"}' -# Execute the install hooks +# Execute the install and uninstall hooks yarn twenty dev:function:exec --postInstall yarn twenty dev:function:exec --preInstall +yarn twenty dev:function:exec --uninstall ``` ## Viewing function logs (`yarn twenty dev:function:logs`) diff --git a/packages/twenty-front/src/modules/logic-functions/utils/__tests__/getLogicFunctionTriggerLabel.test.ts b/packages/twenty-front/src/modules/logic-functions/utils/__tests__/getLogicFunctionTriggerLabel.test.ts index 9493456cee..c541434a96 100644 --- a/packages/twenty-front/src/modules/logic-functions/utils/__tests__/getLogicFunctionTriggerLabel.test.ts +++ b/packages/twenty-front/src/modules/logic-functions/utils/__tests__/getLogicFunctionTriggerLabel.test.ts @@ -19,6 +19,15 @@ describe('getLogicFunctionTriggerLabel', () => { ).toBe('Pre-install'); }); + it('returns Uninstall when the function matches the uninstall identifier', () => { + expect( + getLogicFunctionTriggerLabel( + { universalIdentifier: 'uid-uninstall' }, + { uninstallUniversalIdentifier: 'uid-uninstall' }, + ), + ).toBe('Uninstall'); + }); + it('does not match when both identifiers are undefined', () => { expect(getLogicFunctionTriggerLabel({}, {})).toBe(''); }); diff --git a/packages/twenty-front/src/modules/logic-functions/utils/getLogicFunctionTriggerLabel.ts b/packages/twenty-front/src/modules/logic-functions/utils/getLogicFunctionTriggerLabel.ts index acc00be398..b1436aa4e6 100644 --- a/packages/twenty-front/src/modules/logic-functions/utils/getLogicFunctionTriggerLabel.ts +++ b/packages/twenty-front/src/modules/logic-functions/utils/getLogicFunctionTriggerLabel.ts @@ -15,6 +15,7 @@ export const getLogicFunctionTriggerLabel = ( options: { postInstallUniversalIdentifier?: string; preInstallUniversalIdentifier?: string; + uninstallUniversalIdentifier?: string; } = {}, ): string => { if ( @@ -29,6 +30,12 @@ export const getLogicFunctionTriggerLabel = ( ) { return t`Pre-install`; } + if ( + isDefined(lf.universalIdentifier) && + lf.universalIdentifier === options.uninstallUniversalIdentifier + ) { + return t`Uninstall`; + } if (isDefined(lf.toolTriggerSettings)) return t`AI tool`; if (isDefined(lf.workflowActionTriggerSettings)) return t`Workflow action`; if (lf.cronTriggerSettings) return t`Cron`; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx index ddd30f1eba..677e27fa12 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx @@ -91,6 +91,8 @@ export const SettingsApplicationDetailContentTab = ({ preInstallUniversalIdentifier: manifestContent?.application?.preInstallLogicFunction ?.universalIdentifier, + uninstallUniversalIdentifier: + manifestContent?.application?.uninstallLogicFunction?.universalIdentifier, }; const logicFunctionRows: ApplicationContentRow[] = isDefined( diff --git a/packages/twenty-sdk/src/cli/commands/dev/exec.ts b/packages/twenty-sdk/src/cli/commands/dev/exec.ts index 5c9e9672f6..e09c81fa17 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/exec.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/exec.ts @@ -10,6 +10,7 @@ export class LogicFunctionExecuteCommand { appPath = CURRENT_EXECUTION_DIRECTORY, postInstall = false, preInstall = false, + uninstall = false, functionUniversalIdentifier, functionName, payload = '{}', @@ -17,6 +18,7 @@ export class LogicFunctionExecuteCommand { appPath?: string; postInstall?: boolean; preInstall?: boolean; + uninstall?: boolean; functionUniversalIdentifier?: string; functionName?: string; payload?: string; @@ -35,7 +37,9 @@ export class LogicFunctionExecuteCommand { ? 'post install' : preInstall ? 'pre install' - : (functionUniversalIdentifier ?? functionName); + : uninstall + ? 'uninstall' + : (functionUniversalIdentifier ?? functionName); const remoteName = ConfigService.getActiveRemote(); @@ -48,9 +52,11 @@ export class LogicFunctionExecuteCommand { ? { appPath, postInstall: true as const, payload: parsedPayload } : preInstall ? { appPath, preInstall: true as const, payload: parsedPayload } - : functionUniversalIdentifier - ? { appPath, functionUniversalIdentifier, payload: parsedPayload } - : { appPath, functionName: functionName!, payload: parsedPayload }; + : uninstall + ? { appPath, uninstall: true as const, payload: parsedPayload } + : functionUniversalIdentifier + ? { appPath, functionUniversalIdentifier, payload: parsedPayload } + : { appPath, functionName: functionName!, payload: parsedPayload }; const result = await functionExecute(executeOptions); diff --git a/packages/twenty-sdk/src/cli/commands/dev/function/index.ts b/packages/twenty-sdk/src/cli/commands/dev/function/index.ts index 37d0ef0347..d5b3f2f4e7 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/function/index.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/function/index.ts @@ -39,6 +39,7 @@ export const registerDevFunctionCommands = (program: Command): void => { .description('Execute a logic function') .option('--postInstall', 'Execute post-install logic function if defined') .option('--preInstall', 'Execute pre-install logic function if defined') + .option('--uninstall', 'Execute uninstall logic function if defined') .option( '-p, --payload ', 'JSON payload to send to the function', @@ -58,6 +59,7 @@ export const registerDevFunctionCommands = (program: Command): void => { options?: { postInstall?: boolean; preInstall?: boolean; + uninstall?: boolean; payload?: string; functionUniversalIdentifier?: string; functionName?: string; @@ -66,12 +68,13 @@ export const registerDevFunctionCommands = (program: Command): void => { if ( !options?.postInstall && !options?.preInstall && + !options?.uninstall && !options?.functionUniversalIdentifier && !options?.functionName ) { console.error( chalk.red( - 'Error: Either --postInstall, --preInstall, --functionName (-n), or --functionUniversalIdentifier (-u) is required.', + 'Error: Either --postInstall, --preInstall, --uninstall, --functionName (-n), or --functionUniversalIdentifier (-u) is required.', ), ); process.exit(1); diff --git a/packages/twenty-sdk/src/cli/operations/execute.ts b/packages/twenty-sdk/src/cli/operations/execute.ts index 2c9d04c49e..9f61581b9f 100644 --- a/packages/twenty-sdk/src/cli/operations/execute.ts +++ b/packages/twenty-sdk/src/cli/operations/execute.ts @@ -17,6 +17,7 @@ export type FunctionExecuteOptions = { } & ( | { postInstall: true } | { preInstall: true } + | { uninstall: true } | { functionUniversalIdentifier: string } | { functionName: string } ); @@ -41,6 +42,7 @@ const belongsToApplication = ( const resolveIdentifier = (options: FunctionExecuteOptions): string => { if ('postInstall' in options) return 'post install'; if ('preInstall' in options) return 'pre install'; + if ('uninstall' in options) return 'uninstall'; if ('functionUniversalIdentifier' in options) return options.functionUniversalIdentifier; if ('functionName' in options) return options.functionName; @@ -104,6 +106,12 @@ const innerFunctionExecute = async ( manifest.application.preInstallLogicFunction?.universalIdentifier ); } + if ('uninstall' in options && options.uninstall) { + return ( + logicFunction.universalIdentifier === + manifest.application.uninstallLogicFunction?.universalIdentifier + ); + } if ('functionUniversalIdentifier' in options) { return ( logicFunction.universalIdentifier === diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap index 9dabaadee4..27fc529f27 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap +++ b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap @@ -85,6 +85,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1 "definePreInstallLogicFunction", "defineRole", "defineSkill", + "defineUninstallLogicFunction", "defineView", "defineViewField", ], diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index d69c84532e..fabfa7b04d 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -45,6 +45,7 @@ import { type PermissionFlagManifest, type PostInstallLogicFunctionApplicationManifest, type PreInstallLogicFunctionApplicationManifest, + type UninstallLogicFunctionApplicationManifest, type RoleManifest, type SkillManifest, type StandaloneViewFieldManifest, @@ -119,6 +120,8 @@ export const buildManifest = async ( []; const preInstallLogicFunctions: PreInstallLogicFunctionApplicationManifest[] = []; + const uninstallLogicFunctions: UninstallLogicFunctionApplicationManifest[] = + []; const applicationRoleUniversalIdentifiers: string[] = []; const applicationFilePaths: string[] = []; const objectsFilePaths: string[] = []; @@ -339,6 +342,14 @@ export const buildManifest = async ( }); } + if ( + targetFunctionName === TargetFunction.DefineUninstallLogicFunction + ) { + uninstallLogicFunctions.push({ + universalIdentifier: extract.config.universalIdentifier, + }); + } + break; } case ManifestEntityKey.FrontComponents: { @@ -542,6 +553,12 @@ export const buildManifest = async ( ); } + if (uninstallLogicFunctions.length > 1) { + errors.push( + 'Only one uninstall logic function is allowed per application', + ); + } + if (applicationRoleUniversalIdentifiers.length > 1) { errors.push('Only one defineApplicationRole is allowed per application'); } @@ -591,6 +608,9 @@ export const buildManifest = async ( ...(preInstallLogicFunctions.length >= 1 ? { preInstallLogicFunction: preInstallLogicFunctions[0] } : {}), + ...(uninstallLogicFunctions.length >= 1 + ? { uninstallLogicFunction: uninstallLogicFunctions[0] } + : {}), }; })() : undefined; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts index 8594236767..3e4cf8496a 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts @@ -8,6 +8,7 @@ export enum TargetFunction { DefineLogicFunction = 'defineLogicFunction', DefinePostInstallLogicFunction = 'definePostInstallLogicFunction', DefinePreInstallLogicFunction = 'definePreInstallLogicFunction', + DefineUninstallLogicFunction = 'defineUninstallLogicFunction', DefineObject = 'defineObject', DefinePermissionFlag = 'definePermissionFlag', DefineRole = 'defineRole', @@ -59,6 +60,8 @@ export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record< ManifestEntityKey.LogicFunctions, [TargetFunction.DefinePreInstallLogicFunction]: ManifestEntityKey.LogicFunctions, + [TargetFunction.DefineUninstallLogicFunction]: + ManifestEntityKey.LogicFunctions, [TargetFunction.DefineObject]: ManifestEntityKey.Objects, [TargetFunction.DefinePermissionFlag]: ManifestEntityKey.PermissionFlags, [TargetFunction.DefineRole]: ManifestEntityKey.Roles, diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts index 35d822744e..3eccadc26f 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts @@ -73,6 +73,7 @@ const findUniversalIdentifiers = (obj: object): string[] => { if ( key === 'postInstallLogicFunction' || key === 'preInstallLogicFunction' || + key === 'uninstallLogicFunction' || key === 'onConnectLogicFunction' ) { continue; diff --git a/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts b/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts index b63dee4373..b4e3dfc663 100644 --- a/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts +++ b/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts @@ -10,6 +10,7 @@ import { type PermissionFlagConfig } from '@/sdk/define/permission-flags/permiss import { type ViewConfig } from '@/sdk/define/views/view-config'; import { type PostInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/post-install-logic-function-config'; import { type PreInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/pre-install-logic-function-config'; +import { type UninstallLogicFunctionConfig } from '@/sdk/define/logic-functions/uninstall-logic-function-config'; import { type RoleConfig } from '@/sdk/define/roles/role-config'; import { type AgentManifest, @@ -36,6 +37,7 @@ export type DefinableEntity = | LogicFunctionConfig | PostInstallLogicFunctionConfig | PreInstallLogicFunctionConfig + | UninstallLogicFunctionConfig | AgentManifest | ConnectionProviderManifest | RoleConfig diff --git a/packages/twenty-sdk/src/sdk/define/index.ts b/packages/twenty-sdk/src/sdk/define/index.ts index ba058a4b5e..82bd10881e 100644 --- a/packages/twenty-sdk/src/sdk/define/index.ts +++ b/packages/twenty-sdk/src/sdk/define/index.ts @@ -85,10 +85,15 @@ export type { export { defineLogicFunction } from '@/sdk/define/logic-functions/define-logic-function'; export { definePostInstallLogicFunction } from '@/sdk/define/logic-functions/define-post-install-logic-function'; export { definePreInstallLogicFunction } from '@/sdk/define/logic-functions/define-pre-install-logic-function'; +export { defineUninstallLogicFunction } from '@/sdk/define/logic-functions/define-uninstall-logic-function'; export type { InstallHandler, InstallPayload, } from '@/sdk/define/logic-functions/install-payload-type'; +export type { + UninstallHandler, + UninstallPayload, +} from '@/sdk/define/logic-functions/uninstall-payload-type'; export type { LogicFunctionConfig, LogicFunctionHandler, diff --git a/packages/twenty-sdk/src/sdk/define/logic-functions/__tests__/define-uninstall-logic-function.spec.ts b/packages/twenty-sdk/src/sdk/define/logic-functions/__tests__/define-uninstall-logic-function.spec.ts new file mode 100644 index 0000000000..8edc8b35a9 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/logic-functions/__tests__/define-uninstall-logic-function.spec.ts @@ -0,0 +1,80 @@ +import { defineUninstallLogicFunction } from '@/sdk/define/logic-functions/define-uninstall-logic-function'; +import { type UninstallPayload } from '@/sdk/define/logic-functions/uninstall-payload-type'; + +const mockHandler = async (payload: UninstallPayload) => ({ + success: true, + version: payload.version, +}); + +describe('defineUninstallLogicFunction', () => { + const validConfig = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Cleanup External Resources', + handler: mockHandler, + }; + + it('should return the config when valid', () => { + const result = defineUninstallLogicFunction(validConfig); + + expect(result.success).toBe(true); + expect(result.config).toEqual(validConfig); + }); + + it('should pass through optional fields', () => { + const config = { + ...validConfig, + description: 'Deletes remaining bots on uninstall', + timeoutSeconds: 30, + }; + + const result = defineUninstallLogicFunction(config as any); + + expect(result.success).toBe(true); + expect(result.config.description).toBe( + 'Deletes remaining bots on uninstall', + ); + expect(result.config.timeoutSeconds).toBe(30); + }); + + it('should return error when universalIdentifier is missing', () => { + const config = { + name: 'Cleanup External Resources', + handler: mockHandler, + }; + const result = defineUninstallLogicFunction(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'Uninstall logic function must have a universalIdentifier', + ); + }); + + it('should return error when handler is missing', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Cleanup External Resources', + }; + + const result = defineUninstallLogicFunction(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'Uninstall logic function must have a handler', + ); + }); + + it('should return error when handler is not a function', () => { + const config = { + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Cleanup External Resources', + handler: 'not-a-function', + }; + + const result = defineUninstallLogicFunction(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'Uninstall logic function handler must be a function', + ); + }); +}); diff --git a/packages/twenty-sdk/src/sdk/define/logic-functions/define-uninstall-logic-function.ts b/packages/twenty-sdk/src/sdk/define/logic-functions/define-uninstall-logic-function.ts new file mode 100644 index 0000000000..5bb4334d7e --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/logic-functions/define-uninstall-logic-function.ts @@ -0,0 +1,26 @@ +import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result'; +import type { DefineEntity } from '@/sdk/define/common/types/define-entity.type'; +import { type UninstallLogicFunctionConfig } from '@/sdk/define/logic-functions/uninstall-logic-function-config'; + +export const defineUninstallLogicFunction: DefineEntity< + UninstallLogicFunctionConfig +> = (config) => { + const errors = []; + + if (!config.universalIdentifier) { + errors.push('Uninstall logic function must have a universalIdentifier'); + } + + if (!config.handler) { + errors.push('Uninstall logic function must have a handler'); + } + + if (typeof config.handler !== 'function') { + errors.push('Uninstall logic function handler must be a function'); + } + + return createValidationResult({ + config, + errors, + }); +}; diff --git a/packages/twenty-sdk/src/sdk/define/logic-functions/uninstall-logic-function-config.ts b/packages/twenty-sdk/src/sdk/define/logic-functions/uninstall-logic-function-config.ts new file mode 100644 index 0000000000..89d2fc367d --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/logic-functions/uninstall-logic-function-config.ts @@ -0,0 +1,14 @@ +import type { LogicFunctionConfig } from '@/sdk/define/logic-functions/logic-function-config'; +import type { UninstallHandler } from '@/sdk/define/logic-functions/uninstall-payload-type'; + +export type UninstallLogicFunctionConfig = Omit< + LogicFunctionConfig, + | 'cronTriggerSettings' + | 'databaseEventTriggerSettings' + | 'httpRouteTriggerSettings' + | 'toolTriggerSettings' + | 'workflowActionTriggerSettings' + | 'handler' +> & { + handler: UninstallHandler; +}; diff --git a/packages/twenty-sdk/src/sdk/define/logic-functions/uninstall-payload-type.ts b/packages/twenty-sdk/src/sdk/define/logic-functions/uninstall-payload-type.ts new file mode 100644 index 0000000000..42a5ff5bc3 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/logic-functions/uninstall-payload-type.ts @@ -0,0 +1,7 @@ +export type UninstallPayload = { + version?: string; +}; + +export type UninstallHandler = ( + payload: UninstallPayload, +) => any | Promise; diff --git a/packages/twenty-sdk/src/sdk/logic-function/index.ts b/packages/twenty-sdk/src/sdk/logic-function/index.ts index cb0484a6e5..690891810d 100644 --- a/packages/twenty-sdk/src/sdk/logic-function/index.ts +++ b/packages/twenty-sdk/src/sdk/logic-function/index.ts @@ -22,6 +22,11 @@ export type { InstallPayload, } from '@/sdk/define/logic-functions/install-payload-type'; +export type { + UninstallHandler, + UninstallPayload, +} from '@/sdk/define/logic-functions/uninstall-payload-type'; + export type { CronPayload } from '@/sdk/define/logic-functions/triggers/cron-payload-type'; export type { diff --git a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts index 76a40d354a..84378add85 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts @@ -400,9 +400,12 @@ export class ApplicationInstallService { ); if (!isVersionUpgrade) { + // Rollback of a failed fresh install: the app never finished + // installing, so the uninstall hook must not run. await this.applicationSyncService.uninstallApplication({ applicationUniversalIdentifier: universalIdentifier, workspaceId: params.workspaceId, + shouldRunUninstallHook: false, }); } diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts index 715a79799f..4a00998fd9 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts @@ -1,15 +1,18 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { ApplicationManifestApplyService } from 'src/engine/core-modules/application/application-manifest/application-manifest-apply.service'; import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service'; import { ComputeApplicationManifestAllUniversalFlatEntityMapsService } from 'src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service'; import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service'; +import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module'; import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; +import { LogicFunctionExecutorModule } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module'; import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module'; import { SdkClientModule } from 'src/engine/core-modules/sdk-client/sdk-client.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @@ -18,12 +21,14 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace @Module({ imports: [ + TypeOrmModule.forFeature([ApplicationRegistrationEntity]), ApplicationModule, ApplicationRegistrationModule, ApplicationTranslationModule, ApplicationVariableEntityModule, FeatureFlagModule, FileStorageModule, + LogicFunctionExecutorModule, PermissionsModule, SecretEncryptionModule, SdkClientModule, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts index e1c31a2070..5796f3627c 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts @@ -1,12 +1,15 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { type Manifest } from 'twenty-shared/application'; +import { Repository } from 'typeorm'; import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { PackageJson } from 'type-fest'; import { v4 } from 'uuid'; +import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum'; 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'; @@ -23,6 +26,7 @@ import { type FlatApplication } from 'src/engine/core-modules/application/types/ import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-driver-factory.token'; import { type LogicFunctionDriverFactory } from 'src/engine/core-modules/logic-function/logic-function-drivers/logic-function-driver.factory'; +import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant'; import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; @@ -44,6 +48,9 @@ export class ApplicationSyncService { private readonly applicationTranslationSyncService: ApplicationTranslationSyncService, @Inject(LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN) private readonly logicFunctionDriverFactory: LogicFunctionDriverFactory, + private readonly logicFunctionExecutorService: LogicFunctionExecutorService, + @InjectRepository(ApplicationRegistrationEntity) + private readonly appRegistrationRepository: Repository, ) {} public async synchronizeFromManifest({ @@ -257,9 +264,11 @@ export class ApplicationSyncService { public async uninstallApplication({ workspaceId, applicationUniversalIdentifier, + shouldRunUninstallHook = true, }: { workspaceId: string; applicationUniversalIdentifier: string; + shouldRunUninstallHook?: boolean; }): Promise { const application = await this.applicationService.findOneApplicationOrThrow( { universalIdentifier: applicationUniversalIdentifier, workspaceId }, @@ -272,6 +281,10 @@ export class ApplicationSyncService { ); } + if (shouldRunUninstallHook) { + await this.runUninstallHook({ application, workspaceId }); + } + const flatEntityMapsCacheKeys = Object.values(ALL_METADATA_NAME).map( getMetadataFlatEntityMapsKey, ); @@ -329,6 +342,74 @@ export class ApplicationSyncService { return validateAndBuildResult.workspaceMigration; } + // The uninstall hook must run before the deletion migration: once the + // migration is applied, the hook's logic function metadata, code, and the + // application's data are gone, so nothing can be executed anymore. It is + // best-effort cleanup: a failure must never prevent the application from + // being removed. + private async runUninstallHook({ + application, + workspaceId, + }: { + application: ApplicationEntity; + workspaceId: string; + }): Promise { + if (!isDefined(application.applicationRegistrationId)) { + return; + } + + try { + const appRegistration = await this.appRegistrationRepository.findOne({ + where: { id: application.applicationRegistrationId }, + }); + + const uninstallLogicFunction = + appRegistration?.manifest?.application.uninstallLogicFunction; + + if (!isDefined(uninstallLogicFunction)) { + return; + } + + const { flatLogicFunctionMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatLogicFunctionMaps', + ]); + + const flatLogicFunction = + flatLogicFunctionMaps.byUniversalIdentifier[ + uninstallLogicFunction.universalIdentifier + ]; + + if (!isDefined(flatLogicFunction)) { + this.logger.warn( + `Uninstall logic function "${uninstallLogicFunction.universalIdentifier}" not found for application "${application.universalIdentifier}"; skipping hook`, + ); + + return; + } + + this.logger.log( + `Executing uninstall hook for app ${application.universalIdentifier}`, + ); + + const result = await this.logicFunctionExecutorService.execute({ + logicFunctionId: flatLogicFunction.id, + workspaceId, + payload: { version: application.version ?? undefined }, + }); + + if (isDefined(result.error)) { + this.logger.warn( + `Uninstall hook failed for application ${application.universalIdentifier}: ${result.error.errorMessage}`, + ); + } + } catch (error) { + this.logger.warn( + `Uninstall hook failed for application ${application.universalIdentifier}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + private async cleanupApplicationRuntimeResources({ workspaceId, applicationUniversalIdentifier, diff --git a/packages/twenty-shared/src/application/applicationType.ts b/packages/twenty-shared/src/application/applicationType.ts index e81a04c843..b28e2de847 100644 --- a/packages/twenty-shared/src/application/applicationType.ts +++ b/packages/twenty-shared/src/application/applicationType.ts @@ -1,5 +1,6 @@ import { type PostInstallLogicFunctionApplicationManifest } from '@/application/postInstallLogicFunctionApplicationType'; import { type PreInstallLogicFunctionApplicationManifest } from '@/application/preInstallLogicFunctionApplicationType'; +import { type UninstallLogicFunctionApplicationManifest } from '@/application/uninstallLogicFunctionApplicationType'; import { type ApplicationCategory } from './applicationCategoryType'; import { type ApplicationVariables } from './applicationVariablesType'; import { type ServerVariables } from './server-variables.type'; @@ -30,6 +31,7 @@ export type ApplicationManifest = SyncableEntityOptions & { issueReportUrl?: string; postInstallLogicFunction?: PostInstallLogicFunctionApplicationManifest; preInstallLogicFunction?: PreInstallLogicFunctionApplicationManifest; + uninstallLogicFunction?: UninstallLogicFunctionApplicationManifest; /** * @deprecated Custom settings tabs are no longer supported. This property is * kept for backward compatibility with older manifests but is now ignored. diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index cf8a28015c..c1d4437cc2 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -139,6 +139,7 @@ export type { SkillManifest } from './skillManifestType'; export type { StoredOAuthConnectionProviderConfig } from './storedOAuthConnectionProviderConfigType'; export type { SyncableEntityOptions } from './syncableEntityOptionsType'; export type { ToolTriggerSettings } from './toolTriggerSettingsType'; +export type { UninstallLogicFunctionApplicationManifest } from './uninstallLogicFunctionApplicationType'; export { serializeApplicationVariableValue, deserializeApplicationVariableValue, diff --git a/packages/twenty-shared/src/application/uninstallLogicFunctionApplicationType.ts b/packages/twenty-shared/src/application/uninstallLogicFunctionApplicationType.ts new file mode 100644 index 0000000000..08f7665af5 --- /dev/null +++ b/packages/twenty-shared/src/application/uninstallLogicFunctionApplicationType.ts @@ -0,0 +1,3 @@ +import type { SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; + +export type UninstallLogicFunctionApplicationManifest = SyncableEntityOptions;