1750 extensibility twenty sdk v2 use twenty sdk to define an object (#15230)

We maintain jsonc object definition but will deprecate them pretty soon

## Before
<img width="1512" height="575" alt="image"
src="https://github.com/user-attachments/assets/d2fa6ca4-c456-4aa9-a1e3-845b61839718"
/>

## After
<img width="1260" height="555" alt="image"
src="https://github.com/user-attachments/assets/ba72f4cf-d443-4967-913c-029bc71f3f48"
/>
This commit is contained in:
martmull
2025-10-22 15:18:23 +02:00
committed by GitHub
parent 32558673c6
commit 033c28a3d5
25 changed files with 440 additions and 25 deletions

View File

@@ -1,9 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/object.schema.json",
"universalIdentifier": "54b589ca-eeed-4950-a176-358418b85c05",
"standardId": "54b589ca-eeed-4950-a176-358418b85c05",
"nameSingular": "postCard",
"namePlural": "postCards",
"labelSingular": "Post card",
"labelPlural": "Post cards"
}

View File

@@ -19,7 +19,8 @@
}
},
"dependencies": {
"axios": "^1.12.2"
"axios": "^1.12.2",
"twenty-sdk": "^0.0.2"
},
"devDependencies": {
"@types/node": "^24.7.2"

View File

@@ -0,0 +1,10 @@
import { ObjectMetadata } from 'twenty-sdk';
@ObjectMetadata({
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post card',
labelPlural: 'Post cards',
})
export class PostCard {}

View File

@@ -0,0 +1,22 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": ["node_modules", "dist"],
"include": ["**/*.ts"]
}

View File

@@ -5,11 +5,22 @@ __metadata:
version: 8
cacheKey: 10c0
"@types/node@npm:^24.7.2":
version: 24.9.1
resolution: "@types/node@npm:24.9.1"
dependencies:
undici-types: "npm:~7.16.0"
checksum: 10c0/c52f8168080ef9a7c3dc23d8ac6061fab5371aad89231a0f6f4c075869bc3de7e89b075b1f3e3171d9e5143d0dda1807c3dab8e32eac6d68f02e7480e7e78576
languageName: node
linkType: hard
"Hello world@workspace:.":
version: 0.0.0-use.local
resolution: "Hello world@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
axios: "npm:^1.12.2"
twenty-sdk: "npm:^0.0.2"
languageName: unknown
linkType: soft
@@ -246,3 +257,17 @@ __metadata:
checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b
languageName: node
linkType: hard
"twenty-sdk@npm:^0.0.2":
version: 0.0.2
resolution: "twenty-sdk@npm:0.0.2"
checksum: 10c0/99e6fe86059d847b548c1f03e0f0c59a4d540caf1d28dd4500f1f5f0094196985ded955801274de9e72ff03e3d1f41e9a509b4c2c5a02ffc8a027277b1e35d8e
languageName: node
linkType: hard
"undici-types@npm:~7.16.0":
version: 7.16.0
resolution: "undici-types@npm:7.16.0"
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard

View File

