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,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,
};
};