1751 extensibility twenty sdk v2 use twenty sdk to define a serverless function trigger (#15347)

This PR adds 2 columns handlerPath and handlerName in serverlessFunction
to locate the entrypoint of a serverless in a codebase

It adds the following decorators in twenty-sdk:
- ServerlessFunction
- DatabaseEventTrigger
- RouteTrigger
- CronTrigger
- ApplicationVariable

It still supports deprecated entity.manifest.jsonc 

Overall code needs to be cleaned a little bit, but it should work
properly so you can try to test if the DEVX fits your needs

See updates in hello-world application

```typescript
import axios from 'axios';
import {
  DatabaseEventTrigger,
  ServerlessFunction,
  RouteTrigger,
  CronTrigger,
  ApplicationVariable,
} from 'twenty-sdk';

@ApplicationVariable({
  universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
  key: 'TWENTY_API_KEY',
  description: 'Twenty API Key',
  isSecret: true,
})
@DatabaseEventTrigger({
  universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
  eventName: 'person.created',
})
@RouteTrigger({
  universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
  path: '/post-card/create',
  httpMethod: 'GET',
  isAuthRequired: false,
})
@CronTrigger({
  universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
  pattern: '0 0 1 1 *', // Every year 1st of January
})
@ServerlessFunction({
  universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
})
class CreateNewPostCard {
  main = async (params: { recipient: string }): Promise<string> => {
    const { recipient } = params;

    const options = {
      method: 'POST',
      url: 'http://localhost:3000/rest/postCards',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
      },
      data: { name: recipient ?? 'Unknown' },
    };

    try {
      const { data } = await axios.request(options);

      console.log(`New post card to "${recipient}" created`);

      return data;
    } catch (error) {
      console.error(error);
      throw error;
    }
  };
}

export const createNewPostCardHandler = new CreateNewPostCard().main;

```


### [edit] V2 

After the v1 proposal, I see that using a class method to define the
serverless function handler is pretty confusing. Lets leave
serverlessFunction configuration decorators on the class, but move the
handler like before. Here is the v2 hello-world serverless function:

```typescript
import axios from 'axios';
import {
  DatabaseEventTrigger,
  ServerlessFunction,
  RouteTrigger,
  CronTrigger,
  ApplicationVariable,
} from 'twenty-sdk';

@ApplicationVariable({
  universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
  key: 'TWENTY_API_KEY',
  description: 'Twenty API Key',
  isSecret: true,
})
@DatabaseEventTrigger({
  universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
  eventName: 'person.created',
})
@RouteTrigger({
  universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
  path: '/post-card/create',
  httpMethod: 'GET',
  isAuthRequired: false,
})
@CronTrigger({
  universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
  pattern: '0 0 1 1 *', // Every year 1st of January
})
@ServerlessFunction({
  universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
})
export class ServerlessFunctionDefinition {}

export const main = async (params: { recipient: string }): Promise<string> => {
  const { recipient } = params;

  const options = {
    method: 'POST',
    url: 'http://localhost:3000/rest/postCards',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
    },
    data: { name: recipient ?? 'Unknown' },
  };

  try {
    const { data } = await axios.request(options);

    console.log(`New post card to "${recipient}" created`);

    return data;
  } catch (error) {
    console.error(error);
    throw error;
  }
};

```


### [edit] V3

After the v2 proposal, we don't really like decorators on empty classes.
We decided to go with a Vercel approach with a config constant

```typescript
import axios from 'axios';
import { ServerlessFunctionConfig } from 'twenty-sdk';

export const main = async (params: { recipient: string }): Promise<string> => {
  const { recipient } = params;

  const options = {
    method: 'POST',
    url: 'http://localhost:3000/rest/postCards',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
    },
    data: { name: recipient ?? 'Unknown' },
  };

  try {
    const { data } = await axios.request(options);

    console.log(`New post card to "${recipient}" created`);

    return data;
  } catch (error) {
    console.error(error);
    throw error;
  }
};

export const config: ServerlessFunctionConfig = {
  universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
  routeTriggers: [
  {
    universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
    path: '/post-card/create',
    httpMethod: 'GET',
    isAuthRequired: false,
  }
  ],
  cronTriggers: [
    {
      universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
      pattern: '0 0 1 1 *', // Every year 1st of January
    }
  ],
  databaseEventTriggers: [
  {
    universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
    eventName: 'person.created',
   }
  ]
}

```
This commit is contained in:
martmull
2025-10-29 16:51:43 +00:00
committed by GitHub
parent 75ed5cb3a2
commit a6cc80eedd
81 changed files with 2070 additions and 1044 deletions
@@ -1,5 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { parse } from 'path';
import { isDefined } from 'twenty-shared/utils';
import { ALL_METADATA_NAME, AllMetadataName } from 'twenty-shared/metadata';
@@ -11,13 +13,11 @@ import {
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
import {
AgentManifest,
ObjectManifest,
ServerlessFunctionManifest,
ServerlessFunctionTriggerManifest,
} from 'src/engine/core-modules/application/types/application.types';
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { CronTriggerV2Service } from 'src/engine/metadata-modules/cron-trigger/services/cron-trigger-v2.service';
import { FlatCronTrigger } from 'src/engine/metadata-modules/cron-trigger/types/flat-cron-trigger.type';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@@ -34,6 +34,7 @@ import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serv
import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service';
import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
import { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
@Injectable()
export class ApplicationSyncService {
@@ -47,7 +48,6 @@ export class ApplicationSyncService {
private readonly serverlessFunctionV2Service: ServerlessFunctionV2Service,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly dataSourceService: DataSourceService,
private readonly agentService: AgentService,
private readonly databaseEventTriggerV2Service: DatabaseEventTriggerV2Service,
private readonly cronTriggerV2Service: CronTriggerV2Service,
private readonly routeTriggerV2Service: RouteTriggerV2Service,
@@ -69,12 +69,6 @@ export class ApplicationSyncService {
yarnLock,
});
await this.syncAgents({
agentsToSync: manifest.agents,
workspaceId,
applicationId: application.id,
});
await this.syncObjects({
objectsToSync: manifest.objects,
workspaceId,
@@ -83,6 +77,7 @@ export class ApplicationSyncService {
await this.syncServerlessFunctions({
serverlessFunctionsToSync: manifest.serverlessFunctions,
code: manifest.sources,
workspaceId,
applicationId: application.id,
serverlessFunctionLayerId: application.serverlessFunctionLayerId,
@@ -100,10 +95,12 @@ export class ApplicationSyncService {
workspaceId: string;
}): Promise<ApplicationEntity> {
const application = await this.applicationService.findByUniversalIdentifier(
manifest.universalIdentifier,
manifest.application.universalIdentifier,
workspaceId,
);
const name = manifest.application.displayName ?? packageJson.name;
if (!isDefined(application)) {
const serverlessFunctionLayer =
await this.serverlessFunctionLayerService.create(
@@ -115,18 +112,18 @@ export class ApplicationSyncService {
);
const application = await this.applicationService.create({
universalIdentifier: manifest.universalIdentifier,
name: manifest.name,
description: manifest.description,
version: manifest.version,
universalIdentifier: manifest.application.universalIdentifier,
name,
description: manifest.application.description,
version: packageJson.version,
sourcePath: 'cli-sync', // Placeholder for CLI-synced apps
serverlessFunctionLayerId: serverlessFunctionLayer.id,
workspaceId,
});
await this.applicationVariableService.upsertManyApplicationVariableEntitys(
await this.applicationVariableService.upsertManyApplicationVariableEntities(
{
env: manifest.env,
applicationVariables: manifest.application.applicationVariables,
applicationId: application.id,
},
);
@@ -143,62 +140,21 @@ export class ApplicationSyncService {
);
await this.applicationService.update(application.id, {
name: manifest.name,
description: manifest.description,
version: manifest.version,
name,
description: manifest.application.description,
version: packageJson.version,
});
await this.applicationVariableService.upsertManyApplicationVariableEntitys({
env: manifest.env,
applicationId: application.id,
});
await this.applicationVariableService.upsertManyApplicationVariableEntities(
{
applicationVariables: manifest.application.applicationVariables,
applicationId: application.id,
},
);
return application;
}
private async syncAgents({
agentsToSync,
workspaceId,
applicationId,
}: {
agentsToSync: AgentManifest[];
workspaceId: string;
applicationId: string;
}) {
for (const agentToSync of agentsToSync) {
const existingAgent =
await this.agentService.findOneByApplicationAndStandardId({
workspaceId,
applicationId,
standardId: agentToSync.standardId,
});
if (isDefined(existingAgent)) {
await this.agentService.updateOneAgent(
{ id: existingAgent.id, ...agentToSync },
workspaceId,
);
return;
}
await this.agentService.createOneAgent(
{
name: agentToSync.name,
label: agentToSync.label,
description: agentToSync.description,
icon: agentToSync.icon,
prompt: agentToSync.prompt,
modelId: agentToSync.modelId,
standardId: agentToSync.standardId,
isCustom: true,
applicationId,
},
workspaceId,
);
}
}
private async syncObjects({
objectsToSync,
workspaceId,
@@ -224,27 +180,31 @@ export class ApplicationSyncService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any[];
const objectsToSyncStandardIds = objectsToSync.map((obj) => obj.standardId);
const objectsToSyncUniversalIds = objectsToSync.map(
(obj) => obj.universalIdentifier,
);
const applicationObjectsStandardIds = applicationObjects.map(
(obj) => obj.standardId,
(obj) => obj.universalIdentifier,
);
const objectsToDelete = applicationObjects.filter(
(obj) =>
isDefined(obj.standardId) &&
!objectsToSyncStandardIds.includes(obj.standardId),
isDefined(obj.universalIdentifier) &&
!objectsToSyncUniversalIds.includes(obj.universalIdentifier),
);
const objectsToUpdate = applicationObjects.filter(
(obj) =>
isDefined(obj.standardId) &&
objectsToSyncStandardIds.includes(obj.standardId),
isDefined(obj.universalIdentifier) &&
objectsToSyncUniversalIds.includes(obj.universalIdentifier),
);
const objectsToCreate = objectsToSync.filter(
(objectToSync) =>
!applicationObjectsStandardIds.includes(objectToSync.standardId),
!applicationObjectsStandardIds.includes(
objectToSync.universalIdentifier,
),
);
for (const objectToDelete of objectsToDelete) {
@@ -257,12 +217,12 @@ export class ApplicationSyncService {
for (const objectToUpdate of objectsToUpdate) {
const objectToSync = objectsToSync.find(
(obj) => obj.standardId === objectToUpdate.standardId,
(obj) => obj.universalIdentifier === objectToUpdate.universalIdentifier,
);
if (!objectToSync) {
throw new ApplicationException(
`Failed to find object to sync with standardId ${objectToUpdate.standardId}`,
`Failed to find object to sync with universalIdentifier ${objectToUpdate.universalIdentifier}`,
ApplicationExceptionCode.OBJECT_NOT_FOUND,
);
}
@@ -298,7 +258,8 @@ export class ApplicationSyncService {
labelPlural: objectToCreate.labelPlural,
icon: objectToCreate.icon || undefined,
description: objectToCreate.description || undefined,
standardId: objectToCreate.standardId || undefined,
standardId: objectToCreate.universalIdentifier,
universalIdentifier: objectToCreate.universalIdentifier,
dataSourceId: dataSourceMetadata.id,
applicationId,
};
@@ -312,12 +273,14 @@ export class ApplicationSyncService {
private async syncServerlessFunctions({
serverlessFunctionsToSync,
code,
workspaceId,
applicationId,
serverlessFunctionLayerId,
}: {
serverlessFunctionsToSync: ServerlessFunctionManifest[];
workspaceId: string;
code: Sources;
applicationId: string;
serverlessFunctionLayerId: string;
}) {
@@ -392,12 +355,18 @@ export class ApplicationSyncService {
);
}
const name =
serverlessFunctionToSync.name ??
parse(serverlessFunctionToSync.handlerName).name;
const updateServerlessFunctionInput = {
id: serverlessFunctionToUpdate.id,
update: {
name: serverlessFunctionToSync.name,
name,
code,
timeoutSeconds: serverlessFunctionToSync.timeoutSeconds,
code: serverlessFunctionToSync.code,
handlerPath: serverlessFunctionToSync.handlerPath,
handlerName: serverlessFunctionToSync.handlerName,
},
};
@@ -426,11 +395,17 @@ export class ApplicationSyncService {
}
for (const serverlessFunctionToCreate of serverlessFunctionsToCreate) {
const name =
serverlessFunctionToCreate.name ??
parse(serverlessFunctionToCreate.handlerName).name;
const createServerlessFunctionInput = {
name: serverlessFunctionToCreate.name,
code: serverlessFunctionToCreate.code,
name,
code,
universalIdentifier: serverlessFunctionToCreate.universalIdentifier,
timeoutSeconds: serverlessFunctionToCreate.timeoutSeconds,
handlerPath: serverlessFunctionToCreate.handlerPath,
handlerName: serverlessFunctionToCreate.handlerName,
applicationId,
serverlessFunctionLayerId,
};
@@ -659,7 +634,7 @@ export class ApplicationSyncService {
id: triggerToUpdate.id,
update: {
settings: {
pattern: triggerToSync.schedule,
pattern: triggerToSync.pattern,
},
},
};
@@ -677,7 +652,7 @@ export class ApplicationSyncService {
const createCronTriggerInput = {
settings: {
pattern: triggerToCreate.schedule,
pattern: triggerToCreate.pattern,
},
universalIdentifier: triggerToCreate.universalIdentifier,
serverlessFunctionId,
@@ -8,7 +8,6 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron-trigger.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database-event-trigger/database-event-trigger.module';
@@ -25,7 +24,6 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ObjectMetadataModule,
DataSourceModule,
AgentModule,
ApplicationVariableEntityModule,
ServerlessFunctionLayerModule,
ServerlessFunctionModule,
@@ -57,11 +57,11 @@ export class ApplicationResolver {
@Mutation(() => Boolean)
async deleteApplication(
@Args() { packageJson }: DeleteApplicationInput,
@Args() { universalIdentifier }: DeleteApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
await this.applicationSyncService.deleteApplication({
applicationUniversalIdentifier: packageJson.universalIdentifier,
applicationUniversalIdentifier: universalIdentifier,
workspaceId,
});
@@ -1,11 +1,7 @@
import { ArgsType, Field } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { PackageJson } from 'src/engine/core-modules/application/types/application.types';
@ArgsType()
export class DeleteApplicationInput {
@Field(() => GraphQLJSON, { nullable: false })
packageJson: PackageJson;
@Field(() => String)
universalIdentifier: string;
}
@@ -1,51 +1,56 @@
import { type HTTPMethod } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
export type PackageJson = {
$schema?: string;
universalIdentifier: string;
name: string;
description?: string;
license: string;
engines: {
node: string;
npm: string;
yarn: string;
};
env?: EnvManifest;
icon?: string;
packageManager: string;
version: string;
dependencies?: object;
devDependencies?: object;
};
export type EnvManifest = Record<string, EnvVariableManifest>;
export type EnvVariableManifest = {
type ApplicationVariable = {
universalIdentifier: string;
value?: string;
description?: string;
isSecret: boolean;
isSecret?: boolean;
};
export type AppManifest = PackageJson & {
agents: AgentManifest[];
type Sources = { [key: string]: string | Sources };
type Application = {
universalIdentifier: string;
displayName?: string;
description?: string;
icon?: string;
applicationVariables?: Record<string, ApplicationVariable>;
};
export type AppManifest = {
application: Application;
objects: ObjectManifest[];
serverlessFunctions: ServerlessFunctionManifest[];
sources: Sources;
};
export type ServerlessFunctionManifest = {
$schema?: string;
universalIdentifier: string;
name: string;
name?: string;
description?: string;
timeoutSeconds?: number;
handlerPath: string;
handlerName: string;
triggers: ServerlessFunctionTriggerManifest[];
code: ServerlessFunctionCode;
};
export type ServerlessFunctionTriggerManifest = (
| {
type: 'cron';
schedule: string;
pattern: string;
}
| {
type: 'databaseEvent';
@@ -62,8 +67,7 @@ export type ServerlessFunctionTriggerManifest = (
};
export type ObjectManifest = {
$schema?: string;
standardId: string;
universalIdentifier: string;
nameSingular: string;
namePlural: string;
labelSingular: string;