Dynamic grql api wrapper on application sync (#15791)

# Introduction

Important note: for the moment testing this locally will require some
hack due to latest twenty-sdk not being published.
You will need to build twenty-cli and `cd packages/twenty-cli && yarn
link`
To finally sync the app in your app folder as `cd app-folder && twenty
app sync`

close https://github.com/twentyhq/core-team-issues/issues/1863

In this PR is introduced the generate sdk programmatic call to
[genql](https://genql.dev/) exposed in a `client` barrel of `twenty-sdk`
located in this package as there's high chances that will add a codegen
layer above it at some point ?

The cli calls this method after a sync application and writes a client
in a generated folder. It will make a graql introspection query on the
whole workspace. We should later improve that and only filter by current
applicationId and its dependencies ( when twenty-standard application is
introduced )

Fully typesafe ( input, output, filters etc ) auto-completed client

## Hello-world app serverless refactor

<img width="2480" height="1326" alt="image"
src="https://github.com/user-attachments/assets/b18ea372-b21d-4560-8fbc-1dc348427a95"
/>

---------

Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
Paul Rastoin
2025-11-17 14:46:59 +01:00
committed by GitHub
parent a39efeb1ab
commit 2a44bde848
31 changed files with 874 additions and 242 deletions
@@ -1,11 +1,10 @@
import chalk from 'chalk';
import * as chokidar from 'chokidar';
import { ApiService } from '../services/api.service';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { loadManifest } from '../utils/load-manifest';
import { AppSyncCommand } from './app-sync.command';
export class AppDevCommand {
private apiService = new ApiService();
private syncCommand = new AppSyncCommand();
async execute(options: {
appPath?: string;
@@ -18,13 +17,7 @@ export class AppDevCommand {
this.logStartupInfo(appPath, debounceMs);
const { manifest, packageJson, yarnLock } = await loadManifest(appPath);
await this.apiService.syncApplication({
manifest,
packageJson,
yarnLock,
});
await this.syncCommand.execute(appPath);
const watcher = this.setupFileWatcher(appPath, debounceMs);
@@ -64,13 +57,7 @@ export class AppDevCommand {
timeout = setTimeout(async () => {
console.log(chalk.blue('🔄 Changes detected, syncing...'));
const { manifest, packageJson, yarnLock } = await loadManifest(appPath);
await this.apiService.syncApplication({
manifest,
packageJson,
yarnLock,
});
await this.syncCommand.execute(appPath);
console.log(
chalk.gray('👀 Watching for changes... (Press Ctrl+C to stop)'),
@@ -0,0 +1,19 @@
import chalk from 'chalk';
import { GenerateService } from '../services/generate.service';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
export class AppGenerateCommand {
private generateService = new GenerateService();
async execute(appPath: string = CURRENT_EXECUTION_DIRECTORY) {
try {
await this.generateService.generateClient(appPath);
} catch (error) {
console.error(
chalk.red('Generate Twenty client failed:'),
error instanceof Error ? error.message : error,
);
throw error;
}
}
}
@@ -3,11 +3,12 @@ import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-dire
import { ApiService } from '../services/api.service';
import { ApiResponse } from '../types/config.types';
import { loadManifest } from '../utils/load-manifest';
import { GenerateService } from '../services/generate.service';
export class AppSyncCommand {
private apiService = new ApiService();
private generateService = new GenerateService();
// TODO improve typing
async execute(
appPath: string = CURRENT_EXECUTION_DIRECTORY,
): Promise<ApiResponse<any>> {
@@ -16,21 +17,7 @@ export class AppSyncCommand {
console.log(chalk.gray(`📁 App Path: ${appPath}`));
console.log('');
const { manifest, packageJson, yarnLock } = await loadManifest(appPath);
const result = await this.apiService.syncApplication({
manifest,
packageJson,
yarnLock,
});
if (!result.success) {
console.error(chalk.red('❌ Sync failed:'), result.error);
} else {
console.log(chalk.green('✅ Application synced successfully'));
}
return result;
return await this.synchronize({ appPath });
} catch (error) {
console.error(
chalk.red('Sync failed:'),
@@ -39,4 +26,38 @@ export class AppSyncCommand {
throw error;
}
}
private async synchronize({ appPath }: { appPath: string }) {
const { manifest, packageJson, yarnLock, isTwentyClientUsed } =
await loadManifest(appPath);
let serverlessSyncResult = await this.apiService.syncApplication({
manifest,
packageJson,
yarnLock,
});
if (isTwentyClientUsed) {
await this.generateService.generateClient(appPath);
const { manifest: manifestWithClient } = await loadManifest(appPath);
serverlessSyncResult = await this.apiService.syncApplication({
manifest: manifestWithClient,
packageJson,
yarnLock,
});
}
if (!serverlessSyncResult.success) {
console.error(
chalk.red('❌ Serverless functions Sync failed:'),
serverlessSyncResult.error,
);
} else {
console.log(chalk.green('✅ Serverless functions synced successfully'));
}
return serverlessSyncResult;
}
}
@@ -10,6 +10,7 @@ import { AppDevCommand } from './app-dev.command';
import { AppInitCommand } from './app-init.command';
import { AppSyncCommand } from './app-sync.command';
import { formatPath } from '../utils/format-path';
import { AppGenerateCommand } from './app-generate.command';
export class AppCommand {
private devCommand = new AppDevCommand();
@@ -17,6 +18,7 @@ export class AppCommand {
private deleteCommand = new AppDeleteCommand();
private initCommand = new AppInitCommand();
private addCommand = new AppAddCommand();
private generateCommand = new AppGenerateCommand();
getCommand(): Command {
const appCommand = new Command('app');
@@ -96,6 +98,13 @@ export class AppCommand {
await this.addCommand.execute(entityType as SyncableEntity);
});
appCommand
.command('generate [outputPath]')
.description('Generate Twenty client')
.action(async (appPath?: string) => {
await this.generateCommand.execute(formatPath(appPath));
});
return appCommand;
}
}
@@ -20,12 +20,8 @@
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
"resolveJsonModule": true,
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
@@ -6,6 +6,11 @@ import {
type PackageJson,
} from '../types/config.types';
import { ConfigService } from './config.service';
import {
buildClientSchema,
getIntrospectionQuery,
printSchema,
} from 'graphql/index';
export class ApiService {
private client: AxiosInstance;
@@ -188,4 +193,48 @@ export class ApiService {
throw error;
}
}
async getSchema(): Promise<ApiResponse<string>> {
try {
const introspectionQuery = getIntrospectionQuery();
const response = await this.client.post(
'/graphql',
{
query: introspectionQuery,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: `GraphQL introspection errors: ${JSON.stringify(response.data.errors)}`,
};
}
const schema = buildClientSchema(response.data.data);
return {
success: true,
data: printSchema(schema),
message: 'Successfully load schema',
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error:
error.response.data.errors[0]?.message ||
'Failed to load graphql Schema',
};
}
throw error;
}
}
}
@@ -0,0 +1,57 @@
import chalk from 'chalk';
import { generate } from '@genql/cli';
import { join, resolve } from 'path';
import { ConfigService } from './config.service';
import { ApiService } from './api.service';
export const GENERATED_FOLDER_NAME = 'generated';
export class GenerateService {
private configService: ConfigService;
private apiService: ApiService;
constructor() {
this.configService = new ConfigService();
this.apiService = new ApiService();
}
async generateClient(appPath: string): Promise<void> {
const outputPath = join(appPath, GENERATED_FOLDER_NAME);
console.log(chalk.blue('📦 Generating Twenty client...'));
console.log(chalk.gray(`📁 Output Path: ${outputPath}`));
console.log('');
const config = await this.configService.getConfig();
const url = config.apiUrl;
const token = config.apiKey;
if (!url || !token) {
console.log(
chalk.yellow(
'⚠️ Skipping Client generation: API URL or token not configured',
),
);
return;
}
console.log(chalk.gray(`API URL: ${url}`));
console.log(chalk.gray(`Output: ${outputPath}`));
const { data: schema } = await this.apiService.getSchema();
await generate({
schema,
output: resolve(outputPath),
scalarTypes: {
DateTime: 'string',
JSON: 'Record<string, unknown>',
UUID: 'string',
},
verbose: true,
});
console.log(chalk.green('✓ Client generated successfully!'));
console.log(chalk.gray(`Generated files at: ${outputPath}`));
}
}
@@ -1,6 +1,6 @@
import { ensureDirSync, removeSync, writeFileSync } from 'fs-extra';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { ensureDirSync, writeFileSync, removeSync } from 'fs-extra';
import { copyBaseApplicationProject } from '../app-template';
import { loadManifest } from '../load-manifest';
@@ -24,13 +24,13 @@ const tsLibMock = `declare module 'tslib' {
const twentySdkTypesMock = `
declare module 'twenty-sdk/application' {
export type SyncableEntityOptions = { universalIdentifier: string };
type ApplicationVariable = SyncableEntityOptions & {
value?: string;
description?: string;
isSecret?: boolean;
};
export type ApplicationConfig = SyncableEntityOptions & {
displayName?: string;
description?: string;
@@ -44,27 +44,27 @@ declare module 'twenty-sdk/application' {
httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
isAuthRequired: boolean;
};
type CronTrigger = {
type: 'cron';
pattern: string;
};
type DatabaseEventTrigger = {
type: 'databaseEvent';
eventName: string;
};
type ServerlessFunctionTrigger = SyncableEntityOptions &
(RouteTrigger | CronTrigger | DatabaseEventTrigger);
export type ServerlessFunctionConfig = SyncableEntityOptions & {
name?: string;
description?: string;
timeoutSeconds?: number;
triggers?: ServerlessFunctionTrigger[];
};
type ObjectMetadataOptions = SyncableEntityOptions & {
nameSingular: string;
namePlural: string;
@@ -73,7 +73,7 @@ declare module 'twenty-sdk/application' {
description?: string;
icon?: string;
};
export const ObjectMetadata = (_: ObjectMetadataOptions): ClassDecorator => {
return () => {};
};
@@ -412,11 +412,8 @@ export const format = async (params: any): Promise<any> => {
]);
});
it('fails fast if TS validation fails', async () => {
write(appDirectory, 'src/utils/broken.ts', `const x: number = 'oops';`);
await expect(loadManifest(appDirectory)).rejects.toThrow(
/TypeScript validation failed/,
);
it('manifest should contains typescript sources', async () => {
const { isTwentyClientUsed } = await loadManifest(appDirectory);
expect(isTwentyClientUsed).toBe(false);
});
});
@@ -0,0 +1,20 @@
import ts, { formatDiagnosticsWithColorAndContext, sys } from 'typescript';
export const formatAndWarnTsDiagnostics = ({
diagnostics,
}: {
diagnostics: ts.Diagnostic[];
}) => {
if (diagnostics.length > 0) {
const formattedDiagnostics = formatDiagnosticsWithColorAndContext(
diagnostics,
{
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
},
);
console.warn(formattedDiagnostics);
}
};
@@ -0,0 +1,58 @@
import ts from 'typescript';
import { join } from 'path';
import {
createProgram,
formatDiagnosticsWithColorAndContext,
parseJsonConfigFileContent,
readConfigFile,
sys,
} from 'typescript';
const getProgramFromTsconfig = ({
appPath,
tsconfigPath = 'tsconfig.json',
}: {
appPath: string;
tsconfigPath?: string;
}) => {
const configFile = readConfigFile(join(appPath, 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, appPath);
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);
};
export const getTsProgramAndDiagnostics = async ({
appPath,
}: {
appPath: string;
}): Promise<{ program: ts.Program; diagnostics: ts.Diagnostic[] }> => {
const program = getProgramFromTsconfig({
appPath,
tsconfigPath: 'tsconfig.json',
});
return {
diagnostics: [
...program.getSyntacticDiagnostics(),
...program.getSemanticDiagnostics(),
...program.getGlobalDiagnostics(),
],
program,
};
};
+105 -94
View File
@@ -1,52 +1,52 @@
import * as fs from 'fs-extra';
import { posix, relative, sep } from 'path';
import {
sys,
getDecorators,
readConfigFile,
parseJsonConfigFileContent,
formatDiagnosticsWithColorAndContext,
createProgram,
Decorator,
isPropertyAccessExpression,
isNumericLiteral,
SyntaxKind,
isArrayLiteralExpression,
Expression,
isPropertyAssignment,
isComputedPropertyName,
isStringLiteralLike,
isShorthandPropertyAssignment,
isIdentifier,
FunctionDeclaration,
VariableDeclaration,
Program,
Node,
isClassDeclaration,
isCallExpression,
isObjectLiteralExpression,
forEachChild,
SourceFile,
isVariableStatement,
isArrowFunction,
isFunctionExpression,
isExportAssignment,
Modifier,
isPropertyDeclaration,
Node,
Program,
SourceFile,
SyntaxKind,
VariableDeclaration,
forEachChild,
getDecorators,
isArrayLiteralExpression,
isArrowFunction,
isCallExpression,
isClassDeclaration,
isComputedPropertyName,
isExportAssignment,
isFunctionExpression,
isIdentifier,
isNoSubstitutionTemplateLiteral,
isNumericLiteral,
isObjectLiteralExpression,
isPropertyAccessExpression,
isPropertyAssignment,
isPropertyDeclaration,
isShorthandPropertyAssignment,
isStringLiteralLike,
isTemplateExpression,
isVariableStatement,
isImportDeclaration,
NamedImports,
} from 'typescript';
import {
AppManifest,
Application,
FieldMetadata,
ObjectManifest,
PackageJson,
ServerlessFunctionManifest,
Sources,
FieldMetadata,
} from '../types/config.types';
import { posix, relative, sep, resolve, join } from 'path';
import { parseJsoncFile, parseTextFile } from '../utils/jsonc-parser';
import { findPathFile } from '../utils/find-path-file';
import { parseJsoncFile, parseTextFile } from '../utils/jsonc-parser';
import { formatAndWarnTsDiagnostics } from './format-and-warn-ts-diagnostics';
import { getTsProgramAndDiagnostics } from '../utils/get-ts-program-and-diagnostics';
import { GENERATED_FOLDER_NAME } from '../services/generate.service';
type JSONValue =
| string
@@ -56,32 +56,6 @@ type JSONValue =
| JSONValue[]
| { [k: string]: JSONValue };
const getProgramFromTsconfig = (
appPath: string,
tsconfigPath = 'tsconfig.json',
) => {
const configFile = readConfigFile(join(appPath, 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, appPath);
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)) {
@@ -392,23 +366,6 @@ const collectServerlessFunctions = (program: Program, appPath: string) => {
return serverlessFunctions;
};
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}`);
}
};
const setNested = (root: Sources, parts: string[], value: string) => {
let cur: Sources = root;
for (let i = 0; i < parts.length; i++) {
@@ -423,14 +380,10 @@ const setNested = (root: Sources, parts: string[], value: string) => {
};
const loadFolderContentIntoJson = async (
sourcePath = '.',
tsconfigPath = 'tsconfig.json',
program: Program,
appPath: string,
): Promise<Sources> => {
const sources: Sources = {};
const baseAbs = resolve(sourcePath);
// Build the program from tsconfig (uses your getProgramFromTsconfig)
const program: Program = getProgramFromTsconfig(baseAbs, tsconfigPath);
// Iterate only files the TS program knows about.
for (const sf of program.getSourceFiles()) {
@@ -438,7 +391,7 @@ const loadFolderContentIntoJson = async (
// Skip .d.ts and anything outside sourcePath
if (sf.isDeclarationFile) continue;
if (!abs.startsWith(baseAbs + sep) && abs !== baseAbs) continue;
if (!abs.startsWith(appPath + sep) && abs !== appPath) continue;
// Keep only TS/TSX files
if (!(abs.endsWith('.ts') || abs.endsWith('.tsx'))) continue;
@@ -446,7 +399,7 @@ const loadFolderContentIntoJson = async (
// Optional extra guard (usually unnecessary if tsconfig excludes node_modules)
if (abs.includes(`${sep}node_modules${sep}`)) continue;
const relFromRoot = relative(baseAbs, abs);
const relFromRoot = relative(appPath, abs);
const parts = relFromRoot.split(sep);
const content = await fs.readFile(abs, 'utf8');
@@ -495,15 +448,67 @@ export const extractTwentyAppConfig = (program: Program): Application => {
throw new Error('Could not find default exported ApplicationConfig');
};
const isTwentyClientUsedInProgram = (program: Program): boolean => {
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) continue;
let found = false;
const visit = (node: Node): void => {
if (found) return;
if (isImportDeclaration(node)) {
const moduleSpecifier = node.moduleSpecifier;
if (isStringLiteralLike(moduleSpecifier)) {
const moduleText = moduleSpecifier.text;
// Match ../../generated, ../generated, ./foo/generated, etc.
const isGeneratedModule =
moduleText === GENERATED_FOLDER_NAME ||
moduleText.endsWith(`/${GENERATED_FOLDER_NAME}`);
if (
isGeneratedModule &&
node.importClause &&
node.importClause.namedBindings &&
node.importClause.namedBindings.kind === SyntaxKind.NamedImports
) {
const namedImports = node.importClause
.namedBindings as NamedImports;
for (const element of namedImports.elements) {
const importedName =
element.propertyName?.text ?? element.name.text;
if (importedName === 'createClient') {
found = true;
return;
}
}
}
}
}
forEachChild(node, visit);
};
visit(sf);
if (found) return true;
}
return false;
};
export const loadManifest = async (
path?: string,
appPath: string,
): Promise<{
packageJson: PackageJson;
yarnLock: string;
manifest: AppManifest;
isTwentyClientUsed: boolean;
}> => {
const appPath = path ?? process.cwd();
const packageJson = await parseJsoncFile(
await findPathFile(appPath, 'package.json'),
);
@@ -512,17 +517,22 @@ export const loadManifest = async (
await findPathFile(appPath, 'yarn.lock'),
);
const program = getProgramFromTsconfig(appPath, 'tsconfig.json');
const { diagnostics, program } = await getTsProgramAndDiagnostics({
appPath,
});
validateProgram(program);
formatAndWarnTsDiagnostics({
diagnostics,
});
const [objects, serverlessFunctions, application, sources] =
await Promise.all([
Promise.resolve(collectObjects(program)),
Promise.resolve(collectServerlessFunctions(program, appPath)),
Promise.resolve(extractTwentyAppConfig(program)),
loadFolderContentIntoJson(appPath),
]);
const [objects, serverlessFunctions, application, sources] = [
collectObjects(program),
collectServerlessFunctions(program, appPath),
extractTwentyAppConfig(program),
await loadFolderContentIntoJson(program, appPath),
];
const isTwentyClientUsed = isTwentyClientUsedInProgram(program);
return {
packageJson,
@@ -533,5 +543,6 @@ export const loadManifest = async (
serverlessFunctions,
sources,
},
isTwentyClientUsed,
};
};