Add defineUninstallLogicFunction hook for applications (#23227)

This commit is contained in:
martmull
2026-07-24 10:41:22 +02:00
committed by GitHub
parent cada1ef6d7
commit fb52635d2a
31 changed files with 370 additions and 18 deletions
@@ -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 }`).
@@ -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)* — keyvalue 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.
@@ -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({
</Accordion>
</AccordionGroup>
## 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<void> => {
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,
});
```
@@ -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.
</Card>
<Card title="Install Hooks" icon="wrench" href="/developers/extend/apps/config/install-hooks">
`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.
</Card>
</CardGroup>
@@ -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.
<Note>
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).
@@ -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
<CardGroup cols={2}>
<Card title="Config" icon="screwdriver-wrench" href="/developers/extend/apps/config/overview">
Application identity, default role, and install hooks.
Application identity, default role, and install and uninstall hooks.
</Card>
<Card title="Data" icon="database" href="/developers/extend/apps/data/overview">
Objects, fields, and bidirectional relations.
@@ -149,7 +149,7 @@ Full reference: [Concepts](/developers/extend/apps/getting-started/concepts).
<CardGroup cols={2}>
<Card title="Config" icon="screwdriver-wrench" href="/developers/extend/apps/config/overview">
Application identity, default role, install hooks, public assets.
Application identity, default role, install and uninstall hooks, public assets.
</Card>
<Card title="Data" icon="database" href="/developers/extend/apps/data/overview">
Objects, fields, and bidirectional relations.
@@ -612,7 +612,7 @@ const handler = async (params: { parentMessageId?: string }) => {
</Note>
<Note>
**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`.
</Note>
## Typed API clients (twenty-client-sdk)
@@ -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`)
@@ -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('');
});
@@ -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`;
@@ -91,6 +91,8 @@ export const SettingsApplicationDetailContentTab = ({
preInstallUniversalIdentifier:
manifestContent?.application?.preInstallLogicFunction
?.universalIdentifier,
uninstallUniversalIdentifier:
manifestContent?.application?.uninstallLogicFunction?.universalIdentifier,
};
const logicFunctionRows: ApplicationContentRow[] = isDefined(
@@ -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);
@@ -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 <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);
@@ -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 ===
@@ -85,6 +85,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
"definePreInstallLogicFunction",
"defineRole",
"defineSkill",
"defineUninstallLogicFunction",
"defineView",
"defineViewField",
],
@@ -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;
@@ -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,
@@ -73,6 +73,7 @@ const findUniversalIdentifiers = (obj: object): string[] => {
if (
key === 'postInstallLogicFunction' ||
key === 'preInstallLogicFunction' ||
key === 'uninstallLogicFunction' ||
key === 'onConnectLogicFunction'
) {
continue;
@@ -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
@@ -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,
@@ -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',
);
});
});
@@ -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,
});
};
@@ -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;
};
@@ -0,0 +1,7 @@
export type UninstallPayload = {
version?: string;
};
export type UninstallHandler = (
payload: UninstallPayload,
) => any | Promise<any>;
@@ -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 {
@@ -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,
});
}
@@ -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,
@@ -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<ApplicationRegistrationEntity>,
) {}
public async synchronizeFromManifest({
@@ -257,9 +264,11 @@ export class ApplicationSyncService {
public async uninstallApplication({
workspaceId,
applicationUniversalIdentifier,
shouldRunUninstallHook = true,
}: {
workspaceId: string;
applicationUniversalIdentifier: string;
shouldRunUninstallHook?: boolean;
}): Promise<WorkspaceMigration> {
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<void> {
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,
@@ -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.
@@ -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,
@@ -0,0 +1,3 @@
import type { SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
export type UninstallLogicFunctionApplicationManifest = SyncableEntityOptions;