1751 extensibility twenty sdk v2 use twenty sdk to define a serverless function trigger (#15347)
This PR adds 2 columns handlerPath and handlerName in serverlessFunction
to locate the entrypoint of a serverless in a codebase
It adds the following decorators in twenty-sdk:
- ServerlessFunction
- DatabaseEventTrigger
- RouteTrigger
- CronTrigger
- ApplicationVariable
It still supports deprecated entity.manifest.jsonc
Overall code needs to be cleaned a little bit, but it should work
properly so you can try to test if the DEVX fits your needs
See updates in hello-world application
```typescript
import axios from 'axios';
import {
DatabaseEventTrigger,
ServerlessFunction,
RouteTrigger,
CronTrigger,
ApplicationVariable,
} from 'twenty-sdk';
@ApplicationVariable({
universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
key: 'TWENTY_API_KEY',
description: 'Twenty API Key',
isSecret: true,
})
@DatabaseEventTrigger({
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
eventName: 'person.created',
})
@RouteTrigger({
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
})
@CronTrigger({
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
pattern: '0 0 1 1 *', // Every year 1st of January
})
@ServerlessFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
})
class CreateNewPostCard {
main = async (params: { recipient: string }): Promise<string> => {
const { recipient } = params;
const options = {
method: 'POST',
url: 'http://localhost:3000/rest/postCards',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
data: { name: recipient ?? 'Unknown' },
};
try {
const { data } = await axios.request(options);
console.log(`New post card to "${recipient}" created`);
return data;
} catch (error) {
console.error(error);
throw error;
}
};
}
export const createNewPostCardHandler = new CreateNewPostCard().main;
```
### [edit] V2
After the v1 proposal, I see that using a class method to define the
serverless function handler is pretty confusing. Lets leave
serverlessFunction configuration decorators on the class, but move the
handler like before. Here is the v2 hello-world serverless function:
```typescript
import axios from 'axios';
import {
DatabaseEventTrigger,
ServerlessFunction,
RouteTrigger,
CronTrigger,
ApplicationVariable,
} from 'twenty-sdk';
@ApplicationVariable({
universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
key: 'TWENTY_API_KEY',
description: 'Twenty API Key',
isSecret: true,
})
@DatabaseEventTrigger({
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
eventName: 'person.created',
})
@RouteTrigger({
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
})
@CronTrigger({
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
pattern: '0 0 1 1 *', // Every year 1st of January
})
@ServerlessFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
})
export class ServerlessFunctionDefinition {}
export const main = async (params: { recipient: string }): Promise<string> => {
const { recipient } = params;
const options = {
method: 'POST',
url: 'http://localhost:3000/rest/postCards',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
data: { name: recipient ?? 'Unknown' },
};
try {
const { data } = await axios.request(options);
console.log(`New post card to "${recipient}" created`);
return data;
} catch (error) {
console.error(error);
throw error;
}
};
```
### [edit] V3
After the v2 proposal, we don't really like decorators on empty classes.
We decided to go with a Vercel approach with a config constant
```typescript
import axios from 'axios';
import { ServerlessFunctionConfig } from 'twenty-sdk';
export const main = async (params: { recipient: string }): Promise<string> => {
const { recipient } = params;
const options = {
method: 'POST',
url: 'http://localhost:3000/rest/postCards',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
data: { name: recipient ?? 'Unknown' },
};
try {
const { data } = await axios.request(options);
console.log(`New post card to "${recipient}" created`);
return data;
} catch (error) {
console.error(error);
throw error;
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
routeTriggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
}
],
cronTriggers: [
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
pattern: '0 0 1 1 *', // Every year 1st of January
}
],
databaseEventTriggers: [
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
eventName: 'person.created',
}
]
}
```
This commit is contained in:
+57
-82
@@ -1,5 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { parse } from 'path';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ALL_METADATA_NAME, AllMetadataName } from 'twenty-shared/metadata';
|
||||
|
||||
@@ -11,13 +13,11 @@ import {
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
|
||||
import {
|
||||
AgentManifest,
|
||||
ObjectManifest,
|
||||
ServerlessFunctionManifest,
|
||||
ServerlessFunctionTriggerManifest,
|
||||
} from 'src/engine/core-modules/application/types/application.types';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
|
||||
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
|
||||
import { CronTriggerV2Service } from 'src/engine/metadata-modules/cron-trigger/services/cron-trigger-v2.service';
|
||||
import { FlatCronTrigger } from 'src/engine/metadata-modules/cron-trigger/types/flat-cron-trigger.type';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
@@ -34,6 +34,7 @@ import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serv
|
||||
import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service';
|
||||
import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
|
||||
import { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationSyncService {
|
||||
@@ -47,7 +48,6 @@ export class ApplicationSyncService {
|
||||
private readonly serverlessFunctionV2Service: ServerlessFunctionV2Service,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly agentService: AgentService,
|
||||
private readonly databaseEventTriggerV2Service: DatabaseEventTriggerV2Service,
|
||||
private readonly cronTriggerV2Service: CronTriggerV2Service,
|
||||
private readonly routeTriggerV2Service: RouteTriggerV2Service,
|
||||
@@ -69,12 +69,6 @@ export class ApplicationSyncService {
|
||||
yarnLock,
|
||||
});
|
||||
|
||||
await this.syncAgents({
|
||||
agentsToSync: manifest.agents,
|
||||
workspaceId,
|
||||
applicationId: application.id,
|
||||
});
|
||||
|
||||
await this.syncObjects({
|
||||
objectsToSync: manifest.objects,
|
||||
workspaceId,
|
||||
@@ -83,6 +77,7 @@ export class ApplicationSyncService {
|
||||
|
||||
await this.syncServerlessFunctions({
|
||||
serverlessFunctionsToSync: manifest.serverlessFunctions,
|
||||
code: manifest.sources,
|
||||
workspaceId,
|
||||
applicationId: application.id,
|
||||
serverlessFunctionLayerId: application.serverlessFunctionLayerId,
|
||||
@@ -100,10 +95,12 @@ export class ApplicationSyncService {
|
||||
workspaceId: string;
|
||||
}): Promise<ApplicationEntity> {
|
||||
const application = await this.applicationService.findByUniversalIdentifier(
|
||||
manifest.universalIdentifier,
|
||||
manifest.application.universalIdentifier,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const name = manifest.application.displayName ?? packageJson.name;
|
||||
|
||||
if (!isDefined(application)) {
|
||||
const serverlessFunctionLayer =
|
||||
await this.serverlessFunctionLayerService.create(
|
||||
@@ -115,18 +112,18 @@ export class ApplicationSyncService {
|
||||
);
|
||||
|
||||
const application = await this.applicationService.create({
|
||||
universalIdentifier: manifest.universalIdentifier,
|
||||
name: manifest.name,
|
||||
description: manifest.description,
|
||||
version: manifest.version,
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
name,
|
||||
description: manifest.application.description,
|
||||
version: packageJson.version,
|
||||
sourcePath: 'cli-sync', // Placeholder for CLI-synced apps
|
||||
serverlessFunctionLayerId: serverlessFunctionLayer.id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
await this.applicationVariableService.upsertManyApplicationVariableEntitys(
|
||||
await this.applicationVariableService.upsertManyApplicationVariableEntities(
|
||||
{
|
||||
env: manifest.env,
|
||||
applicationVariables: manifest.application.applicationVariables,
|
||||
applicationId: application.id,
|
||||
},
|
||||
);
|
||||
@@ -143,62 +140,21 @@ export class ApplicationSyncService {
|
||||
);
|
||||
|
||||
await this.applicationService.update(application.id, {
|
||||
name: manifest.name,
|
||||
description: manifest.description,
|
||||
version: manifest.version,
|
||||
name,
|
||||
description: manifest.application.description,
|
||||
version: packageJson.version,
|
||||
});
|
||||
|
||||
await this.applicationVariableService.upsertManyApplicationVariableEntitys({
|
||||
env: manifest.env,
|
||||
applicationId: application.id,
|
||||
});
|
||||
await this.applicationVariableService.upsertManyApplicationVariableEntities(
|
||||
{
|
||||
applicationVariables: manifest.application.applicationVariables,
|
||||
applicationId: application.id,
|
||||
},
|
||||
);
|
||||
|
||||
return application;
|
||||
}
|
||||
|
||||
private async syncAgents({
|
||||
agentsToSync,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
}: {
|
||||
agentsToSync: AgentManifest[];
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
}) {
|
||||
for (const agentToSync of agentsToSync) {
|
||||
const existingAgent =
|
||||
await this.agentService.findOneByApplicationAndStandardId({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
standardId: agentToSync.standardId,
|
||||
});
|
||||
|
||||
if (isDefined(existingAgent)) {
|
||||
await this.agentService.updateOneAgent(
|
||||
{ id: existingAgent.id, ...agentToSync },
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.agentService.createOneAgent(
|
||||
{
|
||||
name: agentToSync.name,
|
||||
label: agentToSync.label,
|
||||
description: agentToSync.description,
|
||||
icon: agentToSync.icon,
|
||||
prompt: agentToSync.prompt,
|
||||
modelId: agentToSync.modelId,
|
||||
standardId: agentToSync.standardId,
|
||||
isCustom: true,
|
||||
applicationId,
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async syncObjects({
|
||||
objectsToSync,
|
||||
workspaceId,
|
||||
@@ -224,27 +180,31 @@ export class ApplicationSyncService {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any[];
|
||||
|
||||
const objectsToSyncStandardIds = objectsToSync.map((obj) => obj.standardId);
|
||||
const objectsToSyncUniversalIds = objectsToSync.map(
|
||||
(obj) => obj.universalIdentifier,
|
||||
);
|
||||
|
||||
const applicationObjectsStandardIds = applicationObjects.map(
|
||||
(obj) => obj.standardId,
|
||||
(obj) => obj.universalIdentifier,
|
||||
);
|
||||
|
||||
const objectsToDelete = applicationObjects.filter(
|
||||
(obj) =>
|
||||
isDefined(obj.standardId) &&
|
||||
!objectsToSyncStandardIds.includes(obj.standardId),
|
||||
isDefined(obj.universalIdentifier) &&
|
||||
!objectsToSyncUniversalIds.includes(obj.universalIdentifier),
|
||||
);
|
||||
|
||||
const objectsToUpdate = applicationObjects.filter(
|
||||
(obj) =>
|
||||
isDefined(obj.standardId) &&
|
||||
objectsToSyncStandardIds.includes(obj.standardId),
|
||||
isDefined(obj.universalIdentifier) &&
|
||||
objectsToSyncUniversalIds.includes(obj.universalIdentifier),
|
||||
);
|
||||
|
||||
const objectsToCreate = objectsToSync.filter(
|
||||
(objectToSync) =>
|
||||
!applicationObjectsStandardIds.includes(objectToSync.standardId),
|
||||
!applicationObjectsStandardIds.includes(
|
||||
objectToSync.universalIdentifier,
|
||||
),
|
||||
);
|
||||
|
||||
for (const objectToDelete of objectsToDelete) {
|
||||
@@ -257,12 +217,12 @@ export class ApplicationSyncService {
|
||||
|
||||
for (const objectToUpdate of objectsToUpdate) {
|
||||
const objectToSync = objectsToSync.find(
|
||||
(obj) => obj.standardId === objectToUpdate.standardId,
|
||||
(obj) => obj.universalIdentifier === objectToUpdate.universalIdentifier,
|
||||
);
|
||||
|
||||
if (!objectToSync) {
|
||||
throw new ApplicationException(
|
||||
`Failed to find object to sync with standardId ${objectToUpdate.standardId}`,
|
||||
`Failed to find object to sync with universalIdentifier ${objectToUpdate.universalIdentifier}`,
|
||||
ApplicationExceptionCode.OBJECT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
@@ -298,7 +258,8 @@ export class ApplicationSyncService {
|
||||
labelPlural: objectToCreate.labelPlural,
|
||||
icon: objectToCreate.icon || undefined,
|
||||
description: objectToCreate.description || undefined,
|
||||
standardId: objectToCreate.standardId || undefined,
|
||||
standardId: objectToCreate.universalIdentifier,
|
||||
universalIdentifier: objectToCreate.universalIdentifier,
|
||||
dataSourceId: dataSourceMetadata.id,
|
||||
applicationId,
|
||||
};
|
||||
@@ -312,12 +273,14 @@ export class ApplicationSyncService {
|
||||
|
||||
private async syncServerlessFunctions({
|
||||
serverlessFunctionsToSync,
|
||||
code,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
serverlessFunctionLayerId,
|
||||
}: {
|
||||
serverlessFunctionsToSync: ServerlessFunctionManifest[];
|
||||
workspaceId: string;
|
||||
code: Sources;
|
||||
applicationId: string;
|
||||
serverlessFunctionLayerId: string;
|
||||
}) {
|
||||
@@ -392,12 +355,18 @@ export class ApplicationSyncService {
|
||||
);
|
||||
}
|
||||
|
||||
const name =
|
||||
serverlessFunctionToSync.name ??
|
||||
parse(serverlessFunctionToSync.handlerName).name;
|
||||
|
||||
const updateServerlessFunctionInput = {
|
||||
id: serverlessFunctionToUpdate.id,
|
||||
update: {
|
||||
name: serverlessFunctionToSync.name,
|
||||
name,
|
||||
code,
|
||||
timeoutSeconds: serverlessFunctionToSync.timeoutSeconds,
|
||||
code: serverlessFunctionToSync.code,
|
||||
handlerPath: serverlessFunctionToSync.handlerPath,
|
||||
handlerName: serverlessFunctionToSync.handlerName,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -426,11 +395,17 @@ export class ApplicationSyncService {
|
||||
}
|
||||
|
||||
for (const serverlessFunctionToCreate of serverlessFunctionsToCreate) {
|
||||
const name =
|
||||
serverlessFunctionToCreate.name ??
|
||||
parse(serverlessFunctionToCreate.handlerName).name;
|
||||
|
||||
const createServerlessFunctionInput = {
|
||||
name: serverlessFunctionToCreate.name,
|
||||
code: serverlessFunctionToCreate.code,
|
||||
name,
|
||||
code,
|
||||
universalIdentifier: serverlessFunctionToCreate.universalIdentifier,
|
||||
timeoutSeconds: serverlessFunctionToCreate.timeoutSeconds,
|
||||
handlerPath: serverlessFunctionToCreate.handlerPath,
|
||||
handlerName: serverlessFunctionToCreate.handlerName,
|
||||
applicationId,
|
||||
serverlessFunctionLayerId,
|
||||
};
|
||||
@@ -659,7 +634,7 @@ export class ApplicationSyncService {
|
||||
id: triggerToUpdate.id,
|
||||
update: {
|
||||
settings: {
|
||||
pattern: triggerToSync.schedule,
|
||||
pattern: triggerToSync.pattern,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -677,7 +652,7 @@ export class ApplicationSyncService {
|
||||
|
||||
const createCronTriggerInput = {
|
||||
settings: {
|
||||
pattern: triggerToCreate.schedule,
|
||||
pattern: triggerToCreate.pattern,
|
||||
},
|
||||
universalIdentifier: triggerToCreate.universalIdentifier,
|
||||
serverlessFunctionId,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
|
||||
import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron-trigger.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database-event-trigger/database-event-trigger.module';
|
||||
@@ -25,7 +24,6 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
ObjectMetadataModule,
|
||||
DataSourceModule,
|
||||
AgentModule,
|
||||
ApplicationVariableEntityModule,
|
||||
ServerlessFunctionLayerModule,
|
||||
ServerlessFunctionModule,
|
||||
|
||||
@@ -57,11 +57,11 @@ export class ApplicationResolver {
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteApplication(
|
||||
@Args() { packageJson }: DeleteApplicationInput,
|
||||
@Args() { universalIdentifier }: DeleteApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
await this.applicationSyncService.deleteApplication({
|
||||
applicationUniversalIdentifier: packageJson.universalIdentifier,
|
||||
applicationUniversalIdentifier: universalIdentifier,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
|
||||
+2
-6
@@ -1,11 +1,7 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { PackageJson } from 'src/engine/core-modules/application/types/application.types';
|
||||
|
||||
@ArgsType()
|
||||
export class DeleteApplicationInput {
|
||||
@Field(() => GraphQLJSON, { nullable: false })
|
||||
packageJson: PackageJson;
|
||||
@Field(() => String)
|
||||
universalIdentifier: string;
|
||||
}
|
||||
|
||||
+22
-18
@@ -1,51 +1,56 @@
|
||||
import { type HTTPMethod } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
|
||||
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
|
||||
export type PackageJson = {
|
||||
$schema?: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
license: string;
|
||||
engines: {
|
||||
node: string;
|
||||
npm: string;
|
||||
yarn: string;
|
||||
};
|
||||
env?: EnvManifest;
|
||||
icon?: string;
|
||||
packageManager: string;
|
||||
version: string;
|
||||
dependencies?: object;
|
||||
devDependencies?: object;
|
||||
};
|
||||
|
||||
export type EnvManifest = Record<string, EnvVariableManifest>;
|
||||
|
||||
export type EnvVariableManifest = {
|
||||
type ApplicationVariable = {
|
||||
universalIdentifier: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
isSecret: boolean;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
export type AppManifest = PackageJson & {
|
||||
agents: AgentManifest[];
|
||||
type Sources = { [key: string]: string | Sources };
|
||||
|
||||
type Application = {
|
||||
universalIdentifier: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
applicationVariables?: Record<string, ApplicationVariable>;
|
||||
};
|
||||
export type AppManifest = {
|
||||
application: Application;
|
||||
objects: ObjectManifest[];
|
||||
serverlessFunctions: ServerlessFunctionManifest[];
|
||||
sources: Sources;
|
||||
};
|
||||
|
||||
export type ServerlessFunctionManifest = {
|
||||
$schema?: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeoutSeconds?: number;
|
||||
handlerPath: string;
|
||||
handlerName: string;
|
||||
triggers: ServerlessFunctionTriggerManifest[];
|
||||
code: ServerlessFunctionCode;
|
||||
};
|
||||
|
||||
export type ServerlessFunctionTriggerManifest = (
|
||||
| {
|
||||
type: 'cron';
|
||||
schedule: string;
|
||||
pattern: string;
|
||||
}
|
||||
| {
|
||||
type: 'databaseEvent';
|
||||
@@ -62,8 +67,7 @@ export type ServerlessFunctionTriggerManifest = (
|
||||
};
|
||||
|
||||
export type ObjectManifest = {
|
||||
$schema?: string;
|
||||
standardId: string;
|
||||
universalIdentifier: string;
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
labelSingular: string;
|
||||
|
||||
Reference in New Issue
Block a user