@@ -30,7 +30,7 @@ const jestConfig = {
],
coverageThreshold: {
global: {
statements: 2,
statements: 1,
lines: 1,
functions: 1,
},

View File

@@ -35,17 +35,21 @@
"fs-extra": "^11.2.0",
"inquirer": "^10.0.0",
"jsonc-parser": "^3.2.0",
"lodash.kebabcase": "^4.1.1"
"lodash.camelcase": "^4.3.0",
"lodash.capitalize": "^4.2.1",
"lodash.kebabcase": "^4.1.1",
"typescript": "^5.9.2"
},
"devDependencies": {
"@types/fs-extra": "^11.0.0",
"@types/inquirer": "^9.0.0",
"@types/jest": "^29.5.0",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.capitalize": "^4",
"@types/lodash.kebabcase": "^4.1.7",
"@types/node": "^20.0.0",
"jest": "^29.5.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0",
"wait-on": "^7.2.0"
},
"engines": {

View File

@@ -3,11 +3,13 @@ import { randomUUID } from 'crypto';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import path from 'path';
import camelcase from 'lodash.camelcase';
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 { BASE_SCHEMAS_PATH } from '../constants/constants-path';
import { getDecoratedClass } from '../utils/get-decorated-class';
export enum SyncableEntity {
AGENT = 'agent',
@@ -56,6 +58,22 @@ export class AppAddCommand {
const entityData = await this.getEntityToCreateData(entity, entityName);
if (entity === SyncableEntity.OBJECT) {
delete entityData['standardId'];
delete entityData['$schema'];
const objectFileName = `${camelcase(entityName)}.ts`;
const decoratedObject = getDecoratedClass({
data: entityData,
name: entityName,
});
await fs.writeFile(path.join(appPath, objectFileName), decoratedObject);
return;
}
const folderName = getFolderName(entity);
const entitiesDir = path.join(appPath, folderName, entityName);

View File

@@ -0,0 +1,5 @@
# Duplicated with ./gitignore because npm publish does not include .gitignore
# https://github.com/npm/npm/issues/3763
.yarn/install-state.gz
.env

View File

@@ -7,6 +7,9 @@
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"dependencies": {
"twenty-sdk": "^0.0.2"
},
"devDependencies": {
"@types/node": "^24.7.2"
}

View File

@@ -0,0 +1,22 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": ["node_modules", "dist"],
"include": ["**/*.ts"]
}

View File

@@ -0,0 +1,38 @@
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 8
cacheKey: 10c0
"@types/node@npm:^24.7.2":
version: 24.9.1
resolution: "@types/node@npm:24.9.1"
dependencies:
undici-types: "npm:~7.16.0"
checksum: 10c0/c52f8168080ef9a7c3dc23d8ac6061fab5371aad89231a0f6f4c075869bc3de7e89b075b1f3e3171d9e5143d0dda1807c3dab8e32eac6d68f02e7480e7e78576
languageName: node
linkType: hard
"root-workspace-0b6124@workspace:.":
version: 0.0.0-use.local
resolution: "root-workspace-0b6124@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
twenty-sdk: "npm:^0.0.2"
languageName: unknown
linkType: soft
"twenty-sdk@npm:^0.0.2":
version: 0.0.2
resolution: "twenty-sdk@npm:0.0.2"
checksum: 10c0/99e6fe86059d847b548c1f03e0f0c59a4d540caf1d28dd4500f1f5f0094196985ded955801274de9e72ff03e3d1f41e9a509b4c2c5a02ffc8a027277b1e35d8e
languageName: node
linkType: hard
"undici-types@npm:~7.16.0":
version: 7.16.0
resolution: "undici-types@npm:7.16.0"
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard

View File

@@ -78,6 +78,7 @@ export type ServerlessFunctionCodeManifest = {
export type ObjectManifest = {
$schema?: string;
standardId: string;
universalIdentifier: string;
nameSingular: string;
namePlural: string;
labelSingular: string;
@@ -89,6 +90,7 @@ export type ObjectManifest = {
export type AgentManifest = {
$schema?: string;
standardId: string;
universalIdentifier: string;
name: string;
label: string;
description?: string;

View File

@@ -0,0 +1,21 @@
import { getDecoratedClass } from '../../utils/get-decorated-class';
describe('getDecoratedClass', () => {
it('should return properly formatted class', () => {
const result = getDecoratedClass({
data: { nameSingular: 'Name', namePlural: 'Names' },
name: 'MyNewObject',
});
const expectedResult = `import { ObjectMetadata } from 'twenty-sdk';
@ObjectMetadata({
nameSingular: 'Name',
namePlural: 'Names',
})
export class MyNewObject {}
`;
expect(result).toEqual(expectedResult);
});
});

View File

@@ -5,10 +5,12 @@ import * as path from 'path';
import {
AppManifest,
CoreEntityManifest,
ObjectManifest,
PackageJson,
} from '../types/config.types';
import { validateSchema } from '../utils/schema-validator';
import { parseJsoncFile } from './jsonc-parser';
import { loadManifestFromDecorators } from '../utils/load-manifest-from-decorators';
type Sources = { [key: string]: string | Sources };
@@ -156,7 +158,7 @@ export const loadManifest = async (
(manifest, path) => validateSchema('agent', manifest, path),
);
const objects = await loadCoreEntity(
const objectFromManifests = await loadCoreEntity(
path.join(appPath, 'objects'),
(manifest, path) => validateSchema('object', manifest, path),
);
@@ -166,6 +168,15 @@ export const loadManifest = async (
(manifest, path) => validateSchema('serverlessFunction', manifest, path),
);
const { objects: objectsFromDecorators } = loadManifestFromDecorators();
const objects = (
[...objectFromManifests, ...objectsFromDecorators] as ObjectManifest[]
).map((object) => {
object.standardId = object.universalIdentifier;
return object;
});
return {
packageJson,
yarnLock: rawYarnLock,

View File

@@ -0,0 +1,25 @@
import camelcase from 'lodash.camelcase';
export const getDecoratedClass = ({
data,
name,
}: {
data: object;
name: string;
}) => {
const decoratorOptions = Object.entries(data)
.map(([key, value]) => ` ${key}: '${value}',`)
.join('\n');
const camelCaseName = camelcase(name);
const className = camelCaseName[0].toUpperCase() + camelCaseName.slice(1);
return `import { ObjectMetadata } from 'twenty-sdk';
@ObjectMetadata({
${decoratorOptions}
})
export class ${className} {}
`;
};

View File

@@ -0,0 +1,175 @@
import {
sys,
getDecorators,
readConfigFile,
parseJsonConfigFileContent,
formatDiagnosticsWithColorAndContext,
createProgram,
Decorator,
isPropertyAccessExpression,
isNumericLiteral,
SyntaxKind,
isArrayLiteralExpression,
Expression,
isPropertyAssignment,
isComputedPropertyName,
isStringLiteralLike,
isShorthandPropertyAssignment,
isIdentifier,
Program,
Node,
isClassDeclaration,
isCallExpression,
isObjectLiteralExpression,
forEachChild,
} from 'typescript';
import { AppManifest, ObjectManifest } from '../types/config.types';
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [k: string]: JSONValue };
const getProgramFromTsconfig = (tsconfigPath = 'tsconfig.json') => {
const basePath = process.cwd();
const configFile = readConfigFile(tsconfigPath, sys.readFile);
if (configFile.error)
throw new Error(
formatDiagnosticsWithColorAndContext([configFile.error], {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
}),
);
const parsed = parseJsonConfigFileContent(configFile.config, sys, basePath);
if (parsed.errors.length) {
throw new Error(
formatDiagnosticsWithColorAndContext(parsed.errors, {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
}),
);
}
return createProgram(parsed.fileNames, parsed.options);
};
const isDecoratorNamed = (node: Decorator, name: string): node is Decorator => {
const expr = node.expression;
if (isCallExpression(expr)) {
if (isIdentifier(expr.expression)) return expr.expression.text === name;
if (isPropertyAccessExpression(expr.expression))
return expr.expression.name.text === name;
}
return false;
};
const exprToValue = (expr: Expression): JSONValue => {
if (isStringLiteralLike(expr)) return expr.text;
if (isNumericLiteral(expr)) return Number(expr.text);
if (expr.kind === SyntaxKind.TrueKeyword) return true;
if (expr.kind === SyntaxKind.FalseKeyword) return false;
if (expr.kind === SyntaxKind.NullKeyword) return null;
if (isArrayLiteralExpression(expr)) {
return expr.elements.map((e) =>
e.kind === SyntaxKind.SpreadElement ? [] : exprToValue(e),
);
}
if (isObjectLiteralExpression(expr)) {
const obj: Record<string, JSONValue> = {};
for (const prop of expr.properties) {
if (isPropertyAssignment(prop)) {
const key =
isIdentifier(prop.name) || isStringLiteralLike(prop.name)
? prop.name.text
: isComputedPropertyName(prop.name) &&
isStringLiteralLike(prop.name.expression)
? prop.name.expression.text
: undefined;
if (key) obj[key] = exprToValue(prop.initializer);
} else if (isShorthandPropertyAssignment(prop)) {
// Unsupported without a checker; skip to keep it "light".
// Could resolve via typechecker if needed.
}
// getters/setters/methods are ignored intentionally
}
return obj;
}
// Keep it intentionally strict/lightweight: anything non-literal becomes a string fallback.
// You can throw instead if you prefer to fail fast.
return isIdentifier(expr)
? expr.text
: String((expr as any).getText?.() ?? '');
};
const collectObjects = (program: Program) => {
const manifest: ObjectManifest[] = [];
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) {
continue;
}
const visit = (node: Node) => {
if (isClassDeclaration(node) && getDecorators(node)?.length) {
const decorators = getDecorators(node);
const objectDec = decorators?.find((d) =>
isDecoratorNamed(d, 'ObjectMetadata'),
);
if (objectDec && isCallExpression(objectDec.expression)) {
const [firstArg] = objectDec.expression.arguments;
if (firstArg && isObjectLiteralExpression(firstArg)) {
const config = exprToValue(firstArg);
if (
config &&
typeof config === 'object' &&
!Array.isArray(config)
) {
manifest.push({
...config,
} as ObjectManifest);
}
}
}
}
forEachChild(node, visit);
};
visit(sf);
}
return manifest;
};
const validateProgram = (program: Program) => {
const diagnostics = [
...program.getSyntacticDiagnostics(),
...program.getSemanticDiagnostics(),
...program.getGlobalDiagnostics(),
];
if (diagnostics.length > 0) {
const formatted = formatDiagnosticsWithColorAndContext(diagnostics, {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
});
throw new Error(`TypeScript validation failed:\n${formatted}`);
}
};
export const loadManifestFromDecorators = (): Pick<AppManifest, 'objects'> => {
const program = getProgramFromTsconfig('tsconfig.json');
validateProgram(program);
const objects = collectObjects(program);
return { objects };
};

View File

@@ -1,6 +1,6 @@
{
"name": "twenty-sdk",
"version": "0.0.1",
"version": "0.0.2",
"license": "AGPL-3.0",
"main": "dist/index.cjs",
"module": "dist/index.mjs",

View File

@@ -1,2 +1 @@
export { };
export { ObjectMetadata } from './object-metadata.decorator';

View File

@@ -0,0 +1,13 @@
type ObjectMetadataOptions = {
universalIdentifier: string;
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
};
export const ObjectMetadata = (_: ObjectMetadataOptions): ClassDecorator => {
return () => {};
};

View File

@@ -1,2 +1 @@
export * from './decorators';
export * from './types';

View File

@@ -1 +0,0 @@
export { };

View File

@@ -1,9 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
"outDir": "./dist",
"rootDir": "src",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"strict": true,
"importHelpers": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
"include": ["src/**/*.ts"]
}

View File

@@ -13,7 +13,7 @@
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"resolveJsonModule": true
},
"exclude": ["node_modules", "tmp"]
}

View File

@@ -21743,6 +21743,15 @@ __metadata:
languageName: node
linkType: hard
"@types/lodash.capitalize@npm:^4":
version: 4.2.9
resolution: "@types/lodash.capitalize@npm:4.2.9"
dependencies:
"@types/lodash": "npm:*"
checksum: 10c0/4a4bc23bc82a8a0952bf75712cea34cd9e6eb15ef77a58352d19f387be50cdea0b6f2b21e7f0d87c1623bfd42b8d4fd2384478901702b146f016f4c7c11e1abb
languageName: node
linkType: hard
"@types/lodash.chunk@npm:^4.2.9":
version: 4.2.9
resolution: "@types/lodash.chunk@npm:4.2.9"
@@ -39575,6 +39584,13 @@ __metadata:
languageName: node
linkType: hard
"lodash.capitalize@npm:^4.2.1":
version: 4.2.1
resolution: "lodash.capitalize@npm:4.2.1"
checksum: 10c0/b289326497c2e24d6b8afa2af2ca4e068ef6ef007ade36bfb6f70af77ce10ea3f090eeee947d5fdcf2db4bcfa4703c8c10a5857a2b39e308bddfd1d11ad35970
languageName: node
linkType: hard
"lodash.chunk@npm:4.2.0, lodash.chunk@npm:^4.2.0":
version: 4.2.0
resolution: "lodash.chunk@npm:4.2.0"
@@ -51694,6 +51710,8 @@ __metadata:
"@types/fs-extra": "npm:^11.0.0"
"@types/inquirer": "npm:^9.0.0"
"@types/jest": "npm:^29.5.0"
"@types/lodash.camelcase": "npm:^4.3.7"
"@types/lodash.capitalize": "npm:^4"
"@types/lodash.kebabcase": "npm:^4.1.7"
"@types/node": "npm:^20.0.0"
ajv: "npm:^8.12.0"
@@ -51707,9 +51725,11 @@ __metadata:
inquirer: "npm:^10.0.0"
jest: "npm:^29.5.0"
jsonc-parser: "npm:^3.2.0"
lodash.camelcase: "npm:^4.3.0"
lodash.capitalize: "npm:^4.2.1"
lodash.kebabcase: "npm:^4.1.1"
tsx: "npm:^4.7.0"
typescript: "npm:^5.3.0"
typescript: "npm:^5.9.2"
wait-on: "npm:^7.2.0"
bin:
twenty: dist/cli.js