1573 extensibility twenty cli handle custom layers for serverless functions of applications (#14779)
- allow specific layers for serverless functions - add a serverlessFunctionLayer table - sync application layer
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"title": "Twenty App Manifest",
|
||||
"description": "Schema for Twenty application manifest files",
|
||||
"type": "object",
|
||||
"required": ["standardId", "label", "version"],
|
||||
"required": ["standardId", "label", "version", "license", "engines"],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
@@ -31,6 +31,16 @@
|
||||
"description": "Icon for the application (emoji or icon name)",
|
||||
"maxLength": 50
|
||||
},
|
||||
"license": {
|
||||
"const": "MIT",
|
||||
"title": "The application's license",
|
||||
"description": "Currently only MIT is accepted, although more licenses will probably be available in the future."
|
||||
},
|
||||
"engines": {
|
||||
"type": "object",
|
||||
"title": "The application's engines",
|
||||
"description": "Define engines here"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Semantic version of the application",
|
||||
@@ -71,7 +81,13 @@
|
||||
"label": "Customer Support App",
|
||||
"description": "Comprehensive customer support application with AI agents",
|
||||
"icon": "🎧",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
|
||||
import chalk from 'chalk';
|
||||
import { type ApiResponse, type AppManifest } from '../types/config.types';
|
||||
import {
|
||||
type ApiResponse,
|
||||
type AppManifest,
|
||||
type PackageJson,
|
||||
} from '../types/config.types';
|
||||
import { ConfigService } from './config.service';
|
||||
|
||||
export class ApiService {
|
||||
@@ -79,16 +83,26 @@ export class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
async syncApplication(manifest: AppManifest): Promise<ApiResponse> {
|
||||
async syncApplication({
|
||||
packageJson,
|
||||
yarnLock,
|
||||
manifest,
|
||||
}: {
|
||||
packageJson: PackageJson;
|
||||
yarnLock: string;
|
||||
manifest: AppManifest;
|
||||
}): Promise<ApiResponse> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation SyncApplication($manifest: JSON!) {
|
||||
syncApplication(manifest: $manifest)
|
||||
mutation SyncApplication($manifest: JSON!, $packageJson: JSON!, $yarnLock: String!) {
|
||||
syncApplication(manifest: $manifest, packageJson: $packageJson, yarnLock: $yarnLock)
|
||||
}
|
||||
`;
|
||||
|
||||
const variables = {
|
||||
manifest,
|
||||
yarnLock,
|
||||
packageJson,
|
||||
};
|
||||
|
||||
const response: AxiosResponse = await this.client.post(
|
||||
|
||||
@@ -8,7 +8,13 @@ export type PackageJson = {
|
||||
$schema?: string;
|
||||
standardId: string;
|
||||
label: string;
|
||||
license: string;
|
||||
description?: string;
|
||||
engines: {
|
||||
node: string;
|
||||
npm: string;
|
||||
yarn: string;
|
||||
};
|
||||
icon?: string;
|
||||
version: string;
|
||||
dependencies?: object;
|
||||
|
||||
@@ -23,6 +23,12 @@ describe('app-template', () => {
|
||||
label: 'My Test App',
|
||||
description: 'A Twenty application for my-test-app',
|
||||
version: '0.0.1',
|
||||
engines: {
|
||||
node: '^24.5.0',
|
||||
npm: 'please-use-yarn',
|
||||
yarn: '>=4.0.2',
|
||||
},
|
||||
license: 'MIT',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import { AppManifest, CoreEntityManifest } from '../types/config.types';
|
||||
import {
|
||||
AppManifest,
|
||||
CoreEntityManifest,
|
||||
PackageJson,
|
||||
} from '../types/config.types';
|
||||
import { parseJsoncFile } from './jsonc-parser';
|
||||
import { validateSchema } from '../utils/schema-validator';
|
||||
|
||||
const findPackageJsonFile = async (appPath: string): Promise<string> => {
|
||||
const jsonPath = path.join(appPath, 'package.json');
|
||||
const findPathFile = async (
|
||||
appPath: string,
|
||||
fileName: string,
|
||||
): Promise<string> => {
|
||||
const jsonPath = path.join(appPath, fileName);
|
||||
|
||||
if (await fs.pathExists(jsonPath)) {
|
||||
return jsonPath;
|
||||
}
|
||||
|
||||
throw new Error(`package.json not found in ${appPath}`);
|
||||
throw new Error(`${fileName} not found in ${appPath}`);
|
||||
};
|
||||
|
||||
const loadCoreEntity = async (
|
||||
@@ -40,10 +47,19 @@ const loadCoreEntity = async (
|
||||
return coreEntities;
|
||||
};
|
||||
|
||||
export const loadManifest = async (appPath: string): Promise<AppManifest> => {
|
||||
const packageJsonPath = await findPackageJsonFile(appPath);
|
||||
export const loadManifest = async (
|
||||
appPath: string,
|
||||
): Promise<{
|
||||
packageJson: PackageJson;
|
||||
yarnLock: string;
|
||||
manifest: AppManifest;
|
||||
}> => {
|
||||
const packageJsonPath = await findPathFile(appPath, 'package.json');
|
||||
const rawPackageJson = await parseJsoncFile(packageJsonPath);
|
||||
|
||||
const yarnLockPath = await findPathFile(appPath, 'yarn.lock');
|
||||
const rawYarnLock = await fs.readFile(yarnLockPath, 'utf8');
|
||||
|
||||
await validateSchema('app-manifest', rawPackageJson, packageJsonPath);
|
||||
|
||||
const agents = await loadCoreEntity(
|
||||
@@ -57,8 +73,12 @@ export const loadManifest = async (appPath: string): Promise<AppManifest> => {
|
||||
);
|
||||
|
||||
return {
|
||||
...rawPackageJson,
|
||||
agents,
|
||||
objects,
|
||||
packageJson: rawPackageJson,
|
||||
yarnLock: rawYarnLock,
|
||||
manifest: {
|
||||
...rawPackageJson,
|
||||
agents,
|
||||
objects,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,10 +6,14 @@ export const syncApp = async (
|
||||
appPath: string,
|
||||
apiService: ApiService,
|
||||
): Promise<any> => {
|
||||
const manifest = await loadManifest(appPath);
|
||||
const { manifest, packageJson, yarnLock } = await loadManifest(appPath);
|
||||
|
||||
try {
|
||||
const result = await apiService.syncApplication(manifest);
|
||||
const result = await apiService.syncApplication({
|
||||
manifest,
|
||||
packageJson,
|
||||
yarnLock,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(chalk.green('✅ Application synced successfully'));
|
||||
|
||||
@@ -15,7 +15,13 @@ export const createBasePackageJson = (
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' '),
|
||||
engines: {
|
||||
node: '^24.5.0',
|
||||
npm: 'please-use-yarn',
|
||||
yarn: '>=4.0.2',
|
||||
},
|
||||
description,
|
||||
license: 'MIT',
|
||||
version: '0.0.1',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1640,6 +1640,7 @@ export type Mutation = {
|
||||
createOneRemoteServer: RemoteServer;
|
||||
createOneRole: Role;
|
||||
createOneServerlessFunction: ServerlessFunction;
|
||||
createOneServerlessFunctionLayer: ServerlessFunctionLayer;
|
||||
createPageLayout: PageLayout;
|
||||
createPageLayoutTab: PageLayoutTab;
|
||||
createPageLayoutWidget: PageLayoutWidget;
|
||||
@@ -1954,6 +1955,12 @@ export type MutationCreateOneServerlessFunctionArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateOneServerlessFunctionLayerArgs = {
|
||||
packageJson: Scalars['JSON'];
|
||||
yarnLock: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreatePageLayoutArgs = {
|
||||
input: CreatePageLayoutInput;
|
||||
};
|
||||
@@ -2378,6 +2385,8 @@ export type MutationSubmitFormStepArgs = {
|
||||
|
||||
export type MutationSyncApplicationArgs = {
|
||||
manifest: Scalars['JSON'];
|
||||
packageJson: Scalars['JSON'];
|
||||
yarnLock: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
@@ -3572,6 +3581,14 @@ export type ServerlessFunctionIdInput = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
||||
export type ServerlessFunctionLayer = {
|
||||
__typename?: 'ServerlessFunctionLayer';
|
||||
applicationId?: Maybe<Scalars['UUID']>;
|
||||
createdAt: Scalars['DateTime'];
|
||||
id: Scalars['UUID'];
|
||||
updatedAt: Scalars['DateTime'];
|
||||
};
|
||||
|
||||
export type SetupOidcSsoInput = {
|
||||
clientID: Scalars['String'];
|
||||
clientSecret: Scalars['String'];
|
||||
|
||||
@@ -1576,6 +1576,7 @@ export type Mutation = {
|
||||
createOneObject: Object;
|
||||
createOneRole: Role;
|
||||
createOneServerlessFunction: ServerlessFunction;
|
||||
createOneServerlessFunctionLayer: ServerlessFunctionLayer;
|
||||
createPageLayout: PageLayout;
|
||||
createPageLayoutTab: PageLayoutTab;
|
||||
createPageLayoutWidget: PageLayoutWidget;
|
||||
@@ -1856,6 +1857,12 @@ export type MutationCreateOneServerlessFunctionArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateOneServerlessFunctionLayerArgs = {
|
||||
packageJson: Scalars['JSON'];
|
||||
yarnLock: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreatePageLayoutArgs = {
|
||||
input: CreatePageLayoutInput;
|
||||
};
|
||||
@@ -2265,6 +2272,8 @@ export type MutationSubmitFormStepArgs = {
|
||||
|
||||
export type MutationSyncApplicationArgs = {
|
||||
manifest: Scalars['JSON'];
|
||||
packageJson: Scalars['JSON'];
|
||||
yarnLock: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
@@ -3350,6 +3359,14 @@ export type ServerlessFunctionIdInput = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
||||
export type ServerlessFunctionLayer = {
|
||||
__typename?: 'ServerlessFunctionLayer';
|
||||
applicationId?: Maybe<Scalars['UUID']>;
|
||||
createdAt: Scalars['DateTime'];
|
||||
id: Scalars['UUID'];
|
||||
updatedAt: Scalars['DateTime'];
|
||||
};
|
||||
|
||||
export type SetupOidcSsoInput = {
|
||||
clientID: Scalars['String'];
|
||||
clientSecret: Scalars['String'];
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class UpdateServerlessFunctionLayerEntity1759236947406
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'UpdateServerlessFunctionLayerEntity1759236947406';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agent" DROP CONSTRAINT "FK_259c48f99f625708723414adb5d"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "core"."serverlessFunctionLayer" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "packageJson" jsonb NOT NULL, "yarnLock" text NOT NULL, "checksum" text NOT NULL, "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_a1077708d1b19463ab2eda7c246" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."serverlessFunction" ADD "serverlessFunctionLayerId" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."application" ADD "serverlessFunctionLayerId" uuid NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."application" ADD CONSTRAINT "UQ_eec488855d08b312a869a13ccb1" UNIQUE ("serverlessFunctionLayerId")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agent" ADD CONSTRAINT "FK_259c48f99f625708723414adb5d" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."serverlessFunction" ADD CONSTRAINT "FK_4b9625a4babf7f4fa942fd26514" FOREIGN KEY ("serverlessFunctionLayerId") REFERENCES "core"."serverlessFunctionLayer"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."serverlessFunction" ADD CONSTRAINT "FK_62cbd26626ff76df897181c7994" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."application" ADD CONSTRAINT "FK_eec488855d08b312a869a13ccb1" FOREIGN KEY ("serverlessFunctionLayerId") REFERENCES "core"."serverlessFunctionLayer"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."objectMetadata" ADD CONSTRAINT "FK_71a7af5a5c916f0b96f358f25f7" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."objectMetadata" DROP CONSTRAINT "FK_71a7af5a5c916f0b96f358f25f7"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."application" DROP CONSTRAINT "FK_eec488855d08b312a869a13ccb1"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."serverlessFunction" DROP CONSTRAINT "FK_62cbd26626ff76df897181c7994"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."serverlessFunction" DROP CONSTRAINT "FK_4b9625a4babf7f4fa942fd26514"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agent" DROP CONSTRAINT "FK_259c48f99f625708723414adb5d"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."application" DROP CONSTRAINT "UQ_eec488855d08b312a869a13ccb1"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."application" DROP COLUMN "serverlessFunctionLayerId"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."serverlessFunction" DROP COLUMN "serverlessFunctionLayerId"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "core"."serverlessFunctionLayer"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agent" ADD CONSTRAINT "FK_259c48f99f625708723414adb5d" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+50
-18
@@ -4,7 +4,6 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
AgentManifest,
|
||||
AppManifest,
|
||||
ObjectManifest,
|
||||
} from 'src/engine/core-modules/application/types/application.types';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
@@ -17,6 +16,8 @@ import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
|
||||
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationSyncService {
|
||||
@@ -24,17 +25,27 @@ export class ApplicationSyncService {
|
||||
|
||||
constructor(
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly serverlessFunctionLayerService: ServerlessFunctionLayerService,
|
||||
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly agentService: AgentService,
|
||||
) {}
|
||||
|
||||
public async synchronizeFromManifest(
|
||||
workspaceId: string,
|
||||
manifest: AppManifest,
|
||||
) {
|
||||
const applicationId = await this.syncApplication(manifest, workspaceId);
|
||||
public async synchronizeFromManifest({
|
||||
workspaceId,
|
||||
manifest,
|
||||
packageJson,
|
||||
yarnLock,
|
||||
}: ApplicationInput & {
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const applicationId = await this.syncApplication({
|
||||
workspaceId,
|
||||
manifest,
|
||||
packageJson,
|
||||
yarnLock,
|
||||
});
|
||||
|
||||
await this.syncAgents({
|
||||
agentsToSync: manifest.agents,
|
||||
@@ -51,32 +62,53 @@ export class ApplicationSyncService {
|
||||
this.logger.log('✅ Application sync from manifest completed');
|
||||
}
|
||||
|
||||
private async syncApplication(
|
||||
applicationToSync: AppManifest,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
private async syncApplication({
|
||||
workspaceId,
|
||||
manifest,
|
||||
packageJson,
|
||||
yarnLock,
|
||||
}: ApplicationInput & {
|
||||
workspaceId: string;
|
||||
}): Promise<string> {
|
||||
const application = await this.applicationService.findByStandardId(
|
||||
applicationToSync.standardId,
|
||||
manifest.standardId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isDefined(application)) {
|
||||
const serverlessFunctionLayer =
|
||||
await this.serverlessFunctionLayerService.create(
|
||||
{
|
||||
packageJson,
|
||||
yarnLock,
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
const createdApplication = await this.applicationService.create({
|
||||
standardId: applicationToSync.standardId,
|
||||
label: applicationToSync.label,
|
||||
description: applicationToSync.description,
|
||||
version: applicationToSync.version,
|
||||
standardId: manifest.standardId,
|
||||
label: manifest.label,
|
||||
description: manifest.description,
|
||||
version: manifest.version,
|
||||
sourcePath: 'cli-sync', // Placeholder for CLI-synced apps
|
||||
serverlessFunctionLayerId: serverlessFunctionLayer.id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return createdApplication.id;
|
||||
}
|
||||
|
||||
await this.serverlessFunctionLayerService.update(
|
||||
application.serverlessFunctionLayerId,
|
||||
{
|
||||
packageJson,
|
||||
yarnLock,
|
||||
},
|
||||
);
|
||||
|
||||
await this.applicationService.update(application.id, {
|
||||
label: applicationToSync.label,
|
||||
description: applicationToSync.description,
|
||||
version: applicationToSync.version,
|
||||
label: manifest.label,
|
||||
description: manifest.description,
|
||||
version: manifest.version,
|
||||
});
|
||||
|
||||
return application.id;
|
||||
|
||||
@@ -6,12 +6,18 @@ import {
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity';
|
||||
|
||||
@Entity({ name: 'application', schema: 'core' })
|
||||
@Index('IDX_APPLICATION_WORKSPACE_ID', ['workspaceId'])
|
||||
@@ -48,6 +54,38 @@ export class ApplicationEntity {
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
workspaceId: string;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
serverlessFunctionLayerId: string;
|
||||
|
||||
@OneToOne(
|
||||
() => ServerlessFunctionLayerEntity,
|
||||
(serverlessFunctionLayer) => serverlessFunctionLayer.application,
|
||||
{
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: 'serverlessFunctionLayerId' })
|
||||
serverlessFunctionLayer: Relation<ServerlessFunctionLayerEntity>;
|
||||
|
||||
@OneToMany(() => AgentEntity, (agent) => agent.application, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
agents: Relation<AgentEntity[]>;
|
||||
|
||||
@OneToMany(
|
||||
() => ServerlessFunctionEntity,
|
||||
(serverlessFunction) => serverlessFunction.application,
|
||||
{
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
)
|
||||
serverlessFunctions: Relation<ServerlessFunctionEntity[]>;
|
||||
|
||||
@OneToMany(() => ObjectMetadataEntity, (object) => object.application, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
objects: Relation<ObjectMetadataEntity[]>;
|
||||
|
||||
@ManyToOne(() => Workspace, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/core-mod
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -19,6 +20,7 @@ import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
|
||||
ObjectMetadataModule,
|
||||
DataSourceModule,
|
||||
AgentModule,
|
||||
ServerlessFunctionLayerModule,
|
||||
],
|
||||
providers: [ApplicationResolver, ApplicationService, ApplicationSyncService],
|
||||
})
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { AppManifest } from 'src/engine/core-modules/application/types/application.types';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-sync.service';
|
||||
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@Resolver()
|
||||
@@ -18,14 +16,15 @@ export class ApplicationResolver {
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async syncApplication(
|
||||
@Args('manifest', { type: () => GraphQLJSON })
|
||||
manifest: AppManifest,
|
||||
@Args() { manifest, packageJson, yarnLock }: ApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
await this.applicationSyncService.synchronizeFromManifest(
|
||||
await this.applicationSyncService.synchronizeFromManifest({
|
||||
workspaceId,
|
||||
manifest,
|
||||
);
|
||||
yarnLock,
|
||||
packageJson,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { PackageJson } from 'src/engine/core-modules/application/types/application.types';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationService {
|
||||
@@ -32,6 +33,7 @@ export class ApplicationService {
|
||||
label: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
serverlessFunctionLayerId: string;
|
||||
sourcePath: string;
|
||||
workspaceId: string;
|
||||
}): Promise<ApplicationEntity> {
|
||||
@@ -50,6 +52,9 @@ export class ApplicationService {
|
||||
description?: string;
|
||||
version?: string;
|
||||
sourcePath?: string;
|
||||
packageJson?: PackageJson;
|
||||
yarnLock?: string;
|
||||
packageChecksum?: string;
|
||||
},
|
||||
): Promise<ApplicationEntity> {
|
||||
await this.applicationRepository.update({ id }, data);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import {
|
||||
AppManifest,
|
||||
PackageJson,
|
||||
} from 'src/engine/core-modules/application/types/application.types';
|
||||
|
||||
@ArgsType()
|
||||
export class ApplicationInput {
|
||||
@Field(() => GraphQLJSON, { nullable: false })
|
||||
manifest: AppManifest;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: false })
|
||||
packageJson: PackageJson;
|
||||
|
||||
@Field(() => String, { nullable: false })
|
||||
yarnLock: string;
|
||||
}
|
||||
@@ -3,6 +3,11 @@ export type PackageJson = {
|
||||
standardId: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
engines: {
|
||||
node: string;
|
||||
npm: string;
|
||||
yarn: string;
|
||||
};
|
||||
icon?: string;
|
||||
version: string;
|
||||
dependencies?: object;
|
||||
|
||||
+6
-5
@@ -1,20 +1,21 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export const handler = async (event) => {
|
||||
const mainPath = `/tmp/${v4()}.mjs`;
|
||||
const randomId = randomBytes(16).toString('hex');
|
||||
|
||||
const mainPath = `/tmp/${randomId}.mjs`;
|
||||
|
||||
try {
|
||||
const { code, params } = event;
|
||||
|
||||
await fs.writeFile(mainPath, code, 'utf8');
|
||||
|
||||
process.env = {}
|
||||
process.env = {};
|
||||
|
||||
const mainFile = await import(mainPath);
|
||||
|
||||
return await mainFile.main(params);
|
||||
return await mainFile.main(params);
|
||||
} finally {
|
||||
await fs.rm(mainPath, { force: true });
|
||||
}
|
||||
|
||||
+47
-25
@@ -51,7 +51,6 @@ import {
|
||||
|
||||
const UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS = 60;
|
||||
const CREDENTIALS_DURATION_IN_SECONDS = 60 * 60; // 1h
|
||||
const LAMBDA_EXECUTOR_DESCRIPTION = 'User script executor';
|
||||
|
||||
export interface LambdaDriverOptions extends LambdaClientConfig {
|
||||
fileStorageService: FileStorageService;
|
||||
@@ -135,22 +134,31 @@ export class LambdaDriver implements ServerlessDriver {
|
||||
);
|
||||
}
|
||||
|
||||
private async createLayerIfNotExists(version: number): Promise<string> {
|
||||
private getLayerName(serverlessFunction: ServerlessFunctionEntity) {
|
||||
if (isDefined(serverlessFunction?.serverlessFunctionLayer)) {
|
||||
return serverlessFunction?.serverlessFunctionLayer.checksum;
|
||||
}
|
||||
|
||||
return COMMON_LAYER_NAME;
|
||||
}
|
||||
|
||||
private async createLayerIfNotExists(
|
||||
serverlessFunction: ServerlessFunctionEntity,
|
||||
): Promise<string> {
|
||||
const layerName = this.getLayerName(serverlessFunction);
|
||||
|
||||
const listLayerParams: ListLayerVersionsCommandInput = {
|
||||
LayerName: COMMON_LAYER_NAME,
|
||||
LayerName: layerName,
|
||||
MaxItems: 1,
|
||||
};
|
||||
|
||||
const listLayerCommand = new ListLayerVersionsCommand(listLayerParams);
|
||||
|
||||
const listLayerResult = await (
|
||||
await this.getLambdaClient()
|
||||
).send(listLayerCommand);
|
||||
|
||||
if (
|
||||
isDefined(listLayerResult.LayerVersions) &&
|
||||
listLayerResult.LayerVersions.length > 0 &&
|
||||
listLayerResult.LayerVersions?.[0].Description === `${version}` &&
|
||||
isDefined(listLayerResult.LayerVersions[0].LayerVersionArn)
|
||||
) {
|
||||
if (isDefined(listLayerResult.LayerVersions?.[0]?.LayerVersionArn)) {
|
||||
return listLayerResult.LayerVersions[0].LayerVersionArn;
|
||||
}
|
||||
|
||||
@@ -163,12 +171,12 @@ export class LambdaDriver implements ServerlessDriver {
|
||||
NODE_LAYER_SUBFOLDER,
|
||||
);
|
||||
|
||||
await copyAndBuildDependencies(nodeDependenciesFolder);
|
||||
await copyAndBuildDependencies(nodeDependenciesFolder, serverlessFunction);
|
||||
|
||||
await createZipFile(sourceTemporaryDir, lambdaZipPath);
|
||||
|
||||
const params: PublishLayerVersionCommandInput = {
|
||||
LayerName: COMMON_LAYER_NAME,
|
||||
LayerName: layerName,
|
||||
Content: {
|
||||
ZipFile: await fs.readFile(lambdaZipPath),
|
||||
},
|
||||
@@ -176,7 +184,6 @@ export class LambdaDriver implements ServerlessDriver {
|
||||
ServerlessFunctionRuntime.NODE18,
|
||||
ServerlessFunctionRuntime.NODE22,
|
||||
],
|
||||
Description: `${version}`,
|
||||
};
|
||||
|
||||
const command = new PublishLayerVersionCommand(params);
|
||||
@@ -220,22 +227,38 @@ export class LambdaDriver implements ServerlessDriver {
|
||||
}
|
||||
}
|
||||
|
||||
private async build(serverlessFunction: ServerlessFunctionEntity) {
|
||||
private async isAlreadyBuilt(serverlessFunction: ServerlessFunctionEntity) {
|
||||
const lambdaExecutor = await this.getLambdaExecutor(serverlessFunction);
|
||||
|
||||
if (isDefined(lambdaExecutor)) {
|
||||
if (
|
||||
lambdaExecutor.Configuration?.Description ===
|
||||
LAMBDA_EXECUTOR_DESCRIPTION
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.delete(serverlessFunction);
|
||||
if (!isDefined(lambdaExecutor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const layerArn = await this.createLayerIfNotExists(
|
||||
serverlessFunction.layerVersion ?? 0,
|
||||
);
|
||||
const layers = lambdaExecutor.Configuration?.Layers;
|
||||
|
||||
if (!isDefined(layers) || layers.length !== 1) {
|
||||
await this.delete(serverlessFunction);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const layerName = this.getLayerName(serverlessFunction);
|
||||
|
||||
if (layers[0].Arn?.includes(layerName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.delete(serverlessFunction);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async build(serverlessFunction: ServerlessFunctionEntity) {
|
||||
if (await this.isAlreadyBuilt(serverlessFunction)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layerArn = await this.createLayerIfNotExists(serverlessFunction);
|
||||
|
||||
const lambdaBuildDirectoryManager = new LambdaBuildDirectoryManager();
|
||||
|
||||
@@ -255,7 +278,6 @@ export class LambdaDriver implements ServerlessDriver {
|
||||
Handler: 'index.handler',
|
||||
Role: this.options.lambdaRole,
|
||||
Runtime: serverlessFunction.runtime,
|
||||
Description: LAMBDA_EXECUTOR_DESCRIPTION,
|
||||
Timeout: serverlessFunction.timeoutSeconds,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from 'path';
|
||||
|
||||
import ts, { transpileModule } from 'typescript';
|
||||
import { v4 } from 'uuid';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
type ServerlessDriver,
|
||||
@@ -31,25 +32,43 @@ export class LocalDriver implements ServerlessDriver {
|
||||
this.fileStorageService = options.fileStorageService;
|
||||
}
|
||||
|
||||
private getInMemoryLayerFolderPath = (version: number) => {
|
||||
return join(SERVERLESS_TMPDIR_FOLDER, COMMON_LAYER_NAME, `${version}`);
|
||||
private getInMemoryLayerFolderPath = (
|
||||
serverlessFunction: ServerlessFunctionEntity,
|
||||
) => {
|
||||
if (!isDefined(serverlessFunction?.serverlessFunctionLayer?.checksum)) {
|
||||
return join(
|
||||
SERVERLESS_TMPDIR_FOLDER,
|
||||
COMMON_LAYER_NAME,
|
||||
`${serverlessFunction.layerVersion}`,
|
||||
);
|
||||
}
|
||||
|
||||
return join(
|
||||
SERVERLESS_TMPDIR_FOLDER,
|
||||
serverlessFunction.serverlessFunctionLayer?.checksum,
|
||||
);
|
||||
};
|
||||
|
||||
private async createLayerIfNotExists(version: number) {
|
||||
const inMemoryLastVersionLayerFolderPath =
|
||||
this.getInMemoryLayerFolderPath(version);
|
||||
private async createLayerIfNotExists(
|
||||
serverlessFunction: ServerlessFunctionEntity,
|
||||
) {
|
||||
const inMemoryLayerFolderPath =
|
||||
this.getInMemoryLayerFolderPath(serverlessFunction);
|
||||
|
||||
try {
|
||||
await fs.access(inMemoryLastVersionLayerFolderPath);
|
||||
await fs.access(inMemoryLayerFolderPath);
|
||||
} catch {
|
||||
await copyAndBuildDependencies(inMemoryLastVersionLayerFolderPath);
|
||||
await copyAndBuildDependencies(
|
||||
inMemoryLayerFolderPath,
|
||||
serverlessFunction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async delete() {}
|
||||
|
||||
private async build(serverlessFunction: ServerlessFunctionEntity) {
|
||||
await this.createLayerIfNotExists(serverlessFunction.layerVersion ?? 0);
|
||||
await this.createLayerIfNotExists(serverlessFunction);
|
||||
}
|
||||
|
||||
private async executeWithTimeout<T>(
|
||||
@@ -119,7 +138,7 @@ export class LocalDriver implements ServerlessDriver {
|
||||
|
||||
await fs.symlink(
|
||||
join(
|
||||
this.getInMemoryLayerFolderPath(serverlessFunction.layerVersion),
|
||||
this.getInMemoryLayerFolderPath(serverlessFunction),
|
||||
'node_modules',
|
||||
),
|
||||
join(compiledCodeFolderPath, 'node_modules'),
|
||||
|
||||
+32
-4
@@ -3,18 +3,46 @@ import { promisify } from 'util';
|
||||
import { exec } from 'child_process';
|
||||
import { join } from 'path';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { getLayerDependenciesDirName } from 'src/engine/core-modules/serverless/drivers/utils/get-layer-dependencies-dir-name';
|
||||
import type { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
import { LAST_LAYER_VERSION } from 'src/engine/core-modules/serverless/drivers/layers/last-layer-version';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
export const copyAndBuildDependencies = async (buildDirectory: string) => {
|
||||
export const copyAndBuildDependencies = async (
|
||||
buildDirectory: string,
|
||||
serverlessFunction: ServerlessFunctionEntity,
|
||||
) => {
|
||||
await fs.mkdir(buildDirectory, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
await fs.cp(getLayerDependenciesDirName('latest'), buildDirectory, {
|
||||
recursive: true,
|
||||
});
|
||||
if (!isDefined(serverlessFunction.serverlessFunctionLayer)) {
|
||||
await fs.cp(
|
||||
getLayerDependenciesDirName(
|
||||
serverlessFunction.layerVersion || LAST_LAYER_VERSION,
|
||||
),
|
||||
buildDirectory,
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const packageJson = serverlessFunction.serverlessFunctionLayer.packageJson;
|
||||
|
||||
const yarnLock = serverlessFunction.serverlessFunctionLayer.yarnLock;
|
||||
|
||||
await fs.writeFile(
|
||||
join(buildDirectory, 'package.json'),
|
||||
JSON.stringify(packageJson, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await fs.writeFile(join(buildDirectory, 'yarn.lock'), yarnLock, 'utf8');
|
||||
}
|
||||
|
||||
await fs.cp(getLayerDependenciesDirName('engine'), buildDirectory, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
+23
-3
@@ -1,15 +1,20 @@
|
||||
import fs from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { getLayerDependenciesDirName } from 'src/engine/core-modules/serverless/drivers/utils/get-layer-dependencies-dir-name';
|
||||
import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
import { LAST_LAYER_VERSION } from 'src/engine/core-modules/serverless/drivers/layers/last-layer-version';
|
||||
import { type PackageJson } from 'src/engine/core-modules/application/types/application.types';
|
||||
|
||||
export type LayerDependencies = {
|
||||
packageJson: { dependencies: object };
|
||||
packageJson: PackageJson;
|
||||
yarnLock: string;
|
||||
};
|
||||
|
||||
export const getLayerDependencies = async (
|
||||
layerVersion: number | 'latest',
|
||||
export const getLastCommonLayerDependencies = async (
|
||||
layerVersion = LAST_LAYER_VERSION,
|
||||
): Promise<LayerDependencies> => {
|
||||
const lastVersionLayerDirName = getLayerDependenciesDirName(layerVersion);
|
||||
const [packageJson, yarnLock] = await Promise.all([
|
||||
@@ -19,3 +24,18 @@ export const getLayerDependencies = async (
|
||||
|
||||
return { packageJson: JSON.parse(packageJson), yarnLock };
|
||||
};
|
||||
|
||||
export const getLayerDependencies = async (
|
||||
serverlessFunction: ServerlessFunctionEntity,
|
||||
): Promise<LayerDependencies> => {
|
||||
if (!isDefined(serverlessFunction.serverlessFunctionLayer)) {
|
||||
return getLastCommonLayerDependencies(
|
||||
serverlessFunction.layerVersion ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
packageJson: serverlessFunction.serverlessFunctionLayer.packageJson,
|
||||
yarnLock: serverlessFunction.serverlessFunctionLayer.yarnLock,
|
||||
};
|
||||
};
|
||||
|
||||
+2
-5
@@ -1,16 +1,13 @@
|
||||
import path from 'path';
|
||||
|
||||
import { LAST_LAYER_VERSION } from 'src/engine/core-modules/serverless/drivers/layers/last-layer-version';
|
||||
import { ASSET_PATH } from 'src/constants/assets-path';
|
||||
|
||||
export const getLayerDependenciesDirName = (
|
||||
version: 'latest' | 'engine' | number,
|
||||
version: 'engine' | number,
|
||||
): string => {
|
||||
const formattedVersion = version === 'latest' ? LAST_LAYER_VERSION : version;
|
||||
|
||||
const baseTypescriptProjectPath = path.join(
|
||||
ASSET_PATH,
|
||||
`engine/core-modules/serverless/drivers/layers/${formattedVersion}`,
|
||||
`engine/core-modules/serverless/drivers/layers/${version}`,
|
||||
);
|
||||
|
||||
return path.resolve(__dirname, baseTypescriptProjectPath);
|
||||
|
||||
@@ -69,8 +69,8 @@ export class AgentEntity {
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<Workspace>;
|
||||
|
||||
@ManyToOne(() => ApplicationEntity, {
|
||||
onDelete: 'SET NULL',
|
||||
@ManyToOne(() => ApplicationEntity, (application) => application.agents, {
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
@JoinColumn({ name: 'applicationId' })
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ export const objectMetadataEntityRelationProperties = [
|
||||
'indexMetadatas',
|
||||
'targetRelationFields',
|
||||
'dataSource',
|
||||
'application',
|
||||
'objectPermissions',
|
||||
'fieldPermissions',
|
||||
] as const satisfies ObjectMetadataRelationProperties[];
|
||||
|
||||
@@ -9,11 +9,12 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { RemoteServerModule } from 'src/engine/metadata-modules/remote-server/remote-server.module';
|
||||
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
||||
import { RouteModule } from 'src/engine/metadata-modules/route/route.module';
|
||||
import { SearchFieldMetadataModule } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.module';
|
||||
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
|
||||
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
|
||||
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
|
||||
import { RouteModule } from 'src/engine/metadata-modules/route/route.module';
|
||||
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -22,6 +23,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
|
||||
ObjectMetadataModule,
|
||||
SearchFieldMetadataModule,
|
||||
ServerlessFunctionModule,
|
||||
ServerlessFunctionLayerModule,
|
||||
AgentModule,
|
||||
WorkspaceMetadataVersionModule,
|
||||
WorkspaceMigrationModule,
|
||||
|
||||
+9
@@ -2,6 +2,7 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
@@ -17,6 +18,7 @@ import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/
|
||||
import { type ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto';
|
||||
import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity';
|
||||
import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
|
||||
@Entity('objectMetadata')
|
||||
@Unique('IDX_OBJECT_METADATA_NAME_SINGULAR_WORKSPACE_ID_UNIQUE', [
|
||||
@@ -133,6 +135,13 @@ export class ObjectMetadataEntity implements Required<ObjectMetadataEntity> {
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToOne(() => ApplicationEntity, (application) => application.objects, {
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
@JoinColumn({ name: 'applicationId' })
|
||||
application: Relation<ApplicationEntity> | null;
|
||||
|
||||
@OneToMany(
|
||||
() => ObjectPermissionEntity,
|
||||
(objectPermission: ObjectPermissionEntity) =>
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsString } from 'class-validator';
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { PackageJson } from 'src/engine/core-modules/application/types/application.types';
|
||||
|
||||
@ArgsType()
|
||||
export class CreateServerlessFunctionLayerInput {
|
||||
@Field(() => GraphQLJSON, { nullable: false })
|
||||
packageJson: PackageJson;
|
||||
|
||||
@IsString()
|
||||
@Field(() => String, { nullable: false })
|
||||
yarnLock: string;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IsDateString } from 'class-validator';
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('ServerlessFunctionLayer')
|
||||
export class ServerlessFunctionLayerDTO {
|
||||
@IDField(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@IDField(() => UUIDScalarType, { nullable: true })
|
||||
applicationId?: string;
|
||||
|
||||
@IsDateString()
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
|
||||
@IsDateString()
|
||||
@Field()
|
||||
updatedAt: Date;
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { PackageJson } from 'src/engine/core-modules/application/types/application.types';
|
||||
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
|
||||
@Entity('serverlessFunctionLayer')
|
||||
export class ServerlessFunctionLayerEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: false })
|
||||
packageJson: PackageJson;
|
||||
|
||||
@Column({ type: 'text', nullable: false })
|
||||
yarnLock: string;
|
||||
|
||||
@Column({ type: 'text', nullable: false })
|
||||
checksum: string;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
workspaceId: string;
|
||||
|
||||
@OneToMany(
|
||||
() => ServerlessFunctionEntity,
|
||||
(serverlessFunction) => serverlessFunction.serverlessFunctionLayer,
|
||||
{
|
||||
onDelete: 'RESTRICT',
|
||||
},
|
||||
)
|
||||
serverlessFunctions: Relation<ServerlessFunctionEntity[]>;
|
||||
|
||||
@OneToOne(
|
||||
() => ApplicationEntity,
|
||||
(application) => application.serverlessFunctionLayer,
|
||||
{
|
||||
nullable: true,
|
||||
},
|
||||
)
|
||||
application: Relation<ApplicationEntity> | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity';
|
||||
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
|
||||
import { ServerlessFunctionLayerResolver } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.resolver';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ServerlessFunctionLayerEntity])],
|
||||
providers: [ServerlessFunctionLayerService, ServerlessFunctionLayerResolver],
|
||||
exports: [ServerlessFunctionLayerService],
|
||||
})
|
||||
export class ServerlessFunctionLayerModule {}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
|
||||
import { CreateServerlessFunctionLayerInput } from 'src/engine/metadata-modules/serverless-function-layer/dtos/create-serverless-function-layer.input';
|
||||
import { ServerlessFunctionLayerDTO } from 'src/engine/metadata-modules/serverless-function-layer/dtos/serverless-function-layer.dto';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@Resolver()
|
||||
export class ServerlessFunctionLayerResolver {
|
||||
constructor(
|
||||
private readonly serverlessFunctionLayerService: ServerlessFunctionLayerService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => ServerlessFunctionLayerDTO)
|
||||
async createOneServerlessFunctionLayer(
|
||||
@Args()
|
||||
createServerlessFunctionLayerInput: CreateServerlessFunctionLayerInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
return this.serverlessFunctionLayerService.create(
|
||||
createServerlessFunctionLayerInput,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity';
|
||||
import { CreateServerlessFunctionLayerInput } from 'src/engine/metadata-modules/serverless-function-layer/dtos/create-serverless-function-layer.input';
|
||||
import { getLastCommonLayerDependencies } from 'src/engine/core-modules/serverless/drivers/utils/get-last-layer-dependencies';
|
||||
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
|
||||
|
||||
@Injectable()
|
||||
export class ServerlessFunctionLayerService {
|
||||
constructor(
|
||||
@InjectRepository(ServerlessFunctionLayerEntity)
|
||||
private readonly serverlessFunctionLayerRepository: Repository<ServerlessFunctionLayerEntity>,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
{ packageJson, yarnLock }: CreateServerlessFunctionLayerInput,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const checksum = serverlessFunctionCreateHash(yarnLock);
|
||||
|
||||
const serverlessFunctionLayer =
|
||||
this.serverlessFunctionLayerRepository.create({
|
||||
packageJson,
|
||||
yarnLock,
|
||||
checksum,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return this.serverlessFunctionLayerRepository.save(serverlessFunctionLayer);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: QueryDeepPartialEntity<ServerlessFunctionLayerEntity>,
|
||||
) {
|
||||
const checksum = data.yarnLock
|
||||
? serverlessFunctionCreateHash(data.yarnLock as string)
|
||||
: undefined;
|
||||
|
||||
const updateData = { ...data, ...(checksum && { checksum }) };
|
||||
|
||||
return this.serverlessFunctionLayerRepository.update(id, updateData);
|
||||
}
|
||||
|
||||
async createCommonLayerIfNotExist(workspaceId: string) {
|
||||
const { packageJson, yarnLock } = await getLastCommonLayerDependencies();
|
||||
const checksum = serverlessFunctionCreateHash(yarnLock);
|
||||
const commonLayer = await this.serverlessFunctionLayerRepository.findOne({
|
||||
where: {
|
||||
checksum,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDefined(commonLayer)) {
|
||||
return commonLayer;
|
||||
}
|
||||
|
||||
return this.create({ packageJson, yarnLock }, workspaceId);
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -1,4 +1,4 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { Field, HideField, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsNotEmpty,
|
||||
@@ -27,4 +27,10 @@ export class CreateServerlessFunctionInput {
|
||||
@Max(900)
|
||||
@IsOptional()
|
||||
timeoutSeconds?: number;
|
||||
|
||||
@HideField()
|
||||
applicationId?: string;
|
||||
|
||||
@HideField()
|
||||
serverlessFunctionLayerId?: string;
|
||||
}
|
||||
|
||||
+27
@@ -5,8 +5,11 @@ import {
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@@ -17,6 +20,8 @@ import { DatabaseEventTrigger } from 'src/engine/metadata-modules/database-event
|
||||
import { Route } from 'src/engine/metadata-modules/route/route.entity';
|
||||
import { ServerlessFunctionEntityRelationProperties } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
|
||||
import { InputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/input-schema.type';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity';
|
||||
|
||||
const DEFAULT_SERVERLESS_TIMEOUT_SECONDS = 300; // 5 minutes
|
||||
|
||||
@@ -74,6 +79,28 @@ export class ServerlessFunctionEntity
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
checksum: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
serverlessFunctionLayerId: string | null;
|
||||
|
||||
@ManyToOne(
|
||||
() => ServerlessFunctionLayerEntity,
|
||||
(serverlessFunctionLayer) => serverlessFunctionLayer.serverlessFunctions,
|
||||
{ nullable: true },
|
||||
)
|
||||
@JoinColumn({ name: 'serverlessFunctionLayerId' })
|
||||
serverlessFunctionLayer: Relation<ServerlessFunctionLayerEntity> | null;
|
||||
|
||||
@ManyToOne(
|
||||
() => ApplicationEntity,
|
||||
(application) => application.serverlessFunctions,
|
||||
{
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: 'applicationId' })
|
||||
application: Relation<ApplicationEntity> | null;
|
||||
|
||||
@OneToMany(
|
||||
() => CronTrigger,
|
||||
(cronTrigger) => cronTrigger.serverlessFunction,
|
||||
|
||||
+2
@@ -17,6 +17,7 @@ import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverles
|
||||
import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service';
|
||||
import { WorkspaceFlatServerlessFunctionMapCacheService } from 'src/engine/metadata-modules/serverless-function/services/workspace-flat-serverless-function-map-cache.service';
|
||||
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
|
||||
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -29,6 +30,7 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
|
||||
FeatureFlagModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
WorkspaceMigrationV2Module,
|
||||
ServerlessFunctionLayerModule,
|
||||
],
|
||||
providers: [
|
||||
ServerlessFunctionService,
|
||||
|
||||
+20
-8
@@ -16,7 +16,6 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
|
||||
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 { LAST_LAYER_VERSION } from 'src/engine/core-modules/serverless/drivers/layers/last-layer-version';
|
||||
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,12 +33,14 @@ import {
|
||||
WorkflowVersionStepException,
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class ServerlessFunctionService {
|
||||
constructor(
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly serverlessService: ServerlessService,
|
||||
private readonly serverlessFunctionLayerService: ServerlessFunctionLayerService,
|
||||
@InjectRepository(ServerlessFunctionEntity)
|
||||
private readonly serverlessFunctionRepository: Repository<ServerlessFunctionEntity>,
|
||||
private readonly throttlerService: ThrottlerService,
|
||||
@@ -116,6 +117,7 @@ export class ServerlessFunctionService {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
relations: ['serverlessFunctionLayer'],
|
||||
});
|
||||
|
||||
const resultServerlessFunction = await this.serverlessService.execute(
|
||||
@@ -304,14 +306,16 @@ export class ServerlessFunctionService {
|
||||
|
||||
async getAvailablePackages(serverlessFunctionId: string) {
|
||||
const serverlessFunction =
|
||||
await this.serverlessFunctionRepository.findOneBy({
|
||||
id: serverlessFunctionId,
|
||||
await this.serverlessFunctionRepository.findOneOrFail({
|
||||
where: { id: serverlessFunctionId },
|
||||
relations: ['serverlessFunctionLayer'],
|
||||
});
|
||||
const { packageJson, yarnLock } = await getLayerDependencies(
|
||||
serverlessFunction?.layerVersion || 'latest',
|
||||
);
|
||||
|
||||
const { packageJson, yarnLock } =
|
||||
await getLayerDependencies(serverlessFunction);
|
||||
|
||||
const packageVersionRegex = /^"([^@]+)@.*?":\n\s+version: (.+)$/gm;
|
||||
|
||||
const versions: Record<string, string> = {};
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
@@ -321,7 +325,7 @@ export class ServerlessFunctionService {
|
||||
const version = match[2];
|
||||
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
if (packageJson.dependencies[packageName]) {
|
||||
if (packageJson.dependencies?.[packageName]) {
|
||||
versions[packageName] = version;
|
||||
}
|
||||
}
|
||||
@@ -333,11 +337,16 @@ export class ServerlessFunctionService {
|
||||
serverlessFunctionInput: CreateServerlessFunctionInput,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const commonServerlessFunctionLayer =
|
||||
await this.serverlessFunctionLayerService.createCommonLayerIfNotExist(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const serverlessFunctionToCreate = this.serverlessFunctionRepository.create(
|
||||
{
|
||||
...serverlessFunctionInput,
|
||||
workspaceId,
|
||||
layerVersion: LAST_LAYER_VERSION,
|
||||
serverlessFunctionLayerId: commonServerlessFunctionLayer.id,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -422,6 +431,9 @@ export class ServerlessFunctionService {
|
||||
name: serverlessFunctionToDuplicate.name,
|
||||
description: serverlessFunctionToDuplicate.description ?? undefined,
|
||||
timeoutSeconds: serverlessFunctionToDuplicate.timeoutSeconds,
|
||||
applicationId: serverlessFunctionToDuplicate.applicationId ?? undefined,
|
||||
serverlessFunctionLayerId:
|
||||
serverlessFunctionToDuplicate.serverlessFunctionLayerId ?? undefined,
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
+8
-1
@@ -4,11 +4,18 @@ import { type DatabaseEventTrigger } from 'src/engine/metadata-modules/database-
|
||||
import { type Route } from 'src/engine/metadata-modules/route/route.entity';
|
||||
import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
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';
|
||||
|
||||
export type ServerlessFunctionEntityRelationProperties =
|
||||
ExtractRecordTypeOrmRelationProperties<
|
||||
ServerlessFunctionEntity,
|
||||
CronTrigger | DatabaseEventTrigger | Route | Workspace
|
||||
| CronTrigger
|
||||
| DatabaseEventTrigger
|
||||
| Route
|
||||
| Workspace
|
||||
| ApplicationEntity
|
||||
| ServerlessFunctionLayerEntity
|
||||
>;
|
||||
|
||||
export type FlatServerlessFunction = Omit<
|
||||
|
||||
+3
-1
@@ -26,11 +26,13 @@ export const fromCreateServerlessFunctionInputToFlatServerlessFunction = ({
|
||||
deletedAt: null,
|
||||
latestVersion: null,
|
||||
publishedVersions: [],
|
||||
applicationId: null,
|
||||
applicationId: createServerlessFunctionInput.applicationId ?? null,
|
||||
latestVersionInputSchema: null,
|
||||
runtime: ServerlessFunctionRuntime.NODE22,
|
||||
timeoutSeconds: createServerlessFunctionInput.timeoutSeconds ?? 300,
|
||||
layerVersion: LAST_LAYER_VERSION,
|
||||
serverlessFunctionLayerId:
|
||||
createServerlessFunctionInput.serverlessFunctionLayerId ?? null,
|
||||
workspaceId,
|
||||
checksum: null,
|
||||
};
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { type Relation } from 'typeorm';
|
||||
export type ExtractRecordTypeOrmRelationProperties<T, TRelationTargets> =
|
||||
NonNullable<
|
||||
{
|
||||
[P in keyof T]: T[P] extends Relation<
|
||||
[P in keyof T]: NonNullable<T[P]> extends Relation<
|
||||
TRelationTargets | TRelationTargets[]
|
||||
>
|
||||
? P
|
||||
|
||||
+2
@@ -5,6 +5,7 @@ import { type IndexMetadataEntity } from 'src/engine/metadata-modules/index-meta
|
||||
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { type FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity';
|
||||
import { type ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
|
||||
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
|
||||
export type MetadataEntitiesRelationTarget =
|
||||
| ObjectMetadataEntity
|
||||
@@ -12,5 +13,6 @@ export type MetadataEntitiesRelationTarget =
|
||||
| IndexFieldMetadataEntity
|
||||
| FieldPermissionEntity
|
||||
| DataSourceEntity
|
||||
| ApplicationEntity
|
||||
| IndexMetadataEntity
|
||||
| ObjectPermissionEntity;
|
||||
|
||||
@@ -42,6 +42,7 @@ export const getMockObjectMetadataEntity = (
|
||||
shortcut: null,
|
||||
standardId: null,
|
||||
applicationId: null,
|
||||
application: null,
|
||||
targetRelationFields: [],
|
||||
standardOverrides: null,
|
||||
targetTableName: faker.string.uuid(),
|
||||
|
||||
Reference in New Issue
Block a user