2024-07-17 15:53:01 +00:00
|
|
|
import {
|
2025-01-17 13:49:02 +00:00
|
|
|
Check,
|
2024-07-17 15:53:01 +00:00
|
|
|
Column,
|
|
|
|
|
CreateDateColumn,
|
2025-06-03 13:28:43 +00:00
|
|
|
DeleteDateColumn,
|
2024-07-17 15:53:01 +00:00
|
|
|
Entity,
|
2025-06-03 13:28:43 +00:00
|
|
|
Index,
|
2025-09-30 14:47:49 +00:00
|
|
|
JoinColumn,
|
|
|
|
|
ManyToOne,
|
2025-09-25 12:05:01 +00:00
|
|
|
OneToMany,
|
2024-07-17 15:53:01 +00:00
|
|
|
PrimaryGeneratedColumn,
|
2025-09-30 14:47:49 +00:00
|
|
|
Relation,
|
2024-07-17 15:53:01 +00:00
|
|
|
UpdateDateColumn,
|
|
|
|
|
} from 'typeorm';
|
|
|
|
|
|
2025-10-22 07:55:20 +00:00
|
|
|
import { CronTriggerEntity } from 'src/engine/metadata-modules/cron-trigger/entities/cron-trigger.entity';
|
|
|
|
|
import { DatabaseEventTriggerEntity } from 'src/engine/metadata-modules/database-event-trigger/entities/database-event-trigger.entity';
|
|
|
|
|
import { RouteTriggerEntity } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
|
2025-09-30 14:47:49 +00:00
|
|
|
import { ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity';
|
2026-01-08 15:45:12 +00:00
|
|
|
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
|
2024-11-05 13:57:06 +00:00
|
|
|
|
2025-01-17 13:49:02 +00:00
|
|
|
const DEFAULT_SERVERLESS_TIMEOUT_SECONDS = 300; // 5 minutes
|
|
|
|
|
|
2024-07-29 11:03:09 +00:00
|
|
|
export enum ServerlessFunctionRuntime {
|
|
|
|
|
NODE18 = 'nodejs18.x',
|
2025-06-06 16:35:30 +00:00
|
|
|
NODE22 = 'nodejs22.x',
|
2024-07-29 11:03:09 +00:00
|
|
|
}
|
|
|
|
|
|
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',
}
]
}
```
2025-10-29 16:51:43 +00:00
|
|
|
export const DEFAULT_HANDLER_PATH = 'src/index.ts';
|
|
|
|
|
export const DEFAULT_HANDLER_NAME = 'main';
|
|
|
|
|
|
2024-07-17 15:53:01 +00:00
|
|
|
@Entity('serverlessFunction')
|
2025-06-03 13:28:43 +00:00
|
|
|
@Index('IDX_SERVERLESS_FUNCTION_ID_DELETED_AT', ['id', 'deletedAt'])
|
2025-09-25 12:05:01 +00:00
|
|
|
export class ServerlessFunctionEntity
|
|
|
|
|
extends SyncableEntity
|
|
|
|
|
implements Required<ServerlessFunctionEntity>
|
|
|
|
|
{
|
2024-07-17 15:53:01 +00:00
|
|
|
@PrimaryGeneratedColumn('uuid')
|
|
|
|
|
id: string;
|
|
|
|
|
|
|
|
|
|
@Column({ nullable: false })
|
|
|
|
|
name: string;
|
|
|
|
|
|
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',
}
]
}
```
2025-10-29 16:51:43 +00:00
|
|
|
@Column({ nullable: false, default: DEFAULT_HANDLER_PATH })
|
|
|
|
|
handlerPath: string;
|
|
|
|
|
|
|
|
|
|
@Column({ nullable: false, default: DEFAULT_HANDLER_NAME })
|
|
|
|
|
handlerName: string;
|
|
|
|
|
|
2025-09-25 12:05:01 +00:00
|
|
|
@Column({ nullable: true, type: 'varchar' })
|
|
|
|
|
description: string | null;
|
2024-07-29 11:03:09 +00:00
|
|
|
|
2025-09-25 12:05:01 +00:00
|
|
|
@Column({ nullable: true, type: 'varchar' })
|
|
|
|
|
latestVersion: string | null;
|
2024-07-17 15:53:01 +00:00
|
|
|
|
2024-10-22 12:51:03 +00:00
|
|
|
@Column({ nullable: false, type: 'jsonb', default: [] })
|
|
|
|
|
publishedVersions: string[];
|
|
|
|
|
|
2025-06-06 16:35:30 +00:00
|
|
|
@Column({ nullable: false, default: ServerlessFunctionRuntime.NODE22 })
|
2024-07-29 11:03:09 +00:00
|
|
|
runtime: ServerlessFunctionRuntime;
|
|
|
|
|
|
2025-01-17 13:49:02 +00:00
|
|
|
@Column({ nullable: false, default: DEFAULT_SERVERLESS_TIMEOUT_SECONDS })
|
|
|
|
|
@Check(`"timeoutSeconds" >= 1 AND "timeoutSeconds" <= 900`)
|
|
|
|
|
timeoutSeconds: number;
|
|
|
|
|
|
2025-09-26 08:24:36 +00:00
|
|
|
@Column({ nullable: true, type: 'text' })
|
|
|
|
|
checksum: string | null;
|
|
|
|
|
|
2026-01-05 10:13:06 +00:00
|
|
|
@Column({ nullable: true, type: 'jsonb' })
|
|
|
|
|
toolInputSchema: object | null;
|
|
|
|
|
|
|
|
|
|
@Column({ nullable: false, default: false })
|
|
|
|
|
isTool: boolean;
|
|
|
|
|
|
2025-10-23 07:58:52 +00:00
|
|
|
@Column({ nullable: false, type: 'uuid' })
|
2025-10-09 10:56:59 +00:00
|
|
|
serverlessFunctionLayerId: string;
|
2025-09-30 14:47:49 +00:00
|
|
|
|
|
|
|
|
@ManyToOne(
|
|
|
|
|
() => ServerlessFunctionLayerEntity,
|
|
|
|
|
(serverlessFunctionLayer) => serverlessFunctionLayer.serverlessFunctions,
|
2025-10-09 10:56:59 +00:00
|
|
|
{ nullable: false },
|
2025-09-30 14:47:49 +00:00
|
|
|
)
|
|
|
|
|
@JoinColumn({ name: 'serverlessFunctionLayerId' })
|
2025-10-09 10:56:59 +00:00
|
|
|
serverlessFunctionLayer: Relation<ServerlessFunctionLayerEntity>;
|
2025-09-30 14:47:49 +00:00
|
|
|
|
2025-08-28 12:47:48 +00:00
|
|
|
@OneToMany(
|
2025-10-22 07:55:20 +00:00
|
|
|
() => CronTriggerEntity,
|
2025-08-28 12:47:48 +00:00
|
|
|
(cronTrigger) => cronTrigger.serverlessFunction,
|
|
|
|
|
{
|
|
|
|
|
cascade: true,
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-22 07:55:20 +00:00
|
|
|
cronTriggers: CronTriggerEntity[];
|
2025-08-28 12:47:48 +00:00
|
|
|
|
2025-08-29 06:11:35 +00:00
|
|
|
@OneToMany(
|
2025-10-22 07:55:20 +00:00
|
|
|
() => DatabaseEventTriggerEntity,
|
2025-08-29 06:11:35 +00:00
|
|
|
(databaseEventTrigger) => databaseEventTrigger.serverlessFunction,
|
|
|
|
|
{
|
|
|
|
|
cascade: true,
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-22 07:55:20 +00:00
|
|
|
databaseEventTriggers: DatabaseEventTriggerEntity[];
|
2025-08-29 06:11:35 +00:00
|
|
|
|
2025-10-02 22:20:21 +00:00
|
|
|
@OneToMany(
|
2025-10-22 07:55:20 +00:00
|
|
|
() => RouteTriggerEntity,
|
2025-10-02 22:20:21 +00:00
|
|
|
(routeTrigger) => routeTrigger.serverlessFunction,
|
|
|
|
|
{
|
|
|
|
|
cascade: true,
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-22 07:55:20 +00:00
|
|
|
routeTriggers: RouteTriggerEntity[];
|
2025-09-02 09:24:59 +00:00
|
|
|
|
2024-07-17 15:53:01 +00:00
|
|
|
@CreateDateColumn({ type: 'timestamptz' })
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
|
|
|
|
|
@UpdateDateColumn({ type: 'timestamptz' })
|
|
|
|
|
updatedAt: Date;
|
2025-06-03 13:28:43 +00:00
|
|
|
|
|
|
|
|
@DeleteDateColumn({ type: 'timestamptz' })
|
2025-09-25 12:05:01 +00:00
|
|
|
deletedAt: Date | null;
|
2024-07-17 15:53:01 +00:00
|
|
|
}
|