Add serverless function in twenty-cli (#14819)
- add serverlessFunction schema in twenty-cli - add trigger schema in twenty-cli - update serverless function code save and get - sync serverless function
This commit is contained in:
@@ -4,17 +4,21 @@
|
||||
"title": "Twenty Agent Manifest",
|
||||
"description": "Schema for Twenty AI agent configuration files",
|
||||
"type": "object",
|
||||
"required": ["standardId", "name", "label", "prompt", "modelId"],
|
||||
"required": ["standardId", "universalIdentifier", "name", "label", "prompt", "modelId"],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON Schema reference for validation and IDE support"
|
||||
},
|
||||
"standardId": {
|
||||
"universalIdentifier": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the agent (UUID format recommended)",
|
||||
"description": "Unique identifier (UUID format recommended)",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
"standardId": {
|
||||
"const": { "$data": "1/universalIdentifier" },
|
||||
"description": "Should be the same as universalIdentifier"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Internal name for the agent (camelCase, used in code)",
|
||||
|
||||
+8
-4
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/app-manifest.schema.json",
|
||||
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json",
|
||||
"title": "Twenty App Manifest",
|
||||
"description": "Schema for Twenty application manifest files",
|
||||
"type": "object",
|
||||
"required": ["standardId", "label", "version", "license", "engines"],
|
||||
"required": ["universalIdentifier", "label", "version", "license", "engines", "packageManager"],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON Schema reference for validation and IDE support"
|
||||
},
|
||||
"standardId": {
|
||||
"universalIdentifier": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the application (UUID format recommended)",
|
||||
"description": "Unique identifier (UUID format recommended)",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
"label": {
|
||||
@@ -41,6 +41,10 @@
|
||||
"title": "The application's engines",
|
||||
"description": "Define engines here"
|
||||
},
|
||||
"packageManager": {
|
||||
"const": "yarn@4.9.2",
|
||||
"title": "Package manager of the application"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Semantic version of the application",
|
||||
@@ -6,6 +6,7 @@
|
||||
"type": "object",
|
||||
"required": [
|
||||
"standardId",
|
||||
"universalIdentifier",
|
||||
"nameSingular",
|
||||
"namePlural",
|
||||
"labelSingular",
|
||||
@@ -16,11 +17,15 @@
|
||||
"type": "string",
|
||||
"description": "JSON Schema reference for validation and IDE support"
|
||||
},
|
||||
"standardId": {
|
||||
"universalIdentifier": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the object (UUID format recommended)",
|
||||
"description": "Unique identifier (UUID format recommended)",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
"standardId": {
|
||||
"const": { "$data": "1/universalIdentifier" },
|
||||
"description": "Should be the same as universalIdentifier"
|
||||
},
|
||||
"nameSingular": {
|
||||
"type": "string",
|
||||
"description": "Name singular for the object",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/serverlessFunction.schema.json",
|
||||
"title": "Twenty Serverless Function Manifest",
|
||||
"description": "Schema for Twenty AI serverless function configuration files",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"universalIdentifier",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON Schema reference for validation and IDE support"
|
||||
},
|
||||
"universalIdentifier": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier (UUID format recommended)",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name singular for the serverless function",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$",
|
||||
"minLength": 1,
|
||||
"maxLength": 100
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Brief description of the serverless function",
|
||||
"maxLength": 500
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"type": "number",
|
||||
"description": "Serverless function timeout in seconds, between 1 and 900",
|
||||
"min": 1,
|
||||
"max": 900
|
||||
},
|
||||
"triggers": {
|
||||
"type": "array",
|
||||
"description": "Serverless function's triggers",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{ "$ref": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/trigger.schema.json" },
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["$ref"],
|
||||
"properties": { "$ref": { "type": "string" } },
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"type": "object",
|
||||
"description": "Serverless function's code",
|
||||
"required": ["src"],
|
||||
"properties": {
|
||||
"src": {
|
||||
"type": "object",
|
||||
"description":"Serverless function source folder",
|
||||
"required": ["index.ts"],
|
||||
"properties": {
|
||||
"index.ts": {
|
||||
"type": "string",
|
||||
"description":"Serverless function index.ts file"
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"examples": [
|
||||
{
|
||||
"standardId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"universalIdentifier": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"name": "My serverless function",
|
||||
"triggers": [
|
||||
{
|
||||
"standardId": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"universalIdentifier": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"type": "cron",
|
||||
"schedule": "0 9 * * *"
|
||||
},
|
||||
{
|
||||
"standardId": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"universalIdentifier": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"type": "databaseEvent",
|
||||
"eventName": "company.created"
|
||||
},
|
||||
{
|
||||
"standardId": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"universalIdentifier": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"type": "route",
|
||||
"path": "test-route",
|
||||
"httpMethod": "GET",
|
||||
"isAuthRequired": false
|
||||
}
|
||||
],
|
||||
"code": {
|
||||
"src": {
|
||||
"index.ts": "{\n \"code\": \"import axios from 'axios';\\n\\nexport const main = async (params) => {\\n const { a, b } = params;\\n const message = \\\"toto\\\";\\n return { message };\\n};\",\n \"params\": { \"a\": \"1\", \"b\": 2 }\n}\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/trigger.schema.json",
|
||||
"type": "object",
|
||||
"required": ["universalIdentifier", "type"],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON Schema reference for validation and IDE support"
|
||||
},
|
||||
|
||||
"universalIdentifier": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier (UUID format recommended)",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
|
||||
"type": { "enum": ["cron", "databaseEvent", "route"] },
|
||||
|
||||
"schedule": { "type": "string" },
|
||||
|
||||
"eventName": { "type": "string" },
|
||||
|
||||
"path": { "type": "string" },
|
||||
|
||||
"httpMethod": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
|
||||
|
||||
"isAuthRequired": { "type": "boolean" }
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": { "properties": { "type": { "const": "cron" } } },
|
||||
"then": { "required": ["schedule"] }
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "type": { "const": "databaseEvent" } } },
|
||||
"then": { "required": ["eventName"] }
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "type": { "const": "route" } } },
|
||||
"then": { "required": ["path", "httpMethod", "isAuthRequired"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,14 +7,20 @@ import { resolveAppPath } from '../utils/app-path-resolver';
|
||||
import { writeJsoncFile } from '../utils/jsonc-parser';
|
||||
import { getSchemaUrls } from '../utils/schema-validator';
|
||||
|
||||
type SyncableEntity = 'agent' | 'object';
|
||||
enum SyncableEntity {
|
||||
AGENT = 'agent',
|
||||
OBJECT = 'object',
|
||||
SERVERLESS_FUNCTION = 'serverlessFunction',
|
||||
}
|
||||
|
||||
const getFolderName = (entity: SyncableEntity) => {
|
||||
switch (entity) {
|
||||
case 'agent':
|
||||
case SyncableEntity.AGENT:
|
||||
return 'agents';
|
||||
case 'object':
|
||||
case SyncableEntity.OBJECT:
|
||||
return 'objects';
|
||||
case SyncableEntity.SERVERLESS_FUNCTION:
|
||||
return 'serverlessFunctions';
|
||||
default:
|
||||
throw new Error(`Unknown entity type: ${entity}`);
|
||||
}
|
||||
@@ -40,13 +46,16 @@ export class AppAddCommand {
|
||||
|
||||
const folderName = getFolderName(entity);
|
||||
|
||||
const entitiesDir = path.join(appPath, folderName);
|
||||
const entitiesDir = path.join(appPath, folderName, entityName);
|
||||
|
||||
await fs.ensureDir(entitiesDir);
|
||||
|
||||
const entityPath = path.join(entitiesDir, `${entityName}.jsonc`);
|
||||
await writeJsoncFile(
|
||||
path.join(entitiesDir, `${entity}.manifest.jsonc`),
|
||||
entityData,
|
||||
);
|
||||
|
||||
await writeJsoncFile(entityPath, entityData);
|
||||
await this.addEntityInitFiles(entity, entitiesDir);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red(`Add new entity failed:`),
|
||||
@@ -56,13 +65,39 @@ export class AppAddCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async addEntityInitFiles(entity: SyncableEntity, entityPath: string) {
|
||||
switch (entity) {
|
||||
case SyncableEntity.SERVERLESS_FUNCTION: {
|
||||
const srcPath = path.join(entityPath, 'src');
|
||||
await fs.ensureDir(srcPath);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(srcPath, 'index.ts'),
|
||||
'export const main = async (params: {\n a: string;\n b: number;\n}): Promise<object> => {\n const { a, b } = params;\n\n // Rename the parameters and code below with your own logic\n // This is just an example\n const message = `Hello, input: ${a} and ${b}`;\n\n\n\n return { message };\n};',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
case SyncableEntity.AGENT:
|
||||
case SyncableEntity.OBJECT:
|
||||
return;
|
||||
default:
|
||||
throw new Error(`Unknown entity type: ${entity}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getEntity() {
|
||||
const { entity } = await inquirer.prompt([
|
||||
{
|
||||
type: 'select',
|
||||
name: 'entity',
|
||||
message: `What entity do you want to create?`,
|
||||
choices: ['agent', 'object'],
|
||||
default: '',
|
||||
choices: [
|
||||
SyncableEntity.AGENT,
|
||||
SyncableEntity.OBJECT,
|
||||
SyncableEntity.SERVERLESS_FUNCTION,
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -90,7 +125,7 @@ export class AppAddCommand {
|
||||
},
|
||||
]);
|
||||
|
||||
return name;
|
||||
return name as string;
|
||||
}
|
||||
|
||||
private async getEntityToCreateData(
|
||||
@@ -99,11 +134,17 @@ export class AppAddCommand {
|
||||
) {
|
||||
const schemas = getSchemaUrls();
|
||||
|
||||
const uuid = v4();
|
||||
|
||||
const entityToCreateData: Record<string, string> = {
|
||||
$schema: schemas[entity],
|
||||
standardId: v4(),
|
||||
universalIdentifier: uuid,
|
||||
};
|
||||
|
||||
if (entity === SyncableEntity.OBJECT || entity === SyncableEntity.AGENT) {
|
||||
entityToCreateData.standardId = uuid;
|
||||
}
|
||||
|
||||
const schemasDir = path.join(__dirname, '../../schemas');
|
||||
|
||||
const schemaPath = path.join(schemasDir, `${entity}.schema.json`);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const SCHEMA_BASE_URL =
|
||||
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas';
|
||||
|
||||
export const APP_MANIFEST_SCHEMA_URL = `${SCHEMA_BASE_URL}/app-manifest.schema.json`;
|
||||
export const APP_MANIFEST_SCHEMA_URL = `${SCHEMA_BASE_URL}/appManifest.schema.json`;
|
||||
export const AGENT_SCHEMA_URL = `${SCHEMA_BASE_URL}/agent.schema.json`;
|
||||
export const OBJECT_SCHEMA_URL = `${SCHEMA_BASE_URL}/object.schema.json`;
|
||||
export const TRIGGER_SCHEMA_URL = `${SCHEMA_BASE_URL}/trigger.schema.json`;
|
||||
export const SERVERLESS_FUNCTION_SCHEMA_URL = `${SCHEMA_BASE_URL}/serverlessFunction.schema.json`;
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface TwentyConfig {
|
||||
|
||||
export type PackageJson = {
|
||||
$schema?: string;
|
||||
standardId: string;
|
||||
universalIdentifier: string;
|
||||
label: string;
|
||||
license: string;
|
||||
description?: string;
|
||||
@@ -15,6 +15,7 @@ export type PackageJson = {
|
||||
npm: string;
|
||||
yarn: string;
|
||||
};
|
||||
packageManager: string;
|
||||
icon?: string;
|
||||
version: string;
|
||||
dependencies?: object;
|
||||
@@ -24,9 +25,47 @@ export type PackageJson = {
|
||||
export type AppManifest = PackageJson & {
|
||||
agents: AgentManifest[];
|
||||
objects: ObjectManifest[];
|
||||
serverlessFunctions: ServerlessFunctionManifest[];
|
||||
};
|
||||
|
||||
export type CoreEntityManifest = AgentManifest | ObjectManifest;
|
||||
export type CoreEntityManifest =
|
||||
| AgentManifest
|
||||
| ObjectManifest
|
||||
| ServerlessFunctionManifest;
|
||||
|
||||
export type ServerlessFunctionManifest = {
|
||||
$schema?: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
timeoutSeconds?: number;
|
||||
triggers: ServerlessFunctionTriggerManifest[];
|
||||
code: ServerlessFunctionCodeManifest;
|
||||
};
|
||||
|
||||
export type ServerlessFunctionTriggerManifest =
|
||||
| {
|
||||
type: 'cron';
|
||||
schedule: string;
|
||||
}
|
||||
| {
|
||||
type: 'databaseEvent';
|
||||
eventName: string;
|
||||
}
|
||||
| {
|
||||
type: 'route';
|
||||
path: string;
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
isAuthRequired: boolean;
|
||||
};
|
||||
|
||||
type Sources = { [key: string]: string | Sources };
|
||||
|
||||
export type ServerlessFunctionCodeManifest = {
|
||||
src: {
|
||||
'index.ts': string;
|
||||
} & Sources;
|
||||
};
|
||||
|
||||
export type ObjectManifest = {
|
||||
$schema?: string;
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('app-template', () => {
|
||||
|
||||
expect(basePackageJson).toEqual({
|
||||
$schema: APP_MANIFEST_SCHEMA_URL,
|
||||
standardId: 'mocked-uuid-12345',
|
||||
universalIdentifier: 'mocked-uuid-12345',
|
||||
label: 'My Test App',
|
||||
description: 'A Twenty application for my-test-app',
|
||||
version: '0.0.1',
|
||||
@@ -31,6 +31,7 @@ describe('app-template', () => {
|
||||
npm: 'please-use-yarn',
|
||||
yarn: '>=4.0.2',
|
||||
},
|
||||
packageManager: 'yarn@4.9.2',
|
||||
license: 'MIT',
|
||||
});
|
||||
});
|
||||
@@ -40,7 +41,7 @@ describe('app-template', () => {
|
||||
const basePackageJson = createBasePackageJson(appName, '');
|
||||
|
||||
expect(basePackageJson.label).toBe('Calculator');
|
||||
expect(basePackageJson.standardId).toBe('mocked-uuid-12345');
|
||||
expect(basePackageJson.universalIdentifier).toBe('mocked-uuid-12345');
|
||||
});
|
||||
|
||||
it('should handle kebab-case app names correctly', () => {
|
||||
@@ -48,14 +49,14 @@ describe('app-template', () => {
|
||||
const basePackageJson = createBasePackageJson(appName, '');
|
||||
|
||||
expect(basePackageJson.label).toBe('User Management System');
|
||||
expect(basePackageJson.standardId).toBe('mocked-uuid-12345');
|
||||
expect(basePackageJson.universalIdentifier).toBe('mocked-uuid-12345');
|
||||
});
|
||||
|
||||
it('should generate unique standardIds', () => {
|
||||
it('should generate unique universalIdentifiers', () => {
|
||||
const basePackageJson = createBasePackageJson('test-app', '');
|
||||
|
||||
expect(basePackageJson.standardId).toBeDefined();
|
||||
expect(typeof basePackageJson.standardId).toBe('string');
|
||||
expect(basePackageJson.universalIdentifier).toBeDefined();
|
||||
expect(typeof basePackageJson.universalIdentifier).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import assert from 'assert';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
import { parseJsoncFile } from './jsonc-parser';
|
||||
import { validateSchema } from '../utils/schema-validator';
|
||||
|
||||
type Sources = { [key: string]: string | Sources };
|
||||
|
||||
const findPathFile = async (
|
||||
appPath: string,
|
||||
fileName: string,
|
||||
@@ -28,16 +31,49 @@ const loadCoreEntity = async (
|
||||
const coreEntities: CoreEntityManifest[] = [];
|
||||
|
||||
if (await fs.pathExists(coreEntityPath)) {
|
||||
const files = await fs.readdir(coreEntityPath);
|
||||
const coreEntityFileNames = files.filter(
|
||||
(file) => file.endsWith('.jsonc') || file.endsWith('.json'),
|
||||
);
|
||||
const entities = await fs.readdir(coreEntityPath);
|
||||
|
||||
for (const fileName of coreEntityFileNames) {
|
||||
const coreEntityManifest = await parseJsoncFile(
|
||||
path.join(coreEntityPath, fileName),
|
||||
for (const entity of entities) {
|
||||
const entityPath = path.join(coreEntityPath, entity);
|
||||
const entityResources = await fs.readdir(entityPath);
|
||||
|
||||
const entityManifests = entityResources.filter(
|
||||
(file) =>
|
||||
file.endsWith('.manifest.jsonc') || file.endsWith('.manifest.json'),
|
||||
);
|
||||
|
||||
assert(
|
||||
entityManifests.length === 1,
|
||||
'Entity should have strictly one manifest file',
|
||||
);
|
||||
|
||||
const entityManifest = entityManifests[0];
|
||||
|
||||
const coreEntityManifest = await parseJsoncFile(
|
||||
path.join(coreEntityPath, entity, entityManifest),
|
||||
);
|
||||
|
||||
const entitySources = entityResources.filter(
|
||||
(folder) => folder === 'src',
|
||||
);
|
||||
|
||||
assert(
|
||||
entitySources.length <= 1,
|
||||
'Entity should have less than one src folder or file',
|
||||
);
|
||||
|
||||
if (entitySources.length === 1) {
|
||||
const entitySourcePath = path.join(
|
||||
coreEntityPath,
|
||||
entity,
|
||||
entitySources[0],
|
||||
);
|
||||
|
||||
const sources = await loadFolderContentIntoJson(entitySourcePath);
|
||||
|
||||
coreEntityManifest['code'] = { src: sources };
|
||||
}
|
||||
|
||||
await validator(coreEntityManifest, coreEntityPath);
|
||||
|
||||
coreEntities.push(coreEntityManifest);
|
||||
@@ -47,6 +83,26 @@ const loadCoreEntity = async (
|
||||
return coreEntities;
|
||||
};
|
||||
|
||||
const loadFolderContentIntoJson = async (
|
||||
sourcePath: string,
|
||||
): Promise<Sources> => {
|
||||
const sources: Sources = {};
|
||||
|
||||
const resources = await fs.readdir(sourcePath);
|
||||
|
||||
for (const resource of resources) {
|
||||
const resourcePath = path.join(sourcePath, resource);
|
||||
const stats = await fs.stat(resourcePath);
|
||||
if (stats.isFile()) {
|
||||
sources[resource] = await fs.readFile(resourcePath, 'utf8');
|
||||
} else {
|
||||
sources[resource] = await loadFolderContentIntoJson(resourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
};
|
||||
|
||||
export const loadManifest = async (
|
||||
appPath: string,
|
||||
): Promise<{
|
||||
@@ -60,7 +116,7 @@ export const loadManifest = async (
|
||||
const yarnLockPath = await findPathFile(appPath, 'yarn.lock');
|
||||
const rawYarnLock = await fs.readFile(yarnLockPath, 'utf8');
|
||||
|
||||
await validateSchema('app-manifest', rawPackageJson, packageJsonPath);
|
||||
await validateSchema('appManifest', rawPackageJson, packageJsonPath);
|
||||
|
||||
const agents = await loadCoreEntity(
|
||||
path.join(appPath, 'agents'),
|
||||
@@ -72,6 +128,11 @@ export const loadManifest = async (
|
||||
(manifest, path) => validateSchema('object', manifest, path),
|
||||
);
|
||||
|
||||
const serverlessFunctions = await loadCoreEntity(
|
||||
path.join(appPath, 'serverlessFunctions'),
|
||||
(manifest, path) => validateSchema('serverlessFunction', manifest, path),
|
||||
);
|
||||
|
||||
return {
|
||||
packageJson: rawPackageJson,
|
||||
yarnLock: rawYarnLock,
|
||||
@@ -79,6 +140,7 @@ export const loadManifest = async (
|
||||
...rawPackageJson,
|
||||
agents,
|
||||
objects,
|
||||
serverlessFunctions,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export const createBasePackageJson = (
|
||||
|
||||
return {
|
||||
$schema: schemas.appManifest,
|
||||
standardId: randomUUID(),
|
||||
universalIdentifier: randomUUID(),
|
||||
label: appName
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
@@ -20,6 +20,7 @@ export const createBasePackageJson = (
|
||||
npm: 'please-use-yarn',
|
||||
yarn: '>=4.0.2',
|
||||
},
|
||||
packageManager: 'yarn@4.9.2',
|
||||
description,
|
||||
license: 'MIT',
|
||||
version: '0.0.1',
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
AGENT_SCHEMA_URL,
|
||||
APP_MANIFEST_SCHEMA_URL,
|
||||
OBJECT_SCHEMA_URL,
|
||||
SERVERLESS_FUNCTION_SCHEMA_URL,
|
||||
TRIGGER_SCHEMA_URL,
|
||||
} from '../constants/schemas';
|
||||
|
||||
export class SchemaValidationError extends Error {
|
||||
@@ -31,7 +33,7 @@ const formatErrors = (errors: any[]): string => {
|
||||
};
|
||||
|
||||
export const validateSchema = async (
|
||||
schemaName: 'app-manifest' | 'agent' | 'object',
|
||||
schemaName: 'appManifest' | 'agent' | 'object' | 'serverlessFunction',
|
||||
manifest: any,
|
||||
filePath?: string,
|
||||
): Promise<void> => {
|
||||
@@ -39,18 +41,19 @@ export const validateSchema = async (
|
||||
allErrors: true,
|
||||
verbose: true,
|
||||
strict: false,
|
||||
$data: true,
|
||||
});
|
||||
|
||||
const schemaUrls = getSchemaUrls();
|
||||
|
||||
let schema;
|
||||
|
||||
for (const name of Object.keys(schemaUrls) as (keyof typeof schemaUrls)[]) {
|
||||
const formattedName = name === 'appManifest' ? 'app-manifest' : name;
|
||||
const schemasDir = path.join(__dirname, '../../schemas');
|
||||
const schemaPath = path.join(schemasDir, `${formattedName}.schema.json`);
|
||||
const schemaPath = path.join(schemasDir, `${name}.schema.json`);
|
||||
ajv.addSchema(await fs.readJson(schemaPath));
|
||||
|
||||
if (formattedName === schemaName) {
|
||||
if (name === schemaName) {
|
||||
schema = ajv.getSchema(schemaUrls[name])?.schema;
|
||||
}
|
||||
}
|
||||
@@ -71,8 +74,10 @@ export const validateSchema = async (
|
||||
|
||||
export const getSchemaUrls = () => {
|
||||
return {
|
||||
trigger: TRIGGER_SCHEMA_URL,
|
||||
agent: AGENT_SCHEMA_URL,
|
||||
object: OBJECT_SCHEMA_URL,
|
||||
serverlessFunction: SERVERLESS_FUNCTION_SCHEMA_URL,
|
||||
appManifest: APP_MANIFEST_SCHEMA_URL,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -837,6 +837,7 @@ export type CreateRoleInput = {
|
||||
};
|
||||
|
||||
export type CreateServerlessFunctionInput = {
|
||||
code?: InputMaybe<Scalars['JSON']>;
|
||||
description?: InputMaybe<Scalars['String']>;
|
||||
name: Scalars['String'];
|
||||
timeoutSeconds?: InputMaybe<Scalars['Float']>;
|
||||
|
||||
@@ -791,6 +791,7 @@ export type CreateRoleInput = {
|
||||
};
|
||||
|
||||
export type CreateServerlessFunctionInput = {
|
||||
code?: InputMaybe<Scalars['JSON']>;
|
||||
description?: InputMaybe<Scalars['String']>;
|
||||
name: Scalars['String'];
|
||||
timeoutSeconds?: InputMaybe<Scalars['Float']>;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const INDEX_FILE_NAME = 'index.ts';
|
||||
@@ -1 +0,0 @@
|
||||
export const INDEX_FILE_PATH = 'src/index.ts';
|
||||
@@ -0,0 +1 @@
|
||||
export const SOURCE_FOLDER_NAME = 'src';
|
||||
+6
-2
@@ -32,7 +32,7 @@ describe('useServerlessFunctionUpdateFormState', () => {
|
||||
);
|
||||
useGetOneServerlessFunctionSourceCodeMock.useGetOneServerlessFunctionSourceCode.mockReturnValue(
|
||||
{
|
||||
code: 'export const handler = () => {}',
|
||||
code: { src: { 'index.ts': 'export const handler = () => {}' } },
|
||||
},
|
||||
);
|
||||
const { result } = renderHook(
|
||||
@@ -44,6 +44,10 @@ describe('useServerlessFunctionUpdateFormState', () => {
|
||||
|
||||
const { formValues } = result.current;
|
||||
|
||||
expect(formValues).toEqual({ name: '', description: '', code: undefined });
|
||||
expect(formValues).toEqual({
|
||||
name: '',
|
||||
description: '',
|
||||
code: { src: { 'index.ts': '' } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+12
-4
@@ -1,4 +1,4 @@
|
||||
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
|
||||
import { INDEX_FILE_NAME } from '@/serverless-functions/constants/IndexFileName';
|
||||
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
|
||||
import { useGetOneServerlessFunction } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunction';
|
||||
import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
|
||||
@@ -6,6 +6,7 @@ import { serverlessFunctionTestDataFamilyState } from '@/workflow/workflow-steps
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { type FindOneServerlessFunctionSourceCodeQuery } from '~/generated-metadata/graphql';
|
||||
import { SOURCE_FOLDER_NAME } from '@/serverless-functions/constants/SourceFolderName';
|
||||
|
||||
export type ServerlessFunctionNewFormValues = {
|
||||
name: string;
|
||||
@@ -13,7 +14,12 @@ export type ServerlessFunctionNewFormValues = {
|
||||
};
|
||||
|
||||
export type ServerlessFunctionFormValues = ServerlessFunctionNewFormValues & {
|
||||
code: { [filePath: string]: string } | undefined;
|
||||
code: {
|
||||
src: {
|
||||
'index.ts': string;
|
||||
} & { [key: string]: string };
|
||||
'.env'?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type SetServerlessFunctionFormValues = Dispatch<
|
||||
@@ -34,7 +40,7 @@ export const useServerlessFunctionUpdateFormState = ({
|
||||
const [formValues, setFormValues] = useState<ServerlessFunctionFormValues>({
|
||||
name: '',
|
||||
description: '',
|
||||
code: undefined,
|
||||
code: { src: { 'index.ts': '' } },
|
||||
});
|
||||
|
||||
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
|
||||
@@ -61,7 +67,9 @@ export const useServerlessFunctionUpdateFormState = ({
|
||||
|
||||
if (serverlessFunctionTestData.shouldInitInput) {
|
||||
const sourceCode =
|
||||
data?.getServerlessFunctionSourceCode?.[INDEX_FILE_PATH];
|
||||
data?.getServerlessFunctionSourceCode?.[SOURCE_FOLDER_NAME]?.[
|
||||
INDEX_FILE_NAME
|
||||
];
|
||||
|
||||
const functionInput = await getFunctionInputFromSourceCode(sourceCode);
|
||||
|
||||
|
||||
+10
-4
@@ -12,7 +12,7 @@ import { setNestedValue } from '@/workflow/workflow-steps/workflow-actions/code-
|
||||
|
||||
import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton';
|
||||
import { ServerlessFunctionExecutionResult } from '@/serverless-functions/components/ServerlessFunctionExecutionResult';
|
||||
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
|
||||
import { INDEX_FILE_NAME } from '@/serverless-functions/constants/IndexFileName';
|
||||
import { useTestServerlessFunction } from '@/serverless-functions/hooks/useTestServerlessFunction';
|
||||
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
|
||||
import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctionOutputSchema';
|
||||
@@ -50,6 +50,7 @@ import { IconCode, IconPlayerPlay, useIcons } from 'twenty-ui/display';
|
||||
import { CodeEditor } from 'twenty-ui/input';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { SOURCE_FOLDER_NAME } from '@/serverless-functions/constants/SourceFolderName';
|
||||
|
||||
const CODE_EDITOR_MIN_HEIGHT = 343;
|
||||
|
||||
@@ -159,7 +160,12 @@ export const WorkflowEditActionServerlessFunction = ({
|
||||
}
|
||||
setFormValues((prevState) => ({
|
||||
...prevState,
|
||||
code: { ...prevState.code, [INDEX_FILE_PATH]: newCode },
|
||||
code: {
|
||||
...prevState.code,
|
||||
[SOURCE_FOLDER_NAME]: {
|
||||
[INDEX_FILE_NAME]: newCode,
|
||||
},
|
||||
},
|
||||
}));
|
||||
await handleSave();
|
||||
await handleUpdateFunctionInputSchema(newCode);
|
||||
@@ -381,7 +387,7 @@ export const WorkflowEditActionServerlessFunction = ({
|
||||
<StyledFullScreenCodeEditorContainer>
|
||||
<CodeEditor
|
||||
height="100%"
|
||||
value={formValues.code?.[INDEX_FILE_PATH]}
|
||||
value={formValues.code?.[SOURCE_FOLDER_NAME]?.[INDEX_FILE_NAME]}
|
||||
language="typescript"
|
||||
onChange={handleCodeChange}
|
||||
onMount={handleEditorDidMount}
|
||||
@@ -428,7 +434,7 @@ export const WorkflowEditActionServerlessFunction = ({
|
||||
readonly={actionOptions.readonly}
|
||||
/>
|
||||
<WorkflowServerlessFunctionCodeEditor
|
||||
value={formValues.code?.[INDEX_FILE_PATH]}
|
||||
value={formValues.code?.[SOURCE_FOLDER_NAME]?.[INDEX_FILE_NAME]}
|
||||
onChange={handleCodeChange}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
|
||||
+3
-2
@@ -3,7 +3,7 @@ import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-func
|
||||
import { type WorkflowCodeAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
||||
|
||||
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
|
||||
import { INDEX_FILE_NAME } from '@/serverless-functions/constants/IndexFileName';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowEditActionServerlessFunctionFields } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunctionFields';
|
||||
import { getWrongExportedFunctionMarkers } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWrongExportedFunctionMarkers';
|
||||
@@ -17,6 +17,7 @@ import { AutoTypings } from 'monaco-editor-auto-typings';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useIcons } from 'twenty-ui/display';
|
||||
import { CodeEditor } from 'twenty-ui/input';
|
||||
import { SOURCE_FOLDER_NAME } from '@/serverless-functions/constants/SourceFolderName';
|
||||
|
||||
const StyledCodeEditorContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -85,7 +86,7 @@ export const WorkflowReadonlyActionServerlessFunction = ({
|
||||
<StyledCodeEditorContainer>
|
||||
<CodeEditor
|
||||
height={343}
|
||||
value={formValues.code?.[INDEX_FILE_PATH]}
|
||||
value={formValues.code?.[SOURCE_FOLDER_NAME]?.[INDEX_FILE_NAME]}
|
||||
language="typescript"
|
||||
onMount={handleEditorDidMount}
|
||||
setMarkers={getWrongExportedFunctionMarkers}
|
||||
|
||||
+12
-6
@@ -21,6 +21,7 @@ import { IconCode, IconSettings, IconTestPipe } from 'twenty-ui/display';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { getErrorMessageFromApolloError } from '~/utils/get-error-message-from-apollo-error.util';
|
||||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||
import { SOURCE_FOLDER_NAME } from '@/serverless-functions/constants/SourceFolderName';
|
||||
|
||||
const SERVERLESS_FUNCTION_DETAIL_ID = 'serverless-function-detail';
|
||||
|
||||
@@ -124,15 +125,20 @@ export const SettingsServerlessFunctionDetail = () => {
|
||||
];
|
||||
|
||||
const files = formValues.code
|
||||
? Object.keys(formValues.code)
|
||||
.map((key) => {
|
||||
? [
|
||||
{
|
||||
path: '.env',
|
||||
language: 'ini',
|
||||
content: formValues.code?.['.env'] || '',
|
||||
},
|
||||
...Object.keys(formValues.code?.[SOURCE_FOLDER_NAME]).map((key) => {
|
||||
return {
|
||||
path: key,
|
||||
language: key === '.env' ? 'ini' : 'typescript',
|
||||
content: formValues.code?.[key] || '',
|
||||
language: 'typescript',
|
||||
content: formValues.code?.[SOURCE_FOLDER_NAME]?.[key] || '',
|
||||
};
|
||||
})
|
||||
.reverse()
|
||||
}),
|
||||
].reverse()
|
||||
: [];
|
||||
|
||||
const renderActiveTabContent = () => {
|
||||
|
||||
+143
-20
@@ -2,22 +2,26 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
AgentManifest,
|
||||
ObjectManifest,
|
||||
ServerlessFunctionManifest,
|
||||
} from 'src/engine/core-modules/application/types/application.types';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service.';
|
||||
import type { FlatObjectMetadataWithFlatFieldMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-with-flat-field-metadata-maps.type';
|
||||
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
|
||||
import {
|
||||
AgentManifest,
|
||||
ObjectManifest,
|
||||
} from 'src/engine/core-modules/application/types/application.types';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service.';
|
||||
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import type { FlatObjectMetadataWithFlatFieldMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-with-flat-field-metadata-maps.type';
|
||||
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
|
||||
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
|
||||
import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
|
||||
import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationSyncService {
|
||||
@@ -27,6 +31,7 @@ export class ApplicationSyncService {
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly serverlessFunctionLayerService: ServerlessFunctionLayerService,
|
||||
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
|
||||
private readonly serverlessFunctionV2Service: ServerlessFunctionV2Service,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly agentService: AgentService,
|
||||
@@ -40,7 +45,7 @@ export class ApplicationSyncService {
|
||||
}: ApplicationInput & {
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const applicationId = await this.syncApplication({
|
||||
const application = await this.syncApplication({
|
||||
workspaceId,
|
||||
manifest,
|
||||
packageJson,
|
||||
@@ -50,13 +55,20 @@ export class ApplicationSyncService {
|
||||
await this.syncAgents({
|
||||
agentsToSync: manifest.agents,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
applicationId: application.id,
|
||||
});
|
||||
|
||||
await this.syncObjects({
|
||||
objectsToSync: manifest.objects,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
applicationId: application.id,
|
||||
});
|
||||
|
||||
await this.syncServerlessFunctions({
|
||||
serverlessFunctionsToSync: manifest.serverlessFunctions,
|
||||
workspaceId,
|
||||
applicationId: application.id,
|
||||
serverlessFunctionLayerId: application.serverlessFunctionLayerId,
|
||||
});
|
||||
|
||||
this.logger.log('✅ Application sync from manifest completed');
|
||||
@@ -69,9 +81,9 @@ export class ApplicationSyncService {
|
||||
yarnLock,
|
||||
}: ApplicationInput & {
|
||||
workspaceId: string;
|
||||
}): Promise<string> {
|
||||
}): Promise<ApplicationEntity> {
|
||||
const application = await this.applicationService.findByUniversalIdentifier(
|
||||
manifest.standardId,
|
||||
manifest.universalIdentifier,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
@@ -84,8 +96,9 @@ export class ApplicationSyncService {
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
const createdApplication = await this.applicationService.create({
|
||||
universalIdentifier: manifest.standardId,
|
||||
|
||||
return await this.applicationService.create({
|
||||
universalIdentifier: manifest.universalIdentifier,
|
||||
label: manifest.label,
|
||||
description: manifest.description,
|
||||
version: manifest.version,
|
||||
@@ -93,8 +106,6 @@ export class ApplicationSyncService {
|
||||
serverlessFunctionLayerId: serverlessFunctionLayer.id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return createdApplication.id;
|
||||
}
|
||||
|
||||
await this.serverlessFunctionLayerService.update(
|
||||
@@ -111,7 +122,7 @@ export class ApplicationSyncService {
|
||||
version: manifest.version,
|
||||
});
|
||||
|
||||
return application.id;
|
||||
return application;
|
||||
}
|
||||
|
||||
private async syncAgents({
|
||||
@@ -265,4 +276,116 @@ export class ApplicationSyncService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async syncServerlessFunctions({
|
||||
serverlessFunctionsToSync,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
serverlessFunctionLayerId,
|
||||
}: {
|
||||
serverlessFunctionsToSync: ServerlessFunctionManifest[];
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
serverlessFunctionLayerId: string;
|
||||
}) {
|
||||
const { flatServerlessFunctionMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatEntities: ['flatServerlessFunctionMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
const applicationServerlessFunctions = Object.values(
|
||||
flatServerlessFunctionMaps.byId,
|
||||
).filter(
|
||||
(serverlessFunction) =>
|
||||
isDefined(serverlessFunction) &&
|
||||
serverlessFunction.applicationId === applicationId,
|
||||
) as FlatServerlessFunction[];
|
||||
|
||||
const serverlessFunctionsToSyncUniversalIdentifiers =
|
||||
serverlessFunctionsToSync.map(
|
||||
(serverlessFunction) => serverlessFunction.universalIdentifier,
|
||||
);
|
||||
|
||||
const applicationServerlessFunctionsUniversalIdentifiers =
|
||||
applicationServerlessFunctions.map(
|
||||
(serverlessFunction) => serverlessFunction.universalIdentifier,
|
||||
);
|
||||
|
||||
const serverlessFunctionsToDelete = applicationServerlessFunctions.filter(
|
||||
(serverlessFunction) =>
|
||||
isDefined(serverlessFunction.universalIdentifier) &&
|
||||
!serverlessFunctionsToSyncUniversalIdentifiers.includes(
|
||||
serverlessFunction.universalIdentifier,
|
||||
),
|
||||
);
|
||||
|
||||
const serverlessFunctionsToUpdate = applicationServerlessFunctions.filter(
|
||||
(serverlessFunction) =>
|
||||
isDefined(serverlessFunction.universalIdentifier) &&
|
||||
serverlessFunctionsToSyncUniversalIdentifiers.includes(
|
||||
serverlessFunction.universalIdentifier,
|
||||
),
|
||||
);
|
||||
|
||||
const serverlessFunctionsToCreate = serverlessFunctionsToSync.filter(
|
||||
(serverlessFunctionToSync) =>
|
||||
!applicationServerlessFunctionsUniversalIdentifiers.includes(
|
||||
serverlessFunctionToSync.universalIdentifier,
|
||||
),
|
||||
);
|
||||
|
||||
for (const serverlessFunctionToDelete of serverlessFunctionsToDelete) {
|
||||
await this.serverlessFunctionV2Service.destroyOne({
|
||||
destroyServerlessFunctionInput: { id: serverlessFunctionToDelete.id },
|
||||
workspaceId,
|
||||
isSystemBuild: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const serverlessFunctionToUpdate of serverlessFunctionsToUpdate) {
|
||||
const serverlessFunctionToSync = serverlessFunctionsToSync.find(
|
||||
(serverlessFunction) =>
|
||||
serverlessFunction.universalIdentifier ===
|
||||
serverlessFunctionToUpdate.universalIdentifier,
|
||||
);
|
||||
|
||||
if (!serverlessFunctionToSync) {
|
||||
throw new ApplicationException(
|
||||
`Failed to find serverlessFunction to sync with universalIdentifier ${serverlessFunctionToUpdate.universalIdentifier}`,
|
||||
ApplicationExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const updateServerlessFunctionInput = {
|
||||
id: serverlessFunctionToUpdate.id,
|
||||
name: serverlessFunctionToSync.name,
|
||||
timeoutSeconds: serverlessFunctionToSync.timeoutSeconds,
|
||||
code: serverlessFunctionToSync.code,
|
||||
};
|
||||
|
||||
await this.serverlessFunctionV2Service.updateOne(
|
||||
updateServerlessFunctionInput,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
for (const serverlessFunctionToCreate of serverlessFunctionsToCreate) {
|
||||
const createServerlessFunctionInput = {
|
||||
name: serverlessFunctionToCreate.name,
|
||||
code: serverlessFunctionToCreate.code,
|
||||
universalIdentifier: serverlessFunctionToCreate.universalIdentifier,
|
||||
timeoutSeconds: serverlessFunctionToCreate.timeoutSeconds,
|
||||
applicationId,
|
||||
serverlessFunctionLayerId,
|
||||
};
|
||||
|
||||
await this.serverlessFunctionV2Service.createOne(
|
||||
createServerlessFunctionInput,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ export class ApplicationException extends CustomException<ApplicationExceptionCo
|
||||
|
||||
export enum ApplicationExceptionCode {
|
||||
OBJECT_NOT_FOUND = 'OBJECT_NOT_FOUND',
|
||||
SERVERLESS_FUNCTION_NOT_FOUND = 'SERVERLESS_FUNCTION_NOT_FOUND',
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
|
||||
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
|
||||
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -21,6 +22,7 @@ import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serve
|
||||
DataSourceModule,
|
||||
AgentModule,
|
||||
ServerlessFunctionLayerModule,
|
||||
ServerlessFunctionModule,
|
||||
],
|
||||
providers: [ApplicationResolver, ApplicationService, ApplicationSyncService],
|
||||
})
|
||||
|
||||
+30
-1
@@ -1,6 +1,8 @@
|
||||
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
|
||||
export type PackageJson = {
|
||||
$schema?: string;
|
||||
standardId: string;
|
||||
universalIdentifier: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
engines: {
|
||||
@@ -17,8 +19,35 @@ export type PackageJson = {
|
||||
export type AppManifest = PackageJson & {
|
||||
agents: AgentManifest[];
|
||||
objects: ObjectManifest[];
|
||||
serverlessFunctions: ServerlessFunctionManifest[];
|
||||
};
|
||||
|
||||
export type ServerlessFunctionManifest = {
|
||||
$schema?: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
timeoutSeconds?: number;
|
||||
triggers: ServerlessFunctionTriggerManifest[];
|
||||
code: ServerlessFunctionCode;
|
||||
};
|
||||
|
||||
export type ServerlessFunctionTriggerManifest =
|
||||
| {
|
||||
type: 'cron';
|
||||
schedule: string;
|
||||
}
|
||||
| {
|
||||
type: 'databaseEvent';
|
||||
eventName: string;
|
||||
}
|
||||
| {
|
||||
type: 'route';
|
||||
path: string;
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
isAuthRequired: boolean;
|
||||
};
|
||||
|
||||
export type ObjectManifest = {
|
||||
$schema?: string;
|
||||
standardId: string;
|
||||
|
||||
+5
@@ -1,14 +1,18 @@
|
||||
import { type Readable } from 'stream';
|
||||
|
||||
import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
|
||||
export interface StorageDriver {
|
||||
delete(params: { folderPath: string; filename?: string }): Promise<void>;
|
||||
read(params: { folderPath: string; filename: string }): Promise<Readable>;
|
||||
readFolder(folderPath: string): Promise<Sources>;
|
||||
write(params: {
|
||||
file: Buffer | Uint8Array | string;
|
||||
name: string;
|
||||
folder: string;
|
||||
mimeType: string | undefined;
|
||||
}): Promise<void>;
|
||||
writeFolder(sources: Sources, folderPath: string): Promise<void>;
|
||||
move(params: {
|
||||
from: { folderPath: string; filename?: string };
|
||||
to: { folderPath: string; filename?: string };
|
||||
@@ -21,6 +25,7 @@ export interface StorageDriver {
|
||||
from: { folderPath: string; filename?: string };
|
||||
to: { folderPath: string; filename?: string };
|
||||
}): Promise<void>;
|
||||
|
||||
checkFileExists(params: {
|
||||
folderPath: string;
|
||||
filename: string;
|
||||
|
||||
+44
-1
@@ -1,14 +1,18 @@
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import * as fs from 'fs/promises';
|
||||
import { dirname, join } from 'path';
|
||||
import path, { dirname, join } from 'path';
|
||||
import { type Readable } from 'stream';
|
||||
|
||||
import { isObject } from '@sniptt/guards';
|
||||
|
||||
import { type StorageDriver } from 'src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface';
|
||||
import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
|
||||
import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
|
||||
export interface LocalDriverOptions {
|
||||
storagePath: string;
|
||||
}
|
||||
@@ -42,6 +46,21 @@ export class LocalDriver implements StorageDriver {
|
||||
await fs.writeFile(filePath, params.file);
|
||||
}
|
||||
|
||||
async writeFolder(sources: Sources, folderPath: string) {
|
||||
for (const key of Object.keys(sources)) {
|
||||
if (isObject(sources[key])) {
|
||||
await this.writeFolder(sources[key], join(folderPath, key));
|
||||
continue;
|
||||
}
|
||||
await this.write({
|
||||
file: sources[key],
|
||||
name: key,
|
||||
mimeType: undefined,
|
||||
folder: folderPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async delete(params: {
|
||||
folderPath: string;
|
||||
filename?: string;
|
||||
@@ -86,6 +105,30 @@ export class LocalDriver implements StorageDriver {
|
||||
}
|
||||
}
|
||||
|
||||
async readFolder(folderPath: string): Promise<Sources> {
|
||||
const sources: Sources = {};
|
||||
|
||||
const rootFolderPath = join(`${this.options.storagePath}/`, folderPath);
|
||||
|
||||
const resources = await fs.readdir(rootFolderPath);
|
||||
|
||||
for (const resource of resources) {
|
||||
const resourcePath = path.join(rootFolderPath, resource);
|
||||
|
||||
const stats = await fs.stat(resourcePath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
sources[resource] = await fs.readFile(resourcePath, 'utf8');
|
||||
} else {
|
||||
sources[resource] = await this.readFolder(
|
||||
path.join(folderPath, resource),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
async move(params: {
|
||||
from: { folderPath: string; filename?: string };
|
||||
to: { folderPath: string; filename?: string };
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type S3ClientConfig,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isObject } from '@sniptt/guards';
|
||||
|
||||
import { type StorageDriver } from 'src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface';
|
||||
import {
|
||||
@@ -28,6 +29,10 @@ import {
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
|
||||
import type { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
import { readFileContent } from 'src/engine/core-modules/file-storage/utils/read-file-content';
|
||||
import { readS3FolderContent } from 'src/engine/core-modules/file-storage/utils/read-s3-folder-content';
|
||||
|
||||
export interface S3DriverOptions extends S3ClientConfig {
|
||||
bucketName: string;
|
||||
endpoint?: string;
|
||||
@@ -70,6 +75,21 @@ export class S3Driver implements StorageDriver {
|
||||
await this.s3Client.send(command);
|
||||
}
|
||||
|
||||
async writeFolder(sources: Sources, folderPath: string) {
|
||||
for (const key of Object.keys(sources)) {
|
||||
if (isObject(sources[key])) {
|
||||
await this.writeFolder(sources[key], join(folderPath, key));
|
||||
continue;
|
||||
}
|
||||
await this.write({
|
||||
file: sources[key],
|
||||
name: key,
|
||||
mimeType: undefined,
|
||||
folder: folderPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchS3FolderContents(folderPath: string) {
|
||||
const listParams = {
|
||||
Bucket: this.bucketName,
|
||||
@@ -177,6 +197,46 @@ export class S3Driver implements StorageDriver {
|
||||
}
|
||||
}
|
||||
|
||||
async readFolder(folderPath: string): Promise<Sources> {
|
||||
const sources: Sources = {};
|
||||
const listedObjects = await this.fetchS3FolderContents(folderPath);
|
||||
|
||||
if (!listedObjects.Contents || listedObjects.Contents.length === 0) {
|
||||
return sources;
|
||||
}
|
||||
|
||||
const files = (
|
||||
await Promise.all(
|
||||
listedObjects.Contents.map(async (object) => {
|
||||
if (!object.Key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const folderAndFilePaths = this.extractFolderAndFilePaths(object.Key);
|
||||
|
||||
if (!isDefined(folderAndFilePaths)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { fromFolderPath, filename } = folderAndFilePaths;
|
||||
|
||||
const fileContent = await readFileContent(
|
||||
await this.read({ folderPath: fromFolderPath, filename }),
|
||||
);
|
||||
|
||||
const formattedObjectKey = object.Key.replace(
|
||||
folderPath + '/',
|
||||
'',
|
||||
).replace(folderPath, '');
|
||||
|
||||
return { path: formattedObjectKey, fileContent };
|
||||
}),
|
||||
)
|
||||
).filter(isDefined);
|
||||
|
||||
return readS3FolderContent(files);
|
||||
}
|
||||
|
||||
async move(params: {
|
||||
from: { folderPath: string; filename?: string };
|
||||
to: { folderPath: string; filename?: string };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { type Readable } from 'stream';
|
||||
import { type StorageDriver } from 'src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface';
|
||||
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
import { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
|
||||
@Injectable()
|
||||
export class FileStorageService implements StorageDriver {
|
||||
@@ -23,12 +24,24 @@ export class FileStorageService implements StorageDriver {
|
||||
return driver.write(params);
|
||||
}
|
||||
|
||||
writeFolder(sources: Sources, folderPath: string): Promise<void> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
return driver.writeFolder(sources, folderPath);
|
||||
}
|
||||
|
||||
read(params: { folderPath: string; filename: string }): Promise<Readable> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
return driver.read(params);
|
||||
}
|
||||
|
||||
readFolder(folderPath: string): Promise<Sources> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
return driver.readFolder(folderPath);
|
||||
}
|
||||
|
||||
delete(params: { folderPath: string; filename?: string }): Promise<void> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type Sources = { [key: string]: string | Sources };
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { readS3FolderContent } from 'src/engine/core-modules/file-storage/utils/read-s3-folder-content';
|
||||
|
||||
describe('read-s3-folder-content', () => {
|
||||
it('should format files to sources properly', () => {
|
||||
const files = [
|
||||
{ path: 'f1/file1.ts', fileContent: 'content1' },
|
||||
{ path: 'f1/f11/file11.ts', fileContent: 'content11' },
|
||||
{ path: 'f1/file2.ts', fileContent: 'content2' },
|
||||
{ path: 'file3.ts', fileContent: 'content3' },
|
||||
];
|
||||
|
||||
const expectedResult = {
|
||||
'file3.ts': 'content3',
|
||||
f1: {
|
||||
'file1.ts': 'content1',
|
||||
'file2.ts': 'content2',
|
||||
f11: { 'file11.ts': 'content11' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = readS3FolderContent(files);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isObject } from '@sniptt/guards';
|
||||
|
||||
import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
|
||||
export const readS3FolderContent = (
|
||||
files: { path: string; fileContent: string }[],
|
||||
) => {
|
||||
const result: Sources = {};
|
||||
|
||||
for (const { path, fileContent } of files) {
|
||||
const segments = path.split('/');
|
||||
const fileName = segments.pop();
|
||||
|
||||
if (!isDefined(fileName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cursor: Sources = result;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (!isObject(cursor[segment])) {
|
||||
cursor[segment] = {};
|
||||
}
|
||||
cursor = cursor[segment] as Sources;
|
||||
}
|
||||
|
||||
cursor[fileName] = fileContent;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
+12
@@ -3,11 +3,15 @@ import { Field, HideField, InputType } from '@nestjs/graphql';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import graphqlTypeJson from 'graphql-type-json';
|
||||
|
||||
import { ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
|
||||
@InputType()
|
||||
export class CreateServerlessFunctionInput {
|
||||
@@ -31,6 +35,14 @@ export class CreateServerlessFunctionInput {
|
||||
@HideField()
|
||||
applicationId?: string;
|
||||
|
||||
@HideField()
|
||||
universalIdentifier?: string;
|
||||
|
||||
@HideField()
|
||||
serverlessFunctionLayerId?: string;
|
||||
|
||||
@Field(() => graphqlTypeJson, { nullable: true })
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
code?: ServerlessFunctionCode;
|
||||
}
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@ import {
|
||||
import graphqlTypeJson from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
|
||||
@InputType()
|
||||
export class UpdateServerlessFunctionInput {
|
||||
@@ -41,5 +42,5 @@ export class UpdateServerlessFunctionInput {
|
||||
|
||||
@Field(() => graphqlTypeJson)
|
||||
@IsObject()
|
||||
code: JSON;
|
||||
code: ServerlessFunctionCode;
|
||||
}
|
||||
|
||||
+1
-1
@@ -39,6 +39,6 @@ import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serve
|
||||
ServerlessFunctionResolver,
|
||||
WorkspaceFlatServerlessFunctionMapCacheService,
|
||||
],
|
||||
exports: [ServerlessFunctionService],
|
||||
exports: [ServerlessFunctionService, ServerlessFunctionV2Service],
|
||||
})
|
||||
export class ServerlessFunctionModule {}
|
||||
|
||||
+8
-28
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { basename, dirname, join } from 'path';
|
||||
import { join } from 'path';
|
||||
|
||||
import deepEqual from 'deep-equal';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -13,9 +13,6 @@ import { type ServerlessExecuteResult } from 'src/engine/core-modules/serverless
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { SERVERLESS_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/serverless-function/serverless-function-executed';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { readFileContent } from 'src/engine/core-modules/file-storage/utils/read-file-content';
|
||||
import { ENV_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/env-file-name';
|
||||
import { INDEX_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/index-file-name';
|
||||
import { getBaseTypescriptProjectFiles } from 'src/engine/core-modules/serverless/drivers/utils/get-base-typescript-project-files';
|
||||
import { getLayerDependencies } from 'src/engine/core-modules/serverless/drivers/utils/get-last-layer-dependencies';
|
||||
import { ServerlessService } from 'src/engine/core-modules/serverless/serverless.service';
|
||||
@@ -34,6 +31,7 @@ import {
|
||||
WorkflowVersionStepExceptionCode,
|
||||
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
|
||||
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
|
||||
import { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
|
||||
@Injectable()
|
||||
export class ServerlessFunctionService {
|
||||
@@ -66,7 +64,7 @@ export class ServerlessFunctionService {
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
version: string,
|
||||
): Promise<{ [filePath: string]: string } | undefined> {
|
||||
): Promise<Sources | undefined> {
|
||||
const serverlessFunction =
|
||||
await this.serverlessFunctionRepository.findOneOrFail({
|
||||
where: {
|
||||
@@ -81,20 +79,7 @@ export class ServerlessFunctionService {
|
||||
version,
|
||||
});
|
||||
|
||||
const indexFileStream = await this.fileStorageService.read({
|
||||
folderPath: join(folderPath, 'src'),
|
||||
filename: INDEX_FILE_NAME,
|
||||
});
|
||||
|
||||
const envFileStream = await this.fileStorageService.read({
|
||||
folderPath: folderPath,
|
||||
filename: ENV_FILE_NAME,
|
||||
});
|
||||
|
||||
return {
|
||||
'.env': await readFileContent(envFileStream),
|
||||
'src/index.ts': await readFileContent(indexFileStream),
|
||||
};
|
||||
return await this.fileStorageService.readFolder(folderPath);
|
||||
} catch (error) {
|
||||
if (error.code === FileStorageExceptionCode.FILE_NOT_FOUND) {
|
||||
return;
|
||||
@@ -289,15 +274,10 @@ export class ServerlessFunctionService {
|
||||
version: 'draft',
|
||||
});
|
||||
|
||||
for (const key of Object.keys(serverlessFunctionInput.code)) {
|
||||
await this.fileStorageService.write({
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
file: serverlessFunctionInput.code[key],
|
||||
name: basename(key),
|
||||
mimeType: undefined,
|
||||
folder: join(fileFolder, dirname(key)),
|
||||
});
|
||||
}
|
||||
await this.fileStorageService.writeFolder(
|
||||
serverlessFunctionInput.code,
|
||||
fileFolder,
|
||||
);
|
||||
|
||||
return this.serverlessFunctionRepository.findOneBy({
|
||||
id: existingServerlessFunction.id,
|
||||
|
||||
+6
-2
@@ -168,9 +168,11 @@ export class ServerlessFunctionV2Service {
|
||||
async deleteOne({
|
||||
deleteServerlessFunctionInput,
|
||||
workspaceId,
|
||||
isSystemBuild = false,
|
||||
}: {
|
||||
deleteServerlessFunctionInput: ServerlessFunctionIdInput;
|
||||
workspaceId: string;
|
||||
isSystemBuild?: boolean;
|
||||
}): Promise<FlatServerlessFunction> {
|
||||
const { flatServerlessFunctionMaps: existingFlatServerlessFunctionMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
@@ -211,8 +213,8 @@ export class ServerlessFunctionV2Service {
|
||||
},
|
||||
},
|
||||
buildOptions: {
|
||||
isSystemBuild: false,
|
||||
inferDeletionFromMissingEntities: false,
|
||||
isSystemBuild,
|
||||
},
|
||||
workspaceId,
|
||||
},
|
||||
@@ -244,9 +246,11 @@ export class ServerlessFunctionV2Service {
|
||||
async destroyOne({
|
||||
destroyServerlessFunctionInput,
|
||||
workspaceId,
|
||||
isSystemBuild = false,
|
||||
}: {
|
||||
destroyServerlessFunctionInput: ServerlessFunctionIdInput;
|
||||
workspaceId: string;
|
||||
isSystemBuild?: boolean;
|
||||
}): Promise<FlatServerlessFunction> {
|
||||
const { flatServerlessFunctionMaps: existingFlatServerlessFunctionMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
@@ -288,7 +292,7 @@ export class ServerlessFunctionV2Service {
|
||||
},
|
||||
},
|
||||
buildOptions: {
|
||||
isSystemBuild: false,
|
||||
isSystemBuild,
|
||||
inferDeletionFromMissingEntities: true,
|
||||
},
|
||||
workspaceId,
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serve
|
||||
import { type ExtractRecordTypeOrmRelationProperties } from 'src/engine/workspace-manager/workspace-migration-v2/types/extract-record-typeorm-relation-properties.type';
|
||||
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { type ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity';
|
||||
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
|
||||
export type ServerlessFunctionEntityRelationProperties =
|
||||
ExtractRecordTypeOrmRelationProperties<
|
||||
@@ -23,5 +24,5 @@ export type FlatServerlessFunction = Omit<
|
||||
ServerlessFunctionEntityRelationProperties
|
||||
> & {
|
||||
universalIdentifier: string;
|
||||
code?: JSON;
|
||||
code?: ServerlessFunctionCode;
|
||||
};
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
|
||||
export type ServerlessFunctionCode = {
|
||||
src: {
|
||||
'index.ts': string;
|
||||
} & Sources;
|
||||
'.env'?: string;
|
||||
};
|
||||
+9
-3
@@ -4,6 +4,7 @@ import { LAST_LAYER_VERSION } from 'src/engine/core-modules/serverless/drivers/l
|
||||
import { type CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
|
||||
import { ServerlessFunctionRuntime } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
import { type FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
|
||||
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
|
||||
|
||||
export const fromCreateServerlessFunctionInputToFlatServerlessFunction = ({
|
||||
createServerlessFunctionInput,
|
||||
@@ -13,14 +14,14 @@ export const fromCreateServerlessFunctionInputToFlatServerlessFunction = ({
|
||||
workspaceId: string;
|
||||
}): FlatServerlessFunction => {
|
||||
const id = v4();
|
||||
const universalIdentifier = v4();
|
||||
const currentDate = new Date();
|
||||
|
||||
return {
|
||||
id,
|
||||
name: createServerlessFunctionInput.name,
|
||||
description: createServerlessFunctionInput.description ?? null,
|
||||
universalIdentifier,
|
||||
universalIdentifier:
|
||||
createServerlessFunctionInput.universalIdentifier ?? v4(),
|
||||
createdAt: currentDate,
|
||||
updatedAt: currentDate,
|
||||
deletedAt: null,
|
||||
@@ -34,6 +35,11 @@ export const fromCreateServerlessFunctionInputToFlatServerlessFunction = ({
|
||||
serverlessFunctionLayerId:
|
||||
createServerlessFunctionInput.serverlessFunctionLayerId ?? null,
|
||||
workspaceId,
|
||||
checksum: null,
|
||||
code: createServerlessFunctionInput?.code,
|
||||
checksum: createServerlessFunctionInput?.code
|
||||
? serverlessFunctionCreateHash(
|
||||
JSON.stringify(createServerlessFunctionInput.code),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
+3
-3
@@ -13,8 +13,8 @@ import {
|
||||
ServerlessFunctionExceptionCode,
|
||||
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
|
||||
import { type FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
|
||||
import { serverlessFunctionCreateCodeChecksum } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-code-checksum.utils';
|
||||
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
|
||||
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
|
||||
|
||||
export const fromUpdateServerlessFunctionInputToFlatServerlessFunctionToUpdateOrThrow =
|
||||
({
|
||||
@@ -42,8 +42,8 @@ export const fromUpdateServerlessFunctionInputToFlatServerlessFunctionToUpdateOr
|
||||
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
|
||||
{
|
||||
...rawUpdateServerlessFunctionInput,
|
||||
checksum: serverlessFunctionCreateCodeChecksum(
|
||||
rawUpdateServerlessFunctionInput.code,
|
||||
checksum: serverlessFunctionCreateHash(
|
||||
JSON.stringify(rawUpdateServerlessFunctionInput.code),
|
||||
),
|
||||
},
|
||||
FLAT_SERVERLESS_FUNCTION_EDITABLE_PROPERTIES,
|
||||
|
||||
+4
-1
@@ -1,8 +1,11 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
|
||||
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
|
||||
export const serverlessFunctionCreateCodeChecksum = (code: JSON): string => {
|
||||
export const serverlessFunctionCreateCodeChecksum = (
|
||||
code: ServerlessFunctionCode,
|
||||
): string => {
|
||||
if (!isDefined(code) || typeof code !== 'object') {
|
||||
return serverlessFunctionCreateHash('');
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { type FlatServerlessFunctionPropertiesToCompare } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function-properties-to-compare.type';
|
||||
import { type FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
|
||||
import { type PropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/types/property-update.type';
|
||||
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
|
||||
export type CreateServerlessFunctionAction = {
|
||||
type: 'create_serverless_function';
|
||||
@@ -10,7 +11,7 @@ export type CreateServerlessFunctionAction = {
|
||||
export type UpdateServerlessFunctionAction = {
|
||||
type: 'update_serverless_function';
|
||||
serverlessFunctionId: string;
|
||||
code?: JSON;
|
||||
code?: ServerlessFunctionCode;
|
||||
updates: Array<
|
||||
{
|
||||
[P in FlatServerlessFunctionPropertiesToCompare]: PropertyUpdate<
|
||||
|
||||
+16
-7
@@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { join } from 'path';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface';
|
||||
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
@@ -40,13 +42,20 @@ export class CreateServerlessFunctionActionHandlerService extends WorkspaceMigra
|
||||
version: 'draft',
|
||||
});
|
||||
|
||||
for (const file of await getBaseTypescriptProjectFiles) {
|
||||
await this.fileStorageService.write({
|
||||
file: file.content,
|
||||
name: file.name,
|
||||
mimeType: undefined,
|
||||
folder: join(draftFileFolder, file.path),
|
||||
});
|
||||
if (isDefined(serverlessFunction?.code)) {
|
||||
await this.fileStorageService.writeFolder(
|
||||
serverlessFunction.code,
|
||||
draftFileFolder,
|
||||
);
|
||||
} else {
|
||||
for (const file of await getBaseTypescriptProjectFiles) {
|
||||
await this.fileStorageService.write({
|
||||
file: file.content,
|
||||
name: file.name,
|
||||
mimeType: undefined,
|
||||
folder: join(draftFileFolder, file.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-12
@@ -1,7 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { basename, dirname, join } from 'path';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface';
|
||||
@@ -15,6 +13,7 @@ import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-f
|
||||
import { UpdateServerlessFunctionAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-serverless-function-action-v2.type';
|
||||
import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/workspace-migration-action-runner-args.type';
|
||||
import { fromWorkspaceMigrationUpdateActionToPartialEntity } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/utils/from-workspace-migration-update-action-to-partial-field-or-object-entity.util';
|
||||
import { ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
|
||||
@Injectable()
|
||||
export class UpdateServerlessFunctionActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
|
||||
@@ -78,21 +77,13 @@ export class UpdateServerlessFunctionActionHandlerService extends WorkspaceMigra
|
||||
code,
|
||||
}: {
|
||||
serverlessFunction: FlatServerlessFunction;
|
||||
code: JSON;
|
||||
code: ServerlessFunctionCode;
|
||||
}) {
|
||||
const fileFolder = getServerlessFolder({
|
||||
serverlessFunction,
|
||||
version: 'draft',
|
||||
});
|
||||
|
||||
for (const key of Object.keys(code)) {
|
||||
await this.fileStorageService.write({
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
file: code[key],
|
||||
name: basename(key),
|
||||
mimeType: undefined,
|
||||
folder: join(fileFolder, dirname(key)),
|
||||
});
|
||||
}
|
||||
await this.fileStorageService.writeFolder(code, fileFolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,54 +18,53 @@ export { UndecoratedLink } from './link/components/UndecoratedLink';
|
||||
export { CAL_LINK } from './link/constants/Cal';
|
||||
export { GITHUB_LINK } from './link/constants/GithubLink';
|
||||
export { TWENTY_PRICING_LINK } from './link/constants/TwentyPricingLink';
|
||||
export { MenuPicker } from './menu/components/MenuPicker';
|
||||
export type { MenuPickerProps } from './menu/components/MenuPicker';
|
||||
export { MenuItem } from './menu/menu-item/components/MenuItem';
|
||||
export { MenuPicker } from './menu/components/MenuPicker';
|
||||
export type {
|
||||
MenuItemIconButton,
|
||||
MenuItemProps
|
||||
MenuItemProps,
|
||||
} from './menu/menu-item/components/MenuItem';
|
||||
export { MenuItemAvatar } from './menu/menu-item/components/MenuItemAvatar';
|
||||
export { MenuItem } from './menu/menu-item/components/MenuItem';
|
||||
export type { MenuItemAvatarProps } from './menu/menu-item/components/MenuItemAvatar';
|
||||
export { MenuItemDraggable } from './menu/menu-item/components/MenuItemDraggable';
|
||||
export { MenuItemAvatar } from './menu/menu-item/components/MenuItemAvatar';
|
||||
export type { MenuItemDraggableProps } from './menu/menu-item/components/MenuItemDraggable';
|
||||
export { MenuItemHotKeys } from './menu/menu-item/components/MenuItemHotKeys';
|
||||
export { MenuItemDraggable } from './menu/menu-item/components/MenuItemDraggable';
|
||||
export type { MenuItemHotKeysProps } from './menu/menu-item/components/MenuItemHotKeys';
|
||||
export { MenuItemHotKeys } from './menu/menu-item/components/MenuItemHotKeys';
|
||||
export { MenuItemMultiSelect } from './menu/menu-item/components/MenuItemMultiSelect';
|
||||
export { MenuItemMultiSelectAvatar } from './menu/menu-item/components/MenuItemMultiSelectAvatar';
|
||||
export { MenuItemMultiSelectTag } from './menu/menu-item/components/MenuItemMultiSelectTag';
|
||||
export { MenuItemNavigate } from './menu/menu-item/components/MenuItemNavigate';
|
||||
export type { MenuItemNavigateProps } from './menu/menu-item/components/MenuItemNavigate';
|
||||
export { MenuItemNavigate } from './menu/menu-item/components/MenuItemNavigate';
|
||||
export {
|
||||
StyledMenuItemSelect,
|
||||
MenuItemSelect,
|
||||
StyledMenuItemSelect
|
||||
} from './menu/menu-item/components/MenuItemSelect';
|
||||
export { MenuItemSelectAvatar } from './menu/menu-item/components/MenuItemSelectAvatar';
|
||||
export {
|
||||
colorLabels,
|
||||
MenuItemSelectColor
|
||||
MenuItemSelectColor,
|
||||
} from './menu/menu-item/components/MenuItemSelectColor';
|
||||
export { MenuItemSelectTag } from './menu/menu-item/components/MenuItemSelectTag';
|
||||
export { MenuItemSuggestion } from './menu/menu-item/components/MenuItemSuggestion';
|
||||
export type { MenuItemSuggestionProps } from './menu/menu-item/components/MenuItemSuggestion';
|
||||
export { MenuItemSuggestion } from './menu/menu-item/components/MenuItemSuggestion';
|
||||
export { MenuItemToggle } from './menu/menu-item/components/MenuItemToggle';
|
||||
export { MenuItemLeftContent } from './menu/menu-item/internals/components/MenuItemLeftContent';
|
||||
export type { MenuItemBaseProps } from './menu/menu-item/internals/components/StyledMenuItemBase';
|
||||
export {
|
||||
StyledDraggableItem,
|
||||
StyledHoverableMenuItemBase,
|
||||
StyledMenuItemBase,
|
||||
StyledMenuItemContextualText,
|
||||
StyledMenuItemIconCheck,
|
||||
StyledMenuItemLabel,
|
||||
StyledMenuItemLabelLight,
|
||||
StyledNoIconFiller,
|
||||
StyledMenuItemLeftContent,
|
||||
StyledMenuItemRightContent,
|
||||
StyledNoIconFiller,
|
||||
StyledRightMenuItemContextualText
|
||||
StyledDraggableItem,
|
||||
StyledHoverableMenuItemBase,
|
||||
StyledMenuItemIconCheck,
|
||||
StyledMenuItemContextualText,
|
||||
StyledRightMenuItemContextualText,
|
||||
} from './menu/menu-item/internals/components/StyledMenuItemBase';
|
||||
export type { MenuItemBaseProps } from './menu/menu-item/internals/components/StyledMenuItemBase';
|
||||
export type { MenuItemAccent } from './menu/menu-item/types/MenuItemAccent';
|
||||
export { NavigationBar } from './navigation-bar/components/NavigationBar';
|
||||
export { NavigationBarItem } from './navigation-bar/components/NavigationBarItem';
|
||||
export { NotificationCounter } from './notification-counter/components/NotificationCounter';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user