Add route trigger to twenty apps (#14864)

This commit is contained in:
Weiko
2025-10-03 12:11:18 +02:00
committed by GitHub
parent a6ffd4707a
commit 329af2b670
9 changed files with 276 additions and 24 deletions
@@ -1,11 +1,12 @@
import chalk from 'chalk';
import { randomUUID } from 'crypto';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import path from 'path';
import { randomUUID } from 'crypto';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { HTTPMethod } from '../types/config.types';
import { parseJsoncFile, writeJsoncFile } from '../utils/jsonc-parser';
import { getSchemaUrls } from '../utils/schema-validator';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
export enum SyncableEntity {
AGENT = 'agent',
@@ -97,7 +98,7 @@ export class AppAddCommand {
}
private async getEntity() {
const { entity } = await inquirer.prompt([
const { entity } = await inquirer.prompt<{ entity: SyncableEntity }>([
{
type: 'select',
name: 'entity',
@@ -107,11 +108,11 @@ export class AppAddCommand {
},
]);
return entity as SyncableEntity;
return entity;
}
private async getEntityName(entity: SyncableEntity) {
const { name } = await inquirer.prompt([
const { name } = await inquirer.prompt<{ name: string }>([
{
type: 'input',
name: 'name',
@@ -131,7 +132,7 @@ export class AppAddCommand {
},
]);
return name as string;
return name;
}
private async getEntityToCreateData(
@@ -169,7 +170,7 @@ export class AppAddCommand {
continue;
}
const answer = await inquirer.prompt([
const answer = await inquirer.prompt<{ [key: string]: string }>([
{
type: 'input',
name: requiredField,
@@ -206,7 +207,9 @@ export class AppAddCommand {
process.exit(1);
}
const { serverlessFunctionName } = await inquirer.prompt([
const { serverlessFunctionName } = await inquirer.prompt<{
serverlessFunctionName: string;
}>([
{
type: 'list',
name: 'serverlessFunctionName',
@@ -215,12 +218,12 @@ export class AppAddCommand {
},
]);
const { triggerType } = await inquirer.prompt([
const { triggerType } = await inquirer.prompt<{ triggerType: string }>([
{
type: 'list',
name: 'triggerType',
message: 'Select the type of trigger:',
choices: ['databaseEvent', 'cron'],
choices: ['databaseEvent', 'cron', 'route'],
},
]);
@@ -230,6 +233,8 @@ export class AppAddCommand {
triggerData = await this.createDatabaseEventTrigger();
} else if (triggerType === 'cron') {
triggerData = await this.createCronTrigger();
} else if (triggerType === 'route') {
triggerData = await this.createRouteTrigger();
}
const manifestPath = path.join(
@@ -256,7 +261,7 @@ export class AppAddCommand {
private async createDatabaseEventTrigger() {
const uuid = randomUUID();
const { eventName } = await inquirer.prompt([
const { eventName } = await inquirer.prompt<{ eventName: string }>([
{
type: 'input',
name: 'eventName',
@@ -284,7 +289,7 @@ export class AppAddCommand {
private async createCronTrigger() {
const uuid = randomUUID();
const { schedule } = await inquirer.prompt([
const { schedule } = await inquirer.prompt<{ schedule: string }>([
{
type: 'input',
name: 'schedule',
@@ -310,4 +315,54 @@ export class AppAddCommand {
schedule,
};
}
private async createRouteTrigger() {
const uuid = randomUUID();
const { path } = await inquirer.prompt<{ path: string }>([
{
type: 'input',
name: 'path',
message: 'Enter the route path (e.g., /webhook/company):',
validate: (input) => {
if (input.length === 0) {
return 'Path is required';
}
if (!input.startsWith('/')) {
return 'Path must start with /';
}
return true;
},
},
]);
const { httpMethod } = await inquirer.prompt<{ httpMethod: HTTPMethod }>([
{
type: 'list',
name: 'httpMethod',
message: 'Select the HTTP method:',
choices: Object.values(HTTPMethod),
default: HTTPMethod.GET,
},
]);
const { isAuthRequired } = await inquirer.prompt<{
isAuthRequired: boolean;
}>([
{
type: 'confirm',
name: 'isAuthRequired',
message: 'Is authentication required?',
default: true,
},
]);
return {
universalIdentifier: uuid,
type: 'route',
path,
httpMethod,
isAuthRequired,
};
}
}
@@ -43,6 +43,14 @@ export type ServerlessFunctionManifest = {
code: ServerlessFunctionCodeManifest;
};
export enum HTTPMethod {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
PATCH = 'PATCH',
DELETE = 'DELETE',
}
export type ServerlessFunctionTriggerManifest =
| {
type: 'cron';
@@ -55,7 +63,7 @@ export type ServerlessFunctionTriggerManifest =
| {
type: 'route';
path: string;
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
httpMethod: HTTPMethod;
isAuthRequired: boolean;
};
@@ -808,6 +808,13 @@ export type CreateRoleInput = {
label: Scalars['String'];
};
export type CreateRouteTriggerInput = {
httpMethod?: HttpMethod;
isAuthRequired?: Scalars['Boolean'];
path: Scalars['String'];
serverlessFunctionId: Scalars['String'];
};
export type CreateServerlessFunctionInput = {
code?: InputMaybe<Scalars['JSON']>;
description?: InputMaybe<Scalars['String']>;
@@ -1633,6 +1640,7 @@ export type Mutation = {
createOneField: Field;
createOneObject: Object;
createOneRole: Role;
createOneRouteTrigger: RouteTrigger;
createOneServerlessFunction: ServerlessFunction;
createOneServerlessFunctionLayer: ServerlessFunctionLayer;
createPageLayout: PageLayout;
@@ -1661,6 +1669,7 @@ export type Mutation = {
deleteOneField: Field;
deleteOneObject: Object;
deleteOneRole: Scalars['String'];
deleteOneRouteTrigger: RouteTrigger;
deleteOneServerlessFunction: ServerlessFunction;
deletePageLayout: PageLayout;
deletePageLayoutTab: Scalars['Boolean'];
@@ -1740,6 +1749,7 @@ export type Mutation = {
updateOneField: Field;
updateOneObject: Object;
updateOneRole: Role;
updateOneRouteTrigger: RouteTrigger;
updateOneServerlessFunction: ServerlessFunction;
updatePageLayout: PageLayout;
updatePageLayoutTab: PageLayoutTab;
@@ -1924,6 +1934,11 @@ export type MutationCreateOneRoleArgs = {
};
export type MutationCreateOneRouteTriggerArgs = {
input: CreateRouteTriggerInput;
};
export type MutationCreateOneServerlessFunctionArgs = {
input: CreateServerlessFunctionInput;
};
@@ -2060,6 +2075,11 @@ export type MutationDeleteOneRoleArgs = {
};
export type MutationDeleteOneRouteTriggerArgs = {
input: RouteTriggerIdInput;
};
export type MutationDeleteOneServerlessFunctionArgs = {
input: ServerlessFunctionIdInput;
};
@@ -2448,6 +2468,11 @@ export type MutationUpdateOneRoleArgs = {
};
export type MutationUpdateOneRouteTriggerArgs = {
input: UpdateRouteTriggerInput;
};
export type MutationUpdateOneServerlessFunctionArgs = {
input: UpdateServerlessFunctionInput;
};
@@ -2913,10 +2938,12 @@ export type Query = {
findManyCronTriggers: Array<CronTrigger>;
findManyDatabaseEventTriggers: Array<DatabaseEventTrigger>;
findManyPublicDomains: Array<PublicDomain>;
findManyRouteTriggers: Array<RouteTrigger>;
findManyServerlessFunctions: Array<ServerlessFunction>;
findOneAgent: Agent;
findOneCronTrigger: CronTrigger;
findOneDatabaseEventTrigger: DatabaseEventTrigger;
findOneRouteTrigger: RouteTrigger;
findOneServerlessFunction: ServerlessFunction;
findWorkspaceFromInviteHash: Workspace;
findWorkspaceInvitations: Array<WorkspaceInvitation>;
@@ -3035,6 +3062,11 @@ export type QueryFindOneDatabaseEventTriggerArgs = {
};
export type QueryFindOneRouteTriggerArgs = {
input: RouteTriggerIdInput;
};
export type QueryFindOneServerlessFunctionArgs = {
input: ServerlessFunctionIdInput;
};
@@ -3369,6 +3401,10 @@ export type RouteTrigger = {
workspaceId: Scalars['String'];
};
export type RouteTriggerIdInput = {
id: Scalars['String'];
};
export type RunWorkflowVersionInput = {
/** Execution result in JSON format */
payload?: InputMaybe<Scalars['JSON']>;
@@ -3854,6 +3890,19 @@ export type UpdateRolePayload = {
label?: InputMaybe<Scalars['String']>;
};
export type UpdateRouteTriggerInput = {
/** The id of the route to update */
id: Scalars['String'];
/** The route updates */
update: UpdateRouteTriggerInputUpdates;
};
export type UpdateRouteTriggerInputUpdates = {
httpMethod: HttpMethod;
isAuthRequired: Scalars['Boolean'];
path: Scalars['String'];
};
export type UpdateServerlessFunctionInput = {
/** Id of the serverless function to update */
id: Scalars['UUID'];
@@ -23,6 +23,8 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-
import { DatabaseEventTriggerV2Service } from 'src/engine/metadata-modules/database-event-trigger/services/database-event-trigger-v2.service';
import { FlatDatabaseEventTrigger } from 'src/engine/metadata-modules/database-event-trigger/types/flat-database-event-trigger.type';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { RouteTriggerV2Service } from 'src/engine/metadata-modules/route-trigger/services/route-trigger-v2.service';
import { FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/types/flat-route-trigger.type';
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service';
import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
@@ -41,6 +43,7 @@ export class ApplicationSyncService {
private readonly agentService: AgentService,
private readonly databaseEventTriggerV2Service: DatabaseEventTriggerV2Service,
private readonly cronTriggerV2Service: CronTriggerV2Service,
private readonly routeTriggerV2Service: RouteTriggerV2Service,
) {}
public async synchronizeFromManifest({
@@ -392,6 +395,12 @@ export class ApplicationSyncService {
triggersToSync: serverlessFunctionToSync.triggers || [],
workspaceId,
});
await this.syncRouteTriggersForServerlessFunction({
serverlessFunctionId: serverlessFunctionToUpdate.id,
triggersToSync: serverlessFunctionToSync.triggers || [],
workspaceId,
});
}
for (const serverlessFunctionToCreate of serverlessFunctionsToCreate) {
@@ -421,6 +430,12 @@ export class ApplicationSyncService {
triggersToSync: serverlessFunctionToCreate.triggers || [],
workspaceId,
});
await this.syncRouteTriggersForServerlessFunction({
serverlessFunctionId: createdServerlessFunction.id,
triggersToSync: serverlessFunctionToCreate.triggers || [],
workspaceId,
});
}
}
@@ -501,7 +516,7 @@ export class ApplicationSyncService {
if (!triggerToSync || triggerToSync.type !== 'databaseEvent') {
throw new ApplicationException(
`Failed to find database event trigger to sync with universalIdentifier ${triggerToUpdate.universalIdentifier}`,
ApplicationExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
ApplicationExceptionCode.ENTITY_NOT_FOUND,
);
}
@@ -614,7 +629,7 @@ export class ApplicationSyncService {
if (!triggerToSync || triggerToSync.type !== 'cron') {
throw new ApplicationException(
`Failed to find cron trigger to sync with universalIdentifier ${triggerToUpdate.universalIdentifier}`,
ApplicationExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
ApplicationExceptionCode.ENTITY_NOT_FOUND,
);
}
@@ -652,4 +667,118 @@ export class ApplicationSyncService {
);
}
}
private async syncRouteTriggersForServerlessFunction({
serverlessFunctionId,
triggersToSync,
workspaceId,
}: {
serverlessFunctionId: string;
triggersToSync: ServerlessFunctionTriggerManifest[];
workspaceId: string;
}) {
const routeTriggersToSync = triggersToSync.filter(
(trigger) => trigger.type === 'route',
);
const { flatRouteTriggerMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatEntities: ['flatRouteTriggerMaps'],
},
);
const existingRouteTriggers = Object.values(
flatRouteTriggerMaps.byId,
).filter(
(trigger) =>
isDefined(trigger) &&
trigger.serverlessFunctionId === serverlessFunctionId,
) as FlatRouteTrigger[];
const triggersToSyncUniversalIdentifiers = routeTriggersToSync.map(
(trigger) => trigger.universalIdentifier,
);
const existingTriggersUniversalIdentifiers = existingRouteTriggers.map(
(trigger) => trigger.universalIdentifier,
);
const triggersToDelete = existingRouteTriggers.filter(
(trigger) =>
isDefined(trigger.universalIdentifier) &&
!triggersToSyncUniversalIdentifiers.includes(
trigger.universalIdentifier,
),
);
const triggersToUpdate = existingRouteTriggers.filter(
(trigger) =>
isDefined(trigger.universalIdentifier) &&
triggersToSyncUniversalIdentifiers.includes(
trigger.universalIdentifier,
),
);
const triggersToCreate = routeTriggersToSync.filter(
(triggerToSync) =>
!existingTriggersUniversalIdentifiers.includes(
triggerToSync.universalIdentifier,
),
);
for (const triggerToDelete of triggersToDelete) {
await this.routeTriggerV2Service.destroyOne({
destroyRouteTriggerInput: { id: triggerToDelete.id },
workspaceId,
});
}
for (const triggerToUpdate of triggersToUpdate) {
const triggerToSync = routeTriggersToSync.find(
(trigger) =>
trigger.universalIdentifier === triggerToUpdate.universalIdentifier,
);
if (!triggerToSync || triggerToSync.type !== 'route') {
throw new ApplicationException(
`Failed to find route trigger to sync with universalIdentifier ${triggerToUpdate.universalIdentifier}`,
ApplicationExceptionCode.ENTITY_NOT_FOUND,
);
}
const updateRouteTriggerInput = {
id: triggerToUpdate.id,
update: {
path: triggerToSync.path,
httpMethod: triggerToSync.httpMethod,
isAuthRequired: triggerToSync.isAuthRequired,
},
};
await this.routeTriggerV2Service.updateOne(
updateRouteTriggerInput,
workspaceId,
);
}
for (const triggerToCreate of triggersToCreate) {
if (triggerToCreate.type !== 'route') {
continue;
}
const createRouteTriggerInput = {
path: triggerToCreate.path,
httpMethod: triggerToCreate.httpMethod,
isAuthRequired: triggerToCreate.isAuthRequired,
serverlessFunctionId,
};
await this.routeTriggerV2Service.createOne(
createRouteTriggerInput,
workspaceId,
);
}
}
}
@@ -5,4 +5,5 @@ export class ApplicationException extends CustomException<ApplicationExceptionCo
export enum ApplicationExceptionCode {
OBJECT_NOT_FOUND = 'OBJECT_NOT_FOUND',
SERVERLESS_FUNCTION_NOT_FOUND = 'SERVERLESS_FUNCTION_NOT_FOUND',
ENTITY_NOT_FOUND = 'ENTITY_NOT_FOUND',
}
@@ -15,6 +15,7 @@ import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serve
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database-event-trigger/database-event-trigger.module';
import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron-trigger.module';
import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/route-trigger.module';
@Module({
imports: [
@@ -27,6 +28,7 @@ import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron
ServerlessFunctionModule,
DatabaseEventTriggerModule,
CronTriggerModule,
RouteTriggerModule,
],
providers: [ApplicationResolver, ApplicationService, ApplicationSyncService],
})
@@ -1,3 +1,4 @@
import { type HTTPMethod } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
export type PackageJson = {
@@ -44,7 +45,7 @@ export type ServerlessFunctionTriggerManifest = (
| {
type: 'route';
path: string;
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
httpMethod: HTTPMethod;
isAuthRequired: boolean;
}
) & {
@@ -63,7 +63,7 @@ export class RouteTriggerResolver {
) {
try {
return await this.routeV2Service.destroyOne({
destroyRouteInput: input,
destroyRouteTriggerInput: input,
workspaceId,
});
} catch (error) {
@@ -28,7 +28,10 @@ export class RouteTriggerV2Service {
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {}
async createOne(routeInput: CreateRouteTriggerInput, workspaceId: string) {
async createOne(
routeTriggerInput: CreateRouteTriggerInput,
workspaceId: string,
) {
const flatEntityMaps =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -41,7 +44,7 @@ export class RouteTriggerV2Service {
const flatRouteTriggerToCreate =
fromCreateRouteTriggerInputToFlatRouteTrigger({
createRouteTriggerInput: routeInput,
createRouteTriggerInput: routeTriggerInput,
workspaceId,
});
@@ -92,7 +95,10 @@ export class RouteTriggerV2Service {
});
}
async updateOne(routeInput: UpdateRouteTriggerInput, workspaceId: string) {
async updateOne(
routeTriggerInput: UpdateRouteTriggerInput,
workspaceId: string,
) {
const flatEntityMaps =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -106,7 +112,7 @@ export class RouteTriggerV2Service {
const optimisticallyUpdatedFlatRouteTrigger =
fromUpdateRouteTriggerInputToFlatRouteTriggerToUpdateOrThrow({
flatRouteTriggerMaps: existingFlatRouteMaps,
updateRouteTriggerInput: routeInput,
updateRouteTriggerInput: routeTriggerInput,
});
const fromFlatRouteMaps = getSubFlatEntityMapsOrThrow({
@@ -161,10 +167,10 @@ export class RouteTriggerV2Service {
}
async destroyOne({
destroyRouteInput,
destroyRouteTriggerInput,
workspaceId,
}: {
destroyRouteInput: RouteTriggerIdInput;
destroyRouteTriggerInput: RouteTriggerIdInput;
workspaceId: string;
}): Promise<FlatRouteTrigger> {
const {
@@ -178,7 +184,8 @@ export class RouteTriggerV2Service {
},
);
const existingFlatRoute = existingFlatRouteMaps.byId[destroyRouteInput.id];
const existingFlatRoute =
existingFlatRouteMaps.byId[destroyRouteTriggerInput.id];
if (!isDefined(existingFlatRoute)) {
throw new RouteTriggerException(