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:
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user