diff --git a/packages/twenty-apps/examples/hello-world/src/logic-functions/post-install.ts b/packages/twenty-apps/examples/hello-world/src/logic-functions/post-install.ts index daa29efbe2b..fd6075d9543 100644 --- a/packages/twenty-apps/examples/hello-world/src/logic-functions/post-install.ts +++ b/packages/twenty-apps/examples/hello-world/src/logic-functions/post-install.ts @@ -1,13 +1,19 @@ -import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'; +import { + definePostInstallLogicFunction, + type InstallLogicFunctionPayload, +} from 'twenty-sdk'; const handler = async (payload: InstallLogicFunctionPayload): Promise => { - console.log('Post install logic function executed successfully!', payload.previousVersion); + console.log( + 'Post install logic function executed successfully!', + payload.previousVersion, + ); }; export default definePostInstallLogicFunction({ universalIdentifier: '8c726dcc-1709-4eac-aa8b-f99960a9ec1b', name: 'post-install', description: 'Runs after installation to set up the application.', - timeoutSeconds: 300, + timeoutSeconds: 30, handler, }); diff --git a/packages/twenty-apps/examples/postcard/src/__tests__/app-install.integration-test.ts b/packages/twenty-apps/examples/postcard/src/__tests__/app-install.integration-test.ts index b05dabe928c..dc21ab87789 100644 --- a/packages/twenty-apps/examples/postcard/src/__tests__/app-install.integration-test.ts +++ b/packages/twenty-apps/examples/postcard/src/__tests__/app-install.integration-test.ts @@ -44,7 +44,9 @@ describe('App installation', () => { if (!uninstallResult.success) { console.warn( - `App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`, + `App uninstall failed: ${ + uninstallResult.error?.message ?? 'Unknown error' + }`, ); } }); diff --git a/packages/twenty-apps/examples/postcard/src/logic-functions/post-install.ts b/packages/twenty-apps/examples/postcard/src/logic-functions/post-install.ts new file mode 100644 index 00000000000..873963844d5 --- /dev/null +++ b/packages/twenty-apps/examples/postcard/src/logic-functions/post-install.ts @@ -0,0 +1,35 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { definePostInstallLogicFunction } from 'twenty-sdk'; + +const SEED_POST_CARDS = [ + { + name: 'Greetings from Paris', + content: 'Wish you were here! The Eiffel Tower is breathtaking.', + }, + { + name: 'Hello from Tokyo', + content: 'Cherry blossoms are in full bloom. Sending love!', + }, +]; + +const handler = async () => { + const client = new CoreApiClient(); + + await client.mutation({ + createPostCards: { + __args: { data: SEED_POST_CARDS as any }, + id: true, + }, + } as any); + + console.log(`Seeded ${SEED_POST_CARDS.length} post cards on install.`); + return {}; +}; + +export default definePostInstallLogicFunction({ + universalIdentifier: '852c6321-1563-4396-b7c5-9d370f3d30a9', + name: 'post-install', + description: 'Runs after installation to set up the application.', + timeoutSeconds: 30, + handler, +}); diff --git a/packages/twenty-apps/examples/postcard/src/logic-functions/pre-install.ts b/packages/twenty-apps/examples/postcard/src/logic-functions/pre-install.ts new file mode 100644 index 00000000000..c60fb743253 --- /dev/null +++ b/packages/twenty-apps/examples/postcard/src/logic-functions/pre-install.ts @@ -0,0 +1,17 @@ +import { definePreInstallLogicFunction } from 'twenty-sdk'; + +const handler = async (params: any) => { + console.log( + `Pre-install logic function executed successfully with params`, + params, + ); + return {}; +}; + +export default definePreInstallLogicFunction({ + universalIdentifier: 'bf27f558-4ec6-481f-b76e-1dbcd05aef1f', + name: 'pre-install', + description: 'Runs before migrations to set up the application.', + timeoutSeconds: 10, + handler, +}); diff --git a/packages/twenty-apps/examples/postcard/src/logic-functions/seed-post-cards.ts b/packages/twenty-apps/examples/postcard/src/logic-functions/seed-post-cards.ts deleted file mode 100644 index e0ebf422fdb..00000000000 --- a/packages/twenty-apps/examples/postcard/src/logic-functions/seed-post-cards.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { CoreApiClient } from 'twenty-client-sdk/core'; -import { definePostInstallLogicFunction } from 'twenty-sdk'; - -const POST_CARDS_TO_SEED = [ - { - name: 'Greetings from Paris', - content: - 'Wish you were here! The Eiffel Tower looks even better in person. - Alex', - }, - { - name: 'Hello from Tokyo', - content: - 'The cherry blossoms are amazing this time of year. See you soon! - Sam', - }, -]; - -const handler = async (): Promise<{ - message: string; - createdIds: string[]; -}> => { - console.log('Seeding 2 post cards...'); - const client = new CoreApiClient(); - - const createdIds: string[] = []; - - for (const postCard of POST_CARDS_TO_SEED) { - const { createPostCard } = await client.mutation({ - createPostCard: { - __args: { - data: { - name: postCard.name, - content: postCard.content, - }, - }, - id: true, - }, - }); - - if (!createPostCard?.id) { - throw new Error(`Failed to create post card "${postCard.name}"`); - } - - createdIds.push(createPostCard.id); - } - - console.log('Seeding complete!'); - return { - message: `Seeded ${createdIds.length} post cards`, - createdIds, - }; -}; - -export default definePostInstallLogicFunction({ - universalIdentifier: '9f3d8c21-b471-4a82-8e5c-6f3a7b8c9d01', - name: 'seed-post-cards', - description: 'Seeds the workspace with 2 sample post card records.', - timeoutSeconds: 10, - handler, -}); diff --git a/packages/twenty-docs/developers/extend/apps/building.mdx b/packages/twenty-docs/developers/extend/apps/building.mdx index de69582e036..ee669d24d74 100644 --- a/packages/twenty-docs/developers/extend/apps/building.mdx +++ b/packages/twenty-docs/developers/extend/apps/building.mdx @@ -644,49 +644,15 @@ export default defineLogicFunction({ **Write a good `description`.** AI agents rely on the function's `description` field to decide when to use the tool. Be specific about what the tool does and when it should be called. - - - -A pre-install function is a logic function that runs automatically before your app is installed on a workspace. This is useful for validation tasks, prerequisite checks, or preparing workspace state before the main installation proceeds. - -```ts src/logic-functions/pre-install.ts -import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'; - -const handler = async (payload: InstallLogicFunctionPayload): Promise => { - console.log('Pre install logic function executed successfully!', payload.previousVersion); -}; - -export default definePreInstallLogicFunction({ - universalIdentifier: 'e0604b9e-e946-456b-886d-3f27d9a6b324', - name: 'pre-install', - description: 'Runs before installation to prepare the application.', - timeoutSeconds: 300, - handler, -}); -``` - -You can also manually execute the pre-install function at any time using the CLI: - -```bash filename="Terminal" -yarn twenty exec --preInstall -``` - -Key points: -- Pre-install functions use `definePreInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`). -- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs). -- Only one pre-install function is allowed per application. The manifest build will error if more than one is detected. -- The function's `universalIdentifier` is automatically set as `preInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`. -- The default timeout is set to 300 seconds (5 minutes) to allow for longer preparation tasks. - -A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings. +A post-install function is a logic function that runs automatically once your app has finished installing on a workspace. The server executes it **after** the app's metadata has been synchronized and the SDK client has been generated, so the workspace is fully ready to use and the new schema is in place. Typical use cases include seeding default data, creating initial records, configuring workspace settings, or provisioning resources on third-party services. ```ts src/logic-functions/post-install.ts -import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'; +import { definePostInstallLogicFunction, type InstallPayload } from 'twenty-sdk'; -const handler = async (payload: InstallLogicFunctionPayload): Promise => { +const handler = async (payload: InstallPayload): Promise => { console.log('Post install logic function executed successfully!', payload.previousVersion); }; @@ -695,6 +661,8 @@ export default definePostInstallLogicFunction({ name: 'post-install', description: 'Runs after installation to set up the application.', timeoutSeconds: 300, + shouldRunOnVersionUpgrade: false, + shouldRunSynchronously: false, handler, }); ``` @@ -707,10 +675,168 @@ yarn twenty exec --postInstall Key points: - Post-install functions use `definePostInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`). -- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs). +- The handler receives an `InstallPayload` with `{ previousVersion?: string; newVersion: string }` — `newVersion` is the version being installed, and `previousVersion` is the version that was previously installed (or `undefined` on a fresh install). Use these values to distinguish fresh installs from upgrades and to run version-specific migration logic. +- **When the hook runs**: on fresh installs only, by default. Pass `shouldRunOnVersionUpgrade: true` if you also want it to run when the app is upgraded from a previous version. When omitted, the flag defaults to `false` and upgrades skip the hook. +- **Execution model — async by default, sync opt-in**: the `shouldRunSynchronously` flag controls *how* post-install is executed. + - `shouldRunSynchronously: false` *(default)* — the hook is **enqueued on the message queue** with `retryLimit: 3` and runs asynchronously in a worker. The install response returns as soon as the job is enqueued, so a slow or failing handler does not block the caller. The worker will retry up to three times. **Use this for long-running jobs** — seeding large datasets, calling slow third-party APIs, provisioning external resources, anything that might exceed a reasonable HTTP response window. + - `shouldRunSynchronously: true` — the hook is executed **inline during the install flow** (same executor as pre-install). The install request blocks until the handler finishes, and if it throws, the install caller receives a `POST_INSTALL_ERROR`. No automatic retries. **Use this for fast, must-complete-before-response work** — for example, emitting a validation error to the user, or quick setup that the client will rely on immediately after the install call returns. Keep in mind the metadata migration has already been applied by the time post-install runs, so a sync-mode failure does **not** roll back the schema changes — it only surfaces the error. +- Make sure your handler is idempotent. In async mode the queue may retry up to three times; in either mode the hook may run again on upgrades when `shouldRunOnVersionUpgrade: true`. +- The environment variables `APPLICATION_ID`, `APP_ACCESS_TOKEN`, and `API_URL` are available inside the handler (same as any other logic function), so you can call the Twenty API with an application access token scoped to your app. - Only one post-install function is allowed per application. The manifest build will error if more than one is detected. -- The function's `universalIdentifier` is automatically set as `postInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`. +- The function's `universalIdentifier`, `shouldRunOnVersionUpgrade`, and `shouldRunSynchronously` are automatically attached to the application manifest under the `postInstallLogicFunction` field during the build — you do not need to reference them in `defineApplication()`. - The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding. +- **Not executed in dev mode**: when an app is registered locally (via `yarn twenty dev`), the server skips the install flow entirely and syncs files directly through the CLI watcher — so post-install never runs in dev mode, regardless of `shouldRunSynchronously`. Use `yarn twenty exec --postInstall` to trigger it manually against a running workspace. + + + + +A pre-install function is a logic function that runs automatically during installation, **before the workspace metadata migration is applied**. It shares the same payload shape as post-install (`InstallPayload`), but it is positioned earlier in the install flow so it can prepare state that the upcoming migration depends on — typical uses include backing up data, validating compatibility with the new schema, or archiving records that are about to be restructured or dropped. + +```ts src/logic-functions/pre-install.ts +import { definePreInstallLogicFunction, type InstallPayload } from 'twenty-sdk'; + +const handler = async (payload: InstallPayload): Promise => { + console.log('Pre install logic function executed successfully!', payload.previousVersion); +}; + +export default definePreInstallLogicFunction({ + universalIdentifier: 'a1b2c3d4-5678-90ab-cdef-1234567890ab', + name: 'pre-install', + description: 'Runs before installation to prepare the application.', + timeoutSeconds: 300, + shouldRunOnVersionUpgrade: true, + handler, +}); +``` + +You can also manually execute the pre-install function at any time using the CLI: + +```bash filename="Terminal" +yarn twenty exec --preInstall +``` + +Key points: +- Pre-install functions use `definePreInstallLogicFunction()` — same specialized config as post-install, just attached to a different lifecycle slot. +- Both pre- and post-install handlers receive the same `InstallPayload` type: `{ previousVersion?: string; newVersion: string }`. Import it once and reuse it for both hooks. +- **When the hook runs**: positioned just before the workspace metadata migration (`synchronizeFromManifest`). Before executing, the server runs a purely additive "pared-down sync" that registers the **new** version's pre-install function in the workspace metadata — nothing else is touched — and then executes it. Because this sync is additive-only, the previous version's objects, fields, and data are still intact when your handler runs: you can safely read and back up pre-migration state. +- **Execution model**: pre-install is executed **synchronously** and **blocks the install**. If the handler throws, the install is aborted before any schema changes are applied — the workspace stays on the previous version in a consistent state. This is intentional: pre-install is your last chance to refuse a risky upgrade. +- As with post-install, only one pre-install function is allowed per application. It is attached to the application manifest under `preInstallLogicFunction` automatically during the build. +- **Not executed in dev mode**: same as post-install — the install flow is skipped entirely for locally-registered apps, so pre-install never runs under `yarn twenty dev`. Use `yarn twenty exec --preInstall` to trigger it manually. + + + + +Both hooks are part of the same install flow and receive the same `InstallPayload`. The difference is **when** they run relative to the workspace metadata migration, and that changes what data they can safely touch. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ install flow │ +│ │ +│ upload package → [pre-install] → metadata migration → │ +│ generate SDK → [post-install] │ +│ │ +│ old schema visible new schema visible │ +└─────────────────────────────────────────────────────────────┘ +``` + +Pre-install is always **synchronous** (it blocks the install and can abort it). Post-install is **asynchronous by default** — enqueued on a worker with automatic retries — but can opt into synchronous execution with `shouldRunSynchronously: true`. See the `definePostInstallLogicFunction` accordion above for when to use each mode. + +**Use `post-install` for anything that needs the new schema to exist.** This is the common case: + +- Seeding default data (creating initial records, default views, demo content) against newly-added objects and fields. +- Registering webhooks with third-party services now that the app has its credentials. +- Calling your own API to finish setup that depends on the synchronized metadata. +- Idempotent "ensure this exists" logic that should reconcile state on every upgrade — combine with `shouldRunOnVersionUpgrade: true`. + +Example — seed a default `PostCard` record after install: + +```ts src/logic-functions/post-install.ts +import { definePostInstallLogicFunction, type InstallPayload } from 'twenty-sdk'; +import { createClient } from './generated/client'; + +const handler = async ({ previousVersion }: InstallPayload): Promise => { + if (previousVersion) return; // fresh installs only + + const client = createClient(); + await client.postCard.create({ + data: { title: 'Welcome to Postcard', content: 'Your first card!' }, + }); +}; + +export default definePostInstallLogicFunction({ + universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210', + name: 'post-install', + description: 'Seeds a welcome post card after install.', + timeoutSeconds: 300, + shouldRunOnVersionUpgrade: false, + handler, +}); +``` + +**Use `pre-install` when a migration would otherwise destroy or corrupt existing data.** Because pre-install runs against the *previous* schema and its failure rolls back the upgrade, it is the right place for anything risky: + +- **Backing up data that is about to be dropped or restructured** — e.g. you are removing a field in v2 and need to copy its values into another field or export them to storage before the migration runs. +- **Archiving records that a new constraint would invalidate** — e.g. a field is becoming `NOT NULL` and you need to delete or fix rows with null values first. +- **Validating compatibility and refusing the upgrade if the current data cannot be migrated cleanly** — throw from the handler and the install aborts with no changes applied. This is safer than discovering the incompatibility mid-migration. +- **Renaming or rekeying data** ahead of a schema change that would lose the association. + +Example — archive records before a destructive migration: + +```ts src/logic-functions/pre-install.ts +import { definePreInstallLogicFunction, type InstallPayload } from 'twenty-sdk'; +import { createClient } from './generated/client'; + +const handler = async ({ previousVersion, newVersion }: InstallPayload): Promise => { + // Only the 1.x → 2.x upgrade drops the legacy `notes` field. + if (!previousVersion?.startsWith('1.') || !newVersion.startsWith('2.')) { + return; + } + + const client = createClient(); + const legacyRecords = await client.postCard.findMany({ + where: { notes: { isNotNull: true } }, + }); + + if (legacyRecords.length === 0) return; + + // Copy legacy `notes` into the new `description` field before the migration + // drops the `notes` column. If this fails, the upgrade is aborted and the + // workspace stays on v1 with all data intact. + await Promise.all( + legacyRecords.map((record) => + client.postCard.update({ + where: { id: record.id }, + data: { description: record.notes }, + }), + ), + ); +}; + +export default definePreInstallLogicFunction({ + universalIdentifier: 'a1b2c3d4-5678-90ab-cdef-1234567890ab', + name: 'pre-install', + description: 'Backs up legacy notes into description before the v2 migration.', + timeoutSeconds: 300, + shouldRunOnVersionUpgrade: true, + handler, +}); +``` + +**Rule of thumb:** + +| You want to… | Use | +|---|---| +| Seed default data, configure the workspace, register external resources | `post-install` | +| Run long-running seeding or third-party calls that shouldn't block the install response | `post-install` (default — `shouldRunSynchronously: false`, with worker retries) | +| Run fast setup that the caller will rely on immediately after the install call returns | `post-install` with `shouldRunSynchronously: true` | +| Read or back up data that the upcoming migration would lose | `pre-install` | +| Reject an upgrade that would corrupt existing data | `pre-install` (throw from the handler) | +| Run reconciliation on every upgrade | `post-install` with `shouldRunOnVersionUpgrade: true` | +| Do one-off setup on the first install only | `post-install` with `shouldRunOnVersionUpgrade: false` (default) | + + +If in doubt, default to **post-install**. Only reach for pre-install when the migration itself is destructive and you need to intercept the previous state before it is gone. + @@ -1818,8 +1944,7 @@ yarn twenty exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf # Pass a JSON payload yarn twenty exec -n create-new-post-card -p '{"name": "Hello"}' -# Execute pre-install or post-install functions -yarn twenty exec --preInstall +# Execute the post-install function yarn twenty exec --postInstall ``` diff --git a/packages/twenty-sdk/src/cli/commands/app-command.ts b/packages/twenty-sdk/src/cli/commands/app-command.ts index 327dbbb0b04..8f92cbdf224 100644 --- a/packages/twenty-sdk/src/cli/commands/app-command.ts +++ b/packages/twenty-sdk/src/cli/commands/app-command.ts @@ -163,8 +163,8 @@ export const registerCommands = (program: Command): void => { program .command('exec [appPath]') - .option('--preInstall', 'Execute pre-install logic function if defined') .option('--postInstall', 'Execute post-install logic function if defined') + .option('--preInstall', 'Execute pre-install logic function if defined') .option( '-p, --payload ', 'JSON payload to send to the function', @@ -183,22 +183,22 @@ export const registerCommands = (program: Command): void => { async ( appPath?: string, options?: { - preInstall?: boolean; postInstall?: boolean; + preInstall?: boolean; payload?: string; functionUniversalIdentifier?: string; functionName?: string; }, ) => { if ( - !options?.preInstall && !options?.postInstall && + !options?.preInstall && !options?.functionUniversalIdentifier && !options?.functionName ) { console.error( chalk.red( - 'Error: Either --preInstall, --postInstall, --functionName (-n), or --functionUniversalIdentifier (-u) is required.', + 'Error: Either --postInstall, --preInstall, --functionName (-n), or --functionUniversalIdentifier (-u) is required.', ), ); process.exit(1); diff --git a/packages/twenty-sdk/src/cli/commands/exec.ts b/packages/twenty-sdk/src/cli/commands/exec.ts index 54afde5231b..0b182efa56e 100644 --- a/packages/twenty-sdk/src/cli/commands/exec.ts +++ b/packages/twenty-sdk/src/cli/commands/exec.ts @@ -7,15 +7,15 @@ import { isDefined } from 'twenty-shared/utils'; export class LogicFunctionExecuteCommand { async execute({ appPath = CURRENT_EXECUTION_DIRECTORY, - preInstall = false, postInstall = false, + preInstall = false, functionUniversalIdentifier, functionName, payload = '{}', }: { appPath?: string; - preInstall?: boolean; postInstall?: boolean; + preInstall?: boolean; functionUniversalIdentifier?: string; functionName?: string; payload?: string; @@ -30,19 +30,19 @@ export class LogicFunctionExecuteCommand { process.exit(1); } - const identifier = preInstall - ? 'pre install' - : postInstall - ? 'post install' + const identifier = postInstall + ? 'post install' + : preInstall + ? 'pre install' : (functionUniversalIdentifier ?? functionName); console.log(chalk.blue(`🚀 Executing function "${identifier}"...`)); console.log(chalk.gray(` Payload: ${JSON.stringify(parsedPayload)}\n`)); - const executeOptions = preInstall - ? { appPath, preInstall: true as const, payload: parsedPayload } - : postInstall - ? { appPath, postInstall: true as const, payload: parsedPayload } + const executeOptions = postInstall + ? { 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 }; diff --git a/packages/twenty-sdk/src/cli/operations/execute.ts b/packages/twenty-sdk/src/cli/operations/execute.ts index f9522bac5a0..2c9d04c49e8 100644 --- a/packages/twenty-sdk/src/cli/operations/execute.ts +++ b/packages/twenty-sdk/src/cli/operations/execute.ts @@ -15,8 +15,8 @@ export type FunctionExecuteOptions = { remote?: string; payload?: Record; } & ( - | { preInstall: true } | { postInstall: true } + | { preInstall: true } | { functionUniversalIdentifier: string } | { functionName: string } ); @@ -39,8 +39,8 @@ const belongsToApplication = ( }; const resolveIdentifier = (options: FunctionExecuteOptions): string => { - if ('preInstall' in options) return 'pre install'; if ('postInstall' in options) return 'post install'; + if ('preInstall' in options) return 'pre install'; if ('functionUniversalIdentifier' in options) return options.functionUniversalIdentifier; if ('functionName' in options) return options.functionName; @@ -92,16 +92,16 @@ const innerFunctionExecute = async ( ); const targetFunction = appFunctions.find((logicFunction) => { - if ('preInstall' in options && options.preInstall) { - return ( - logicFunction.universalIdentifier === - manifest.application.preInstallLogicFunctionUniversalIdentifier - ); - } if ('postInstall' in options && options.postInstall) { return ( logicFunction.universalIdentifier === - manifest.application.postInstallLogicFunctionUniversalIdentifier + manifest.application.postInstallLogicFunction?.universalIdentifier + ); + } + if ('preInstall' in options && options.preInstall) { + return ( + logicFunction.universalIdentifier === + manifest.application.preInstallLogicFunction?.universalIdentifier ); } if ('functionUniversalIdentifier' in options) { 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 6532a96bafc..52246e753c1 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 @@ -34,10 +34,14 @@ import { type RoleManifest, type SkillManifest, type ViewManifest, + type PostInstallLogicFunctionApplicationManifest, + type PreInstallLogicFunctionApplicationManifest, } from 'twenty-shared/application'; import { getInputSchemaFromSourceCode } from 'twenty-shared/logic-function'; import { assertUnreachable } from 'twenty-shared/utils'; import { addMissingFieldOptionIds } from '@/cli/utilities/build/manifest/utils/add-missing-field-option-ids'; +import { type PostInstallLogicFunctionConfig } from '@/sdk/logic-functions/post-install-logic-function-config'; +import { type PreInstallLogicFunctionConfig } from '@/sdk/logic-functions/pre-install-logic-function-config'; import { fromRoleConfigToRoleManifest } from '@/cli/utilities/build/manifest/utils/from-role-config-to-role-manifest'; import { type RoleConfig } from '@/sdk/roles/role-config'; @@ -80,9 +84,10 @@ export const buildManifest = async ( const views: ViewManifest[] = []; const navigationMenuItems: NavigationMenuItemManifest[] = []; const pageLayouts: PageLayoutManifest[] = []; - const preInstallLogicFunctionUniversalIdentifiers: string[] = []; - const postInstallLogicFunctionUniversalIdentifiers: string[] = []; - + const postInstallLogicFunctions: PostInstallLogicFunctionApplicationManifest[] = + []; + const preInstallLogicFunctions: PreInstallLogicFunctionApplicationManifest[] = + []; const applicationFilePaths: string[] = []; const objectsFilePaths: string[] = []; const fieldsFilePaths: string[] = []; @@ -231,19 +236,31 @@ export const buildManifest = async ( logicFunctionsFilePaths.push(relativePath); if ( - targetFunctionName === TargetFunction.DefinePreInstallLogicFunction + targetFunctionName === TargetFunction.DefinePostInstallLogicFunction ) { - preInstallLogicFunctionUniversalIdentifiers.push( - extract.config.universalIdentifier, - ); + const postInstallHookConfig = + extract.config as PostInstallLogicFunctionConfig; + + postInstallLogicFunctions.push({ + universalIdentifier: extract.config.universalIdentifier, + shouldRunOnVersionUpgrade: + postInstallHookConfig.shouldRunOnVersionUpgrade ?? false, + shouldRunSynchronously: + postInstallHookConfig.shouldRunSynchronously ?? false, + }); } if ( - targetFunctionName === TargetFunction.DefinePostInstallLogicFunction + targetFunctionName === TargetFunction.DefinePreInstallLogicFunction ) { - postInstallLogicFunctionUniversalIdentifiers.push( - extract.config.universalIdentifier, - ); + const preInstallHookConfig = + extract.config as PreInstallLogicFunctionConfig; + + preInstallLogicFunctions.push({ + universalIdentifier: extract.config.universalIdentifier, + shouldRunOnVersionUpgrade: + preInstallHookConfig.shouldRunOnVersionUpgrade ?? false, + }); } break; @@ -346,31 +363,29 @@ export const buildManifest = async ( ); } - if (preInstallLogicFunctionUniversalIdentifiers.length > 1) { - errors.push( - 'Only one pre install logic function is allowed per application', - ); - } - - if (postInstallLogicFunctionUniversalIdentifiers.length > 1) { + if (postInstallLogicFunctions.length > 1) { errors.push( 'Only one post install logic function is allowed per application', ); } - if (application && preInstallLogicFunctionUniversalIdentifiers.length >= 1) { + if (preInstallLogicFunctions.length > 1) { + errors.push( + 'Only one pre install logic function is allowed per application', + ); + } + + if (application && postInstallLogicFunctions.length >= 1) { application = { ...application, - preInstallLogicFunctionUniversalIdentifier: - preInstallLogicFunctionUniversalIdentifiers[0], + postInstallLogicFunction: postInstallLogicFunctions[0], }; } - if (application && postInstallLogicFunctionUniversalIdentifiers.length >= 1) { + if (application && preInstallLogicFunctions.length >= 1) { application = { ...application, - postInstallLogicFunctionUniversalIdentifier: - postInstallLogicFunctionUniversalIdentifiers[0], + preInstallLogicFunction: preInstallLogicFunctions[0], }; } 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 2c0cb4ccdc7..0d3c152b657 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 @@ -4,8 +4,8 @@ export enum TargetFunction { DefineApplication = 'defineApplication', DefineField = 'defineField', DefineLogicFunction = 'defineLogicFunction', - DefinePreInstallLogicFunction = 'definePreInstallLogicFunction', DefinePostInstallLogicFunction = 'definePostInstallLogicFunction', + DefinePreInstallLogicFunction = 'definePreInstallLogicFunction', DefineObject = 'defineObject', DefineRole = 'defineRole', DefineSkill = 'defineSkill', @@ -40,10 +40,10 @@ export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record< [TargetFunction.DefineApplication]: ManifestEntityKey.Application, [TargetFunction.DefineField]: ManifestEntityKey.Fields, [TargetFunction.DefineLogicFunction]: ManifestEntityKey.LogicFunctions, - [TargetFunction.DefinePreInstallLogicFunction]: - ManifestEntityKey.LogicFunctions, [TargetFunction.DefinePostInstallLogicFunction]: ManifestEntityKey.LogicFunctions, + [TargetFunction.DefinePreInstallLogicFunction]: + ManifestEntityKey.LogicFunctions, [TargetFunction.DefineObject]: ManifestEntityKey.Objects, [TargetFunction.DefineRole]: ManifestEntityKey.Roles, [TargetFunction.DefineSkill]: ManifestEntityKey.Skills, 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 d930c5ee435..320e4fde6b3 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 @@ -38,6 +38,14 @@ const findUniversalIdentifiers = (obj: object): string[] => { if (key === 'universalIdentifier' && typeof val === 'string') { universalIdentifiers.push(val); } + + if ( + key === 'postInstallLogicFunction' || + key === 'preInstallLogicFunction' + ) { + continue; + } + if (typeof val === 'object') { universalIdentifiers.push(...findUniversalIdentifiers(val)); } diff --git a/packages/twenty-sdk/src/sdk/application/application-config.ts b/packages/twenty-sdk/src/sdk/application/application-config.ts index 5725b9ed9d9..21957fc6da1 100644 --- a/packages/twenty-sdk/src/sdk/application/application-config.ts +++ b/packages/twenty-sdk/src/sdk/application/application-config.ts @@ -4,6 +4,6 @@ export type ApplicationConfig = Omit< ApplicationManifest, | 'packageJsonChecksum' | 'yarnLockChecksum' - | 'postInstallLogicFunctionUniversalIdentifier' - | 'preInstallLogicFunctionUniversalIdentifier' + | 'postInstallLogicFunction' + | 'preInstallLogicFunction' >; diff --git a/packages/twenty-sdk/src/sdk/common/types/define-entity.type.ts b/packages/twenty-sdk/src/sdk/common/types/define-entity.type.ts index 93fb3a70df4..7befedcf114 100644 --- a/packages/twenty-sdk/src/sdk/common/types/define-entity.type.ts +++ b/packages/twenty-sdk/src/sdk/common/types/define-entity.type.ts @@ -4,8 +4,11 @@ import { type LogicFunctionConfig } from '@/sdk/logic-functions/logic-function-c import { type ObjectConfig } from '@/sdk/objects/object-config'; import { type PageLayoutConfig } from '@/sdk/page-layouts/page-layout-config'; import { type ViewConfig } from '@/sdk/views/view-config'; +import { type PostInstallLogicFunctionConfig } from '@/sdk/logic-functions/post-install-logic-function-config'; +import { type PreInstallLogicFunctionConfig } from '@/sdk/logic-functions/pre-install-logic-function-config'; import { type RoleConfig } from '@/sdk/roles/role-config'; import { + type AgentManifest, type FieldManifest, type NavigationMenuItemManifest, type SkillManifest, @@ -23,6 +26,9 @@ export type DefinableEntity = | FieldManifest | FrontComponentConfig | LogicFunctionConfig + | PostInstallLogicFunctionConfig + | PreInstallLogicFunctionConfig + | AgentManifest | RoleConfig | SkillManifest | ViewConfig diff --git a/packages/twenty-sdk/src/sdk/index.ts b/packages/twenty-sdk/src/sdk/index.ts index b18b7e81600..95671080e7c 100644 --- a/packages/twenty-sdk/src/sdk/index.ts +++ b/packages/twenty-sdk/src/sdk/index.ts @@ -40,9 +40,9 @@ export { defineLogicFunction } from './logic-functions/define-logic-function'; export { definePostInstallLogicFunction } from './logic-functions/define-post-install-logic-function'; export { definePreInstallLogicFunction } from './logic-functions/define-pre-install-logic-function'; export type { - InstallLogicFunctionHandler, - InstallLogicFunctionPayload, -} from './logic-functions/install-logic-function-payload-type'; + InstallHandler, + InstallPayload, +} from '@/sdk/logic-functions/install-payload-type'; export type { LogicFunctionConfig, LogicFunctionHandler, diff --git a/packages/twenty-sdk/src/sdk/logic-functions/__tests__/define-post-install-logic-function.spec.ts b/packages/twenty-sdk/src/sdk/logic-functions/__tests__/define-post-install-logic-function.spec.ts index 9f517ab892b..ed6cd3b38ad 100644 --- a/packages/twenty-sdk/src/sdk/logic-functions/__tests__/define-post-install-logic-function.spec.ts +++ b/packages/twenty-sdk/src/sdk/logic-functions/__tests__/define-post-install-logic-function.spec.ts @@ -1,7 +1,7 @@ import { definePostInstallLogicFunction } from '@/sdk/logic-functions/define-post-install-logic-function'; -import { type InstallLogicFunctionPayload } from '@/sdk/logic-functions/install-logic-function-payload-type'; +import { type InstallPayload } from '@/sdk/logic-functions/install-payload-type'; -const mockHandler = async (payload: InstallLogicFunctionPayload) => ({ +const mockHandler = async (payload: InstallPayload) => ({ success: true, previousVersion: payload.previousVersion, }); @@ -24,12 +24,14 @@ describe('definePostInstallLogicFunction', () => { ...validRouteConfig, description: 'Send a postcard to a contact', timeoutSeconds: 30, + shouldRunOnVersionUpgrade: true, }; const result = definePostInstallLogicFunction(config as any); expect(result.config.description).toBe('Send a postcard to a contact'); expect(result.config.timeoutSeconds).toBe(30); + expect(result.config.shouldRunOnVersionUpgrade).toBe(true); }); it('should return error when universalIdentifier is missing', () => { diff --git a/packages/twenty-sdk/src/sdk/logic-functions/__tests__/define-pre-install-logic-function.spec.ts b/packages/twenty-sdk/src/sdk/logic-functions/__tests__/define-pre-install-logic-function.spec.ts index 30eb5d89bb8..7bd64d778ad 100644 --- a/packages/twenty-sdk/src/sdk/logic-functions/__tests__/define-pre-install-logic-function.spec.ts +++ b/packages/twenty-sdk/src/sdk/logic-functions/__tests__/define-pre-install-logic-function.spec.ts @@ -1,7 +1,7 @@ import { definePreInstallLogicFunction } from '@/sdk/logic-functions/define-pre-install-logic-function'; -import { type InstallLogicFunctionPayload } from '@/sdk/logic-functions/install-logic-function-payload-type'; +import { type InstallPayload } from '@/sdk/logic-functions/install-payload-type'; -const mockHandler = async (payload: InstallLogicFunctionPayload) => ({ +const mockHandler = async (payload: InstallPayload) => ({ success: true, previousVersion: payload.previousVersion, }); @@ -9,11 +9,11 @@ const mockHandler = async (payload: InstallLogicFunctionPayload) => ({ describe('definePreInstallLogicFunction', () => { const validRouteConfig = { universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', - name: 'Send Postcard', + name: 'Prepare Install', handler: mockHandler, }; - it('should return the config when valid with route trigger', () => { + it('should return the config when valid', () => { const result = definePreInstallLogicFunction(validRouteConfig); expect(result.config).toEqual(validRouteConfig); @@ -22,19 +22,21 @@ describe('definePreInstallLogicFunction', () => { it('should pass through optional fields', () => { const config = { ...validRouteConfig, - description: 'Send a postcard to a contact', + description: 'Prepare state before install', timeoutSeconds: 30, + shouldRunOnVersionUpgrade: true, }; const result = definePreInstallLogicFunction(config as any); - expect(result.config.description).toBe('Send a postcard to a contact'); + expect(result.config.description).toBe('Prepare state before install'); expect(result.config.timeoutSeconds).toBe(30); + expect(result.config.shouldRunOnVersionUpgrade).toBe(true); }); it('should return error when universalIdentifier is missing', () => { const config = { - name: 'Send Postcard', + name: 'Prepare Install', handler: mockHandler, }; const result = definePreInstallLogicFunction(config as any); @@ -48,7 +50,7 @@ describe('definePreInstallLogicFunction', () => { it('should return error when handler is missing', () => { const config = { universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', - name: 'Send Postcard', + name: 'Prepare Install', }; const result = definePreInstallLogicFunction(config as any); @@ -62,7 +64,7 @@ describe('definePreInstallLogicFunction', () => { it('should return error when handler is not a function', () => { const config = { universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', - name: 'Send Postcard', + name: 'Prepare Install', handler: 'not-a-function', }; diff --git a/packages/twenty-sdk/src/sdk/logic-functions/define-post-install-logic-function.ts b/packages/twenty-sdk/src/sdk/logic-functions/define-post-install-logic-function.ts index 2359e2fc430..3eb75e4b3b3 100644 --- a/packages/twenty-sdk/src/sdk/logic-functions/define-post-install-logic-function.ts +++ b/packages/twenty-sdk/src/sdk/logic-functions/define-post-install-logic-function.ts @@ -1,19 +1,9 @@ -import { type LogicFunctionConfig } from '@/sdk/logic-functions/logic-function-config'; -import { type InstallLogicFunctionHandler } from '@/sdk/logic-functions/install-logic-function-payload-type'; import { createValidationResult } from '@/sdk/common/utils/create-validation-result'; import type { DefineEntity } from '@/sdk/common/types/define-entity.type'; +import { type PostInstallLogicFunctionConfig } from '@/sdk/logic-functions/post-install-logic-function-config'; export const definePostInstallLogicFunction: DefineEntity< - Omit< - LogicFunctionConfig, - | 'cronTriggerSettings' - | 'databaseEventTriggerSettings' - | 'httpRouteTriggerSettings' - | 'isTool' - | 'handler' - > & { - handler: InstallLogicFunctionHandler; - } + PostInstallLogicFunctionConfig > = (config) => { const errors = []; diff --git a/packages/twenty-sdk/src/sdk/logic-functions/define-pre-install-logic-function.ts b/packages/twenty-sdk/src/sdk/logic-functions/define-pre-install-logic-function.ts index 8d8d9172380..48831eb86d7 100644 --- a/packages/twenty-sdk/src/sdk/logic-functions/define-pre-install-logic-function.ts +++ b/packages/twenty-sdk/src/sdk/logic-functions/define-pre-install-logic-function.ts @@ -1,19 +1,9 @@ -import { type LogicFunctionConfig } from '@/sdk/logic-functions/logic-function-config'; -import { type InstallLogicFunctionHandler } from '@/sdk/logic-functions/install-logic-function-payload-type'; import { createValidationResult } from '@/sdk/common/utils/create-validation-result'; import type { DefineEntity } from '@/sdk/common/types/define-entity.type'; +import { type PreInstallLogicFunctionConfig } from '@/sdk/logic-functions/pre-install-logic-function-config'; export const definePreInstallLogicFunction: DefineEntity< - Omit< - LogicFunctionConfig, - | 'cronTriggerSettings' - | 'databaseEventTriggerSettings' - | 'httpRouteTriggerSettings' - | 'isTool' - | 'handler' - > & { - handler: InstallLogicFunctionHandler; - } + PreInstallLogicFunctionConfig > = (config) => { const errors = []; diff --git a/packages/twenty-sdk/src/sdk/logic-functions/install-logic-function-payload-type.ts b/packages/twenty-sdk/src/sdk/logic-functions/install-logic-function-payload-type.ts deleted file mode 100644 index 7fb6e48a22f..00000000000 --- a/packages/twenty-sdk/src/sdk/logic-functions/install-logic-function-payload-type.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type InstallLogicFunctionPayload = { - previousVersion: string; -}; - -export type InstallLogicFunctionHandler = ( - payload: InstallLogicFunctionPayload, -) => any | Promise; diff --git a/packages/twenty-sdk/src/sdk/logic-functions/install-payload-type.ts b/packages/twenty-sdk/src/sdk/logic-functions/install-payload-type.ts new file mode 100644 index 00000000000..ddc03be3a37 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-functions/install-payload-type.ts @@ -0,0 +1,6 @@ +export type InstallPayload = { + previousVersion?: string; + newVersion: string; +}; + +export type InstallHandler = (payload: InstallPayload) => any | Promise; diff --git a/packages/twenty-sdk/src/sdk/logic-functions/post-install-logic-function-config.ts b/packages/twenty-sdk/src/sdk/logic-functions/post-install-logic-function-config.ts new file mode 100644 index 00000000000..f401282310c --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-functions/post-install-logic-function-config.ts @@ -0,0 +1,5 @@ +import { type PreInstallLogicFunctionConfig } from '@/sdk/logic-functions/pre-install-logic-function-config'; + +export type PostInstallLogicFunctionConfig = PreInstallLogicFunctionConfig & { + shouldRunSynchronously?: boolean; +}; diff --git a/packages/twenty-sdk/src/sdk/logic-functions/pre-install-logic-function-config.ts b/packages/twenty-sdk/src/sdk/logic-functions/pre-install-logic-function-config.ts new file mode 100644 index 00000000000..5f514f55294 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-functions/pre-install-logic-function-config.ts @@ -0,0 +1,13 @@ +import type { InstallHandler, LogicFunctionConfig } from '@/sdk'; + +export type PreInstallLogicFunctionConfig = Omit< + LogicFunctionConfig, + | 'cronTriggerSettings' + | 'databaseEventTriggerSettings' + | 'httpRouteTriggerSettings' + | 'isTool' + | 'handler' +> & { + handler: InstallHandler; + shouldRunOnVersionUpgrade?: boolean; +}; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts index e6524453b6d..12ffc47a7dd 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts @@ -33,6 +33,8 @@ export class ApplicationExceptionFilter implements ExceptionFilter { case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION: throw new UserInputError(exception); case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED: + case ApplicationExceptionCode.POST_INSTALL_ERROR: + case ApplicationExceptionCode.PRE_INSTALL_ERROR: case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED: case ApplicationExceptionCode.UPGRADE_FAILED: throw new InternalServerError(exception); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.module.ts index 47fd91d54f1..75ec535d2bb 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.module.ts @@ -10,8 +10,10 @@ import { ApplicationPackageModule } from 'src/engine/core-modules/application/ap import { ApplicationInstallResolver } from 'src/engine/core-modules/application/application-install/application-install.resolver'; import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service'; import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; +import { LogicFunctionModule } from 'src/engine/core-modules/logic-function/logic-function.module'; import { SdkClientModule } from 'src/engine/core-modules/sdk-client/sdk-client.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @Module({ imports: [ @@ -21,9 +23,11 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi ApplicationPackageModule, CacheLockModule, FeatureFlagModule, + LogicFunctionModule, SdkClientModule, PermissionsModule, FileStorageModule, + WorkspaceCacheModule, ], providers: [ApplicationInstallResolver, ApplicationInstallService], exports: [ApplicationInstallService], 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 231cd5b8dd2..f37c5636067 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 @@ -25,7 +25,17 @@ import { import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service'; import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { + LogicFunctionTriggerJob, + type LogicFunctionTriggerJobData, +} from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job'; +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; + @Injectable() export class ApplicationInstallService { private readonly logger = new Logger(ApplicationInstallService.name); @@ -37,8 +47,12 @@ export class ApplicationInstallService { private readonly applicationPackageFetcherService: ApplicationPackageFetcherService, private readonly applicationSyncService: ApplicationSyncService, private readonly fileStorageService: FileStorageService, + private readonly logicFunctionExecutorService: LogicFunctionExecutorService, private readonly cacheLockService: CacheLockService, private readonly sdkClientGenerationService: SdkClientGenerationService, + @InjectMessageQueue(MessageQueue.logicFunctionQueue) + private readonly messageQueueService: MessageQueueService, + private readonly workspaceCacheService: WorkspaceCacheService, ) {} async installApplication(params: { @@ -110,7 +124,27 @@ export class ApplicationInstallService { const universalIdentifier = appRegistration.universalIdentifier; + const existingApplication = + await this.applicationService.findByUniversalIdentifier({ + universalIdentifier, + workspaceId: params.workspaceId, + }); + + const previousVersion = existingApplication?.version ?? undefined; + + const newVersion = resolvedPackage.packageJson.version; + + if (!isDefined(newVersion)) { + throw new ApplicationException( + `Package ${universalIdentifier} has no version`, + ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED, + ); + } + + const isVersionUpgrade = isDefined(existingApplication); + const { application, wasCreated } = await this.ensureApplicationExists({ + existingApplication, universalIdentifier, name: resolvedPackage.manifest.application.displayName, workspaceId: params.workspaceId, @@ -156,6 +190,16 @@ export class ApplicationInstallService { params.workspaceId, ); + await this.runPreInstallHook({ + manifest: resolvedPackage.manifest, + workspaceId: params.workspaceId, + applicationRegistrationId: appRegistration.id, + previousVersion, + newVersion, + isVersionUpgrade, + universalIdentifier, + }); + const { hasSchemaMetadataChanged } = await this.applicationSyncService.synchronizeFromManifest({ workspaceId: params.workspaceId, @@ -171,6 +215,15 @@ export class ApplicationInstallService { }); } + await this.runPostInstallHook({ + manifest: resolvedPackage.manifest, + workspaceId: params.workspaceId, + previousVersion, + newVersion, + isVersionUpgrade, + universalIdentifier, + }); + this.logger.log( `Successfully installed app ${universalIdentifier} v${resolvedPackage.packageJson.version ?? 'unknown'}`, ); @@ -191,6 +244,185 @@ export class ApplicationInstallService { } } + private async runPreInstallHook(params: { + manifest: Manifest; + workspaceId: string; + applicationRegistrationId?: string; + previousVersion?: string; + newVersion: string; + isVersionUpgrade: boolean; + universalIdentifier: string; + }): Promise { + const { + manifest, + workspaceId, + applicationRegistrationId, + previousVersion, + newVersion, + isVersionUpgrade, + universalIdentifier, + } = params; + + if (!isDefined(manifest.application.preInstallLogicFunction)) { + return; + } + + await this.applicationSyncService.preInstallSynchronizeFromManifest({ + workspaceId: params.workspaceId, + manifest, + applicationRegistrationId, + }); + + const { + universalIdentifier: preInstallLogicFunctionUniversalIdentifier, + shouldRunOnVersionUpgrade, + } = manifest.application.preInstallLogicFunction; + + if (isVersionUpgrade && !shouldRunOnVersionUpgrade) { + this.logger.log( + `Skipping pre-install hook for app ${universalIdentifier}: version upgrade and shouldRunOnVersionUpgrade is false`, + ); + + return; + } + + const { flatLogicFunctionMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatLogicFunctionMaps', + ]); + + const flatLogicFunction = + flatLogicFunctionMaps.byUniversalIdentifier[ + preInstallLogicFunctionUniversalIdentifier + ]; + + // preInstallSynchronizeFromManifest should have registered this function + // moments ago — a miss here means the pared-down sync did not persist the + // entry, which is a real failure and should abort the install. + if (!isDefined(flatLogicFunction)) { + throw new ApplicationException( + `Pre-install logic function "${preInstallLogicFunctionUniversalIdentifier}" not found for application "${universalIdentifier}" after pre-install sync. The pared-down sync did not register the function as expected.`, + ApplicationExceptionCode.ENTITY_NOT_FOUND, + ); + } + + const payload = { previousVersion, newVersion }; + + this.logger.log( + `Executing pre-install hook for app ${universalIdentifier} with payload:`, + JSON.stringify(payload), + ); + + const result = await this.logicFunctionExecutorService.execute({ + logicFunctionId: flatLogicFunction.id, + workspaceId, + payload, + }); + + if (!isDefined(result)) { + this.logger.log('Pre-install hook executed successfully'); + } + + if (result.error) { + throw new ApplicationException( + result.error.errorMessage, + ApplicationExceptionCode.PRE_INSTALL_ERROR, + ); + } + } + + private async runPostInstallHook(params: { + manifest: Manifest; + workspaceId: string; + previousVersion?: string; + newVersion: string; + isVersionUpgrade: boolean; + universalIdentifier: string; + }): Promise { + const { + manifest, + workspaceId, + previousVersion, + newVersion, + isVersionUpgrade, + universalIdentifier, + } = params; + + if (!isDefined(manifest.application.postInstallLogicFunction)) { + return; + } + + const { + universalIdentifier: postInstallLogicFunctionUniversalIdentifier, + shouldRunOnVersionUpgrade, + shouldRunSynchronously, + } = manifest.application.postInstallLogicFunction; + + if (isVersionUpgrade && !shouldRunOnVersionUpgrade) { + this.logger.log( + `Skipping post-install hook for app ${universalIdentifier}: version upgrade and shouldRunOnVersionUpgrade is false`, + ); + + return; + } + + const { flatLogicFunctionMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatLogicFunctionMaps', + ]); + + const flatLogicFunction = + flatLogicFunctionMaps.byUniversalIdentifier[ + postInstallLogicFunctionUniversalIdentifier + ]; + + if (!isDefined(flatLogicFunction)) { + throw new ApplicationException( + `Post-install logic function "${postInstallLogicFunctionUniversalIdentifier}" not found for application "${universalIdentifier}" after sync. Manifest may reference a stale identifier.`, + ApplicationExceptionCode.ENTITY_NOT_FOUND, + ); + } + + const payload = { previousVersion, newVersion }; + + this.logger.log( + `Enqueuing post-install hook for app ${universalIdentifier} with payload:`, + JSON.stringify(payload), + ); + + if (!shouldRunSynchronously) { + await this.messageQueueService.add( + LogicFunctionTriggerJob.name, + [ + { + logicFunctionId: flatLogicFunction.id, + workspaceId, + payload, + }, + ], + { retryLimit: 3 }, + ); + return; + } + + const result = await this.logicFunctionExecutorService.execute({ + logicFunctionId: flatLogicFunction.id, + workspaceId, + payload, + }); + + if (!isDefined(result)) { + this.logger.log('Post-install hook executed successfully'); + } + + if (result.error) { + throw new ApplicationException( + result.error.errorMessage, + ApplicationExceptionCode.POST_INSTALL_ERROR, + ); + } + } + private async writeFilesToStorage( extractedDir: string, manifest: Manifest, @@ -269,19 +501,15 @@ export class ApplicationInstallService { } private async ensureApplicationExists(params: { + existingApplication: ApplicationEntity | null; universalIdentifier: string; name: string; workspaceId: string; applicationRegistrationId: string; sourceType: ApplicationRegistrationSourceType; }): Promise<{ application: ApplicationEntity; wasCreated: boolean }> { - const existing = await this.applicationService.findByUniversalIdentifier({ - universalIdentifier: params.universalIdentifier, - workspaceId: params.workspaceId, - }); - - if (isDefined(existing)) { - return { application: existing, wasCreated: false }; + if (isDefined(params.existingApplication)) { + return { application: params.existingApplication, wasCreated: false }; } const application = await this.applicationService.create({ diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts index 29504c2fdf5..9c34766ef22 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts @@ -4,15 +4,15 @@ import { type Manifest } from 'twenty-shared/application'; import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; import { isDefined } from 'twenty-shared/utils'; +import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util'; +import { computeApplicationManifestAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util'; +import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util'; import { ApplicationException, ApplicationExceptionCode, } from 'src/engine/core-modules/application/application.exception'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; -import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util'; -import { computeApplicationManifestAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util'; -import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util'; import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; 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'; @@ -33,6 +33,149 @@ export class ApplicationManifestMigrationService { private readonly applicationService: ApplicationService, ) {} + async syncPreInstallLogicFunctionFromManifest({ + manifest, + workspaceId, + ownerFlatApplication, + }: { + manifest: Manifest; + workspaceId: string; + ownerFlatApplication: FlatApplication; + }): Promise { + const preInstallLogicFunction = + manifest.application.preInstallLogicFunction; + + if (!isDefined(preInstallLogicFunction)) { + return; + } + + const preInstallLogicFunctionManifest = manifest.logicFunctions.find( + (logicFunction) => + logicFunction.universalIdentifier === + preInstallLogicFunction.universalIdentifier, + ); + + if (!isDefined(preInstallLogicFunctionManifest)) { + throw new ApplicationException( + `Pre-install logic function "${preInstallLogicFunction.universalIdentifier}" is declared on the application manifest but not present in manifest.logicFunctions`, + ApplicationExceptionCode.ENTITY_NOT_FOUND, + ); + } + + const defaultRoleManifest = manifest.roles.find( + (role) => + role.universalIdentifier === + manifest.application.defaultRoleUniversalIdentifier, + ); + + // Pared-down manifest: only the pre-install logic function, every other + // entity array intentionally empty. Combined with + // inferDeletionFromMissingEntities: false below, this produces a purely + // additive migration that registers the pre-install logic function without + // touching any previously-synced metadata (important on upgrades). + const strippedDefaultRoleManifest = isDefined(defaultRoleManifest) + ? { + ...defaultRoleManifest, + objectPermissions: [], + fieldPermissions: [], + } + : undefined; + + const preInstallOnlyManifest: Manifest = { + application: manifest.application, + objects: [], + fields: [], + logicFunctions: [preInstallLogicFunctionManifest], + frontComponents: [], + roles: isDefined(strippedDefaultRoleManifest) + ? [strippedDefaultRoleManifest] + : [], + skills: [], + agents: [], + publicAssets: [], + views: [], + navigationMenuItems: [], + pageLayouts: [], + }; + + const now = new Date().toISOString(); + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const cacheResult = await this.workspaceCacheService.getOrRecompute( + workspaceId, + [ + ...Object.values(ALL_METADATA_NAME).map(getMetadataFlatEntityMapsKey), + 'featureFlagsMap', + ], + ); + + const { featureFlagsMap, ...existingAllFlatEntityMaps } = cacheResult; + + const fromAllFlatEntityMaps = getApplicationSubAllFlatEntityMaps({ + applicationIds: [ownerFlatApplication.id], + fromAllFlatEntityMaps: existingAllFlatEntityMaps, + }); + + const toAllUniversalFlatEntityMaps = + computeApplicationManifestAllUniversalFlatEntityMaps({ + manifest: preInstallOnlyManifest, + ownerFlatApplication, + now, + }); + + const dependencyAllFlatEntityMaps = getApplicationSubAllFlatEntityMaps({ + applicationIds: + ownerFlatApplication.universalIdentifier === + TWENTY_STANDARD_APPLICATION.universalIdentifier + ? [twentyStandardFlatApplication.id] + : [ownerFlatApplication.id, twentyStandardFlatApplication.id], + fromAllFlatEntityMaps: existingAllFlatEntityMaps, + }); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigrationFromTo( + { + // inferDeletionFromMissingEntities is intentionally omitted (undefined) + // so this pared-down sync is purely additive — existing metadata for + // objects/fields/other logic functions that are absent from + // preInstallOnlyManifest are left untouched on upgrades. + buildOptions: { + isSystemBuild: false, + applicationUniversalIdentifier: + ownerFlatApplication.universalIdentifier, + }, + fromToAllFlatEntityMaps: buildFromToAllUniversalFlatEntityMaps({ + fromAllFlatEntityMaps, + toAllUniversalFlatEntityMaps, + }), + workspaceId, + dependencyAllFlatEntityMaps, + additionalCacheDataMaps: { featureFlagsMap }, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + throw new WorkspaceMigrationBuilderException( + validateAndBuildResult, + 'Validation errors occurred while syncing pre-install logic function', + ); + } + + await this.syncDefaultRoleAndSettingsCustomTab({ + manifest, + workspaceId, + ownerFlatApplication, + }); + + this.logger.log( + `Pre-install logic function synced for application ${ownerFlatApplication.universalIdentifier}`, + ); + } + async syncMetadataFromManifest({ manifest, workspaceId, 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 83c62af5ec1..08fe10fd02e 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 @@ -71,6 +71,42 @@ export class ApplicationSyncService { return syncResult; } + // Registers the application + only the pre-install logic function in + // workspace metadata so the pre-install hook can resolve and execute it + // before the main synchronizeFromManifest runs the full migrations. + // No-op when the manifest does not declare a pre-install logic function. + public async preInstallSynchronizeFromManifest({ + workspaceId, + manifest, + applicationRegistrationId, + }: { + workspaceId: string; + manifest: Manifest; + applicationRegistrationId?: string; + }): Promise { + if (!isDefined(manifest.application.preInstallLogicFunction)) { + return; + } + + const application = await this.syncApplication({ + workspaceId, + manifest, + applicationRegistrationId, + }); + + const ownerFlatApplication: FlatApplication = application; + + await this.applicationManifestMigrationService.syncPreInstallLogicFunctionFromManifest( + { + manifest, + workspaceId, + ownerFlatApplication, + }, + ); + + this.logger.log('Pre-install sync from manifest completed'); + } + private async syncApplication({ workspaceId, manifest, diff --git a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts index 75119855ab7..f5d4cf52c84 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts @@ -18,6 +18,8 @@ export enum ApplicationExceptionCode { PACKAGE_RESOLUTION_FAILED = 'PACKAGE_RESOLUTION_FAILED', TARBALL_EXTRACTION_FAILED = 'TARBALL_EXTRACTION_FAILED', UPGRADE_FAILED = 'UPGRADE_FAILED', + PRE_INSTALL_ERROR = 'PRE_INSTALL_ERROR', + POST_INSTALL_ERROR = 'POST_INSTALL_ERROR', APP_ALREADY_INSTALLED = 'APP_ALREADY_INSTALLED', CANNOT_DOWNGRADE_APPLICATION = 'CANNOT_DOWNGRADE_APPLICATION', } @@ -52,6 +54,10 @@ const getApplicationExceptionUserFriendlyMessage = ( return msg`Failed to extract tarball.`; case ApplicationExceptionCode.UPGRADE_FAILED: return msg`Application upgrade failed.`; + case ApplicationExceptionCode.PRE_INSTALL_ERROR: + return msg`Application pre-install logic function failed.`; + case ApplicationExceptionCode.POST_INSTALL_ERROR: + return msg`Application post-install logic function failed.`; case ApplicationExceptionCode.APP_ALREADY_INSTALLED: return msg`This version of the application is already installed in this workspace.`; case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION: diff --git a/packages/twenty-shared/src/application/applicationType.ts b/packages/twenty-shared/src/application/applicationType.ts index 7ae44e556aa..28385b82d2f 100644 --- a/packages/twenty-shared/src/application/applicationType.ts +++ b/packages/twenty-shared/src/application/applicationType.ts @@ -1,6 +1,8 @@ import { type ApplicationVariables } from './applicationVariablesType'; import { type ServerVariables } from './server-variables.type'; import { type SyncableEntityOptions } from './syncableEntityOptionsType'; +import { type PostInstallLogicFunctionApplicationManifest } from '@/application/postInstallLogicFunctionApplicationType'; +import { type PreInstallLogicFunctionApplicationManifest } from '@/application/preInstallLogicFunctionApplicationType'; export type ApplicationManifest = SyncableEntityOptions & { defaultRoleUniversalIdentifier: string; @@ -18,8 +20,8 @@ export type ApplicationManifest = SyncableEntityOptions & { termsUrl?: string; emailSupport?: string; issueReportUrl?: string; - preInstallLogicFunctionUniversalIdentifier?: string; - postInstallLogicFunctionUniversalIdentifier?: string; + postInstallLogicFunction?: PostInstallLogicFunctionApplicationManifest; + preInstallLogicFunction?: PreInstallLogicFunctionApplicationManifest; settingsCustomTabFrontComponentUniversalIdentifier?: string; packageJsonChecksum: string | null; yarnLockChecksum: string | null; diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index 9c2f5353c33..773bf25f3d4 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -44,6 +44,8 @@ export type { PageLayoutTabManifest, PageLayoutManifest, } from './pageLayoutManifestType'; +export type { PostInstallLogicFunctionApplicationManifest } from './postInstallLogicFunctionApplicationType'; +export type { PreInstallLogicFunctionApplicationManifest } from './preInstallLogicFunctionApplicationType'; export type { ObjectPermissionManifest, FieldPermissionManifest, diff --git a/packages/twenty-shared/src/application/postInstallLogicFunctionApplicationType.ts b/packages/twenty-shared/src/application/postInstallLogicFunctionApplicationType.ts new file mode 100644 index 00000000000..9c8df6d1b77 --- /dev/null +++ b/packages/twenty-shared/src/application/postInstallLogicFunctionApplicationType.ts @@ -0,0 +1,6 @@ +import { type PreInstallLogicFunctionApplicationManifest } from './preInstallLogicFunctionApplicationType'; + +export type PostInstallLogicFunctionApplicationManifest = + PreInstallLogicFunctionApplicationManifest & { + shouldRunSynchronously?: boolean; + }; diff --git a/packages/twenty-shared/src/application/preInstallLogicFunctionApplicationType.ts b/packages/twenty-shared/src/application/preInstallLogicFunctionApplicationType.ts new file mode 100644 index 00000000000..4a58534cd5b --- /dev/null +++ b/packages/twenty-shared/src/application/preInstallLogicFunctionApplicationType.ts @@ -0,0 +1,7 @@ +import type { SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; + +export type PreInstallLogicFunctionApplicationManifest = + SyncableEntityOptions & { + universalIdentifier: string; + shouldRunOnVersionUpgrade?: boolean; + };