a735e3dfef
# Introduction
- fix twenty-apps hello world deps lockfile
- improve error response format in application resolver
- fixed tests by adding applicationId back to serverless function
service v2
## New log format
We should have a tmp logs folder where we write the errors so the user
can see the whole of them such as what's done in yarn logs
```ts
console.log
✓ Client generated successfully!
at GenerateService.generateClient (src/services/generate.service.ts:58:13)
console.log
Generated files at: /Users/paulrastoin/ws/twenty/packages/twenty-apps/hello-world/generated
at GenerateService.generateClient (src/services/generate.service.ts:59:13)
console.error
❌ Serverless functions Sync failed: {
message: 'Multiple validation errors occurred while creating serverless function',
extensions: {
code: 'METADATA_VALIDATION_FAILED',
errors: {
fieldMetadata: [],
objectMetadata: [],
view: [],
viewField: [],
viewGroup: [],
index: [],
serverlessFunction: [Array],
cronTrigger: [],
databaseEventTrigger: [],
routeTrigger: [],
viewFilter: []
},
summary: {
invalidViewFilter: 0,
invalidObjectMetadata: 0,
invalidView: 0,
invalidViewField: 0,
invalidIndex: 0,
invalidServerlessFunction: 0,
invalidDatabaseEventTrigger: 0,
invalidCronTrigger: 0,
invalidRouteTrigger: 0,
invalidFieldMetadata: 0,
invalidViewGroup: 0,
totalErrors: 0
},
message: 'Validation failed for 0 object(s) and 0 field(s)',
userFriendlyMessage: 'Validation failed for 0 object(s) and 0 field(s)'
}
}
63 | JSON.stringify(serverlessSyncResult.error, null, 2),
64 | );
> 65 | console.error(
| ^
66 | chalk.red('❌ Serverless functions Sync failed:'),
67 | serverlessSyncResult.error,
68 | );
at AppSyncCommand.synchronize (src/commands/app-sync.command.ts:65:15)
at async AppSyncCommand.execute (src/commands/app-sync.command.ts:21:14)
at async Object.<anonymous> (src/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts:28:22)
```
533 lines
15 KiB
TypeScript
533 lines
15 KiB
TypeScript
import * as fs from 'fs-extra';
|
||
import { posix, relative, sep } from 'path';
|
||
import {
|
||
Decorator,
|
||
Expression,
|
||
FunctionDeclaration,
|
||
Modifier,
|
||
Node,
|
||
Program,
|
||
SourceFile,
|
||
SyntaxKind,
|
||
VariableDeclaration,
|
||
forEachChild,
|
||
getDecorators,
|
||
isArrayLiteralExpression,
|
||
isArrowFunction,
|
||
isCallExpression,
|
||
isClassDeclaration,
|
||
isComputedPropertyName,
|
||
isExportAssignment,
|
||
isFunctionExpression,
|
||
isIdentifier,
|
||
isImportDeclaration,
|
||
isNoSubstitutionTemplateLiteral,
|
||
isNumericLiteral,
|
||
isObjectLiteralExpression,
|
||
isPropertyAccessExpression,
|
||
isPropertyAssignment,
|
||
isPropertyDeclaration,
|
||
isShorthandPropertyAssignment,
|
||
isStringLiteralLike,
|
||
isTemplateExpression,
|
||
isVariableStatement,
|
||
} from 'typescript';
|
||
import { GENERATED_FOLDER_NAME } from '../services/generate.service';
|
||
import {
|
||
AppManifest,
|
||
Application,
|
||
FieldMetadata,
|
||
ObjectManifest,
|
||
PackageJson,
|
||
ServerlessFunctionManifest,
|
||
Sources,
|
||
} from '../types/config.types';
|
||
import { findPathFile } from '../utils/find-path-file';
|
||
import { getTsProgramAndDiagnostics } from '../utils/get-ts-program-and-diagnostics';
|
||
import { parseJsoncFile, parseTextFile } from '../utils/jsonc-parser';
|
||
import { formatAndWarnTsDiagnostics } from './format-and-warn-ts-diagnostics';
|
||
|
||
type JSONValue =
|
||
| string
|
||
| number
|
||
| boolean
|
||
| null
|
||
| JSONValue[]
|
||
| { [k: string]: JSONValue };
|
||
|
||
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 (isPropertyAccessExpression(expr)) {
|
||
if (isIdentifier(expr.expression) && isIdentifier(expr.name)) {
|
||
return expr.name.text;
|
||
}
|
||
return String(expr.getText());
|
||
}
|
||
|
||
if (isNoSubstitutionTemplateLiteral(expr)) {
|
||
return expr.text;
|
||
}
|
||
if (isTemplateExpression(expr)) {
|
||
let out = expr.head.text;
|
||
for (const span of expr.templateSpans) {
|
||
const v = exprToValue(span.expression);
|
||
out += String(v) + span.literal.text;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
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 getFirstArgObject = (dec: Decorator) => {
|
||
if (!isCallExpression(dec.expression)) return undefined;
|
||
const [firstArg] = dec.expression.arguments;
|
||
return firstArg && isObjectLiteralExpression(firstArg)
|
||
? (exprToValue(firstArg) as Record<string, JSONValue>)
|
||
: undefined;
|
||
};
|
||
|
||
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') ||
|
||
isDecoratorNamed(d, 'Object'),
|
||
);
|
||
if (objectDec) {
|
||
const cfg = getFirstArgObject(objectDec);
|
||
if (cfg && typeof cfg === 'object' && !Array.isArray(cfg)) {
|
||
const fields: Array<Record<string, JSONValue>> = [];
|
||
|
||
for (const member of node.members) {
|
||
if (!isPropertyDeclaration(member)) {
|
||
continue;
|
||
}
|
||
|
||
const fieldDec = getDecorators(member)?.find(
|
||
(d) =>
|
||
isDecoratorNamed(d, 'FieldMetadata') ||
|
||
isDecoratorNamed(d, 'Field'),
|
||
);
|
||
|
||
if (!fieldDec) {
|
||
continue;
|
||
}
|
||
|
||
const fieldCfg = getFirstArgObject(fieldDec);
|
||
|
||
if (!fieldCfg) {
|
||
continue;
|
||
}
|
||
|
||
// Try to attach the TypeScript property name as "name"
|
||
let name: string | undefined;
|
||
if (member.name && isIdentifier(member.name)) {
|
||
name = member.name.text;
|
||
} else {
|
||
// fallback to AST text if not a simple identifier
|
||
name = member.name?.getText?.() ?? undefined;
|
||
}
|
||
|
||
fields.push({
|
||
...(fieldCfg as FieldMetadata),
|
||
...(name ? { name } : {}),
|
||
});
|
||
}
|
||
manifest.push({ ...(cfg as any), fields } as ObjectManifest);
|
||
}
|
||
}
|
||
}
|
||
forEachChild(node, visit);
|
||
};
|
||
|
||
visit(sf);
|
||
}
|
||
|
||
return manifest;
|
||
};
|
||
|
||
// Add if you want a small guard for "export" presence on statements
|
||
const hasExportModifier = (st: any) =>
|
||
st.modifiers?.some((m: Modifier) => m.kind === SyntaxKind.ExportKeyword) ??
|
||
false;
|
||
|
||
/**
|
||
* Finds (and validates) the new serverless file shape:
|
||
* - exactly 2 exported bindings
|
||
* - one must be `config` (typed ServerlessFunctionConfig)
|
||
* - the other must be a function (exported function declaration, or const initialized with arrow/function expression)
|
||
*/
|
||
const findHandlerAndConfig = (
|
||
sf: SourceFile,
|
||
): {
|
||
handlerName: ServerlessFunctionManifest['handlerName'];
|
||
configObject: Pick<
|
||
ServerlessFunctionManifest,
|
||
| 'universalIdentifier'
|
||
| 'name'
|
||
| 'description'
|
||
| 'timeoutSeconds'
|
||
| 'triggers'
|
||
>;
|
||
} => {
|
||
type Exported = {
|
||
name: string;
|
||
kind: 'function' | 'const';
|
||
init?: Expression;
|
||
declNode: Node;
|
||
};
|
||
|
||
const exported: Exported[] = [];
|
||
|
||
// 1) export const X = <arrow|function expr>
|
||
for (const st of sf.statements) {
|
||
if (!isVariableStatement(st) || !hasExportModifier(st)) continue;
|
||
|
||
for (const decl of st.declarationList.declarations) {
|
||
if (!isIdentifier(decl.name)) continue;
|
||
|
||
const name = decl.name.text;
|
||
const init = decl.initializer ?? undefined;
|
||
|
||
exported.push({
|
||
name,
|
||
kind: 'const',
|
||
init,
|
||
declNode: decl,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 2) export function X() { ... }
|
||
for (const st of sf.statements) {
|
||
if (st.kind === SyntaxKind.FunctionDeclaration && hasExportModifier(st)) {
|
||
const fd = st as FunctionDeclaration;
|
||
if (fd.name && isIdentifier(fd.name)) {
|
||
exported.push({
|
||
name: fd.name.text,
|
||
kind: 'function',
|
||
init: undefined,
|
||
declNode: fd,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Enforce exactly two exports
|
||
const unique = Array.from(new Map(exported.map((e) => [e.name, e])).values());
|
||
if (unique.length !== 2) {
|
||
throw new Error(
|
||
`Serverless file ${sf.fileName} must export exactly 2 bindings (handler + config). Found: ${unique.map((e) => e.name).join(', ')}`,
|
||
);
|
||
}
|
||
|
||
// Find config
|
||
const configExport = unique.find((e) => e.name === 'config');
|
||
if (!configExport) {
|
||
throw new Error(
|
||
`Serverless file ${sf.fileName} must export a binding named "config".`,
|
||
);
|
||
}
|
||
// Must be initialized to an object literal
|
||
if (!configExport.init || !isObjectLiteralExpression(configExport.init)) {
|
||
throw new Error(
|
||
`"config" in ${sf.fileName} must be initialized to an object literal.`,
|
||
);
|
||
}
|
||
// (Light) type guard: ensure declared type mentions ServerlessFunctionConfig if present
|
||
const maybeVarDecl = configExport.declNode as VariableDeclaration;
|
||
if ('type' in maybeVarDecl && maybeVarDecl.type) {
|
||
const typeText = maybeVarDecl.type.getText(sf);
|
||
if (!/\bServerlessFunctionConfig\b/.test(typeText)) {
|
||
throw new Error(
|
||
`"config" in ${sf.fileName} must be typed as ServerlessFunctionConfig (got: ${typeText}).`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const configObject = exprToValue(configExport.init) as Pick<
|
||
ServerlessFunctionManifest,
|
||
| 'universalIdentifier'
|
||
| 'name'
|
||
| 'description'
|
||
| 'timeoutSeconds'
|
||
| 'triggers'
|
||
>;
|
||
|
||
// Identify the handler: the other export
|
||
const handlerExport = unique.find((e) => e.name !== 'config');
|
||
if (!handlerExport) {
|
||
throw new Error(`Could not find the handler export in ${sf.fileName}.`);
|
||
}
|
||
|
||
// If it's a const, make sure it’s a function-ish initializer
|
||
if (handlerExport.kind === 'const') {
|
||
const init = handlerExport.init;
|
||
const isFuncLike =
|
||
!!init && (isArrowFunction(init) || isFunctionExpression(init));
|
||
if (!isFuncLike) {
|
||
throw new Error(
|
||
`Handler "${handlerExport.name}" in ${sf.fileName} must be a function (arrow or function expression).`,
|
||
);
|
||
}
|
||
}
|
||
|
||
return {
|
||
handlerName: handlerExport.name,
|
||
configObject,
|
||
};
|
||
};
|
||
|
||
const posixRelativeFromCwd = (fileName: string, appPath: string) => {
|
||
const rel = relative(appPath, fileName);
|
||
// normalize to posix separators for portability / manifest stability
|
||
return rel.split(sep).join(posix.sep);
|
||
};
|
||
|
||
const collectServerlessFunctions = (program: Program, appPath: string) => {
|
||
const serverlessFunctions: ServerlessFunctionManifest[] = [];
|
||
|
||
for (const sf of program.getSourceFiles()) {
|
||
if (sf.isDeclarationFile) continue;
|
||
|
||
try {
|
||
const { handlerName, configObject } = findHandlerAndConfig(sf);
|
||
|
||
const handlerPath = posixRelativeFromCwd(sf.fileName, appPath);
|
||
|
||
serverlessFunctions.push({
|
||
...configObject,
|
||
handlerPath,
|
||
handlerName,
|
||
});
|
||
} catch {
|
||
// Not a serverless file under the new format — ignore and continue scanning.
|
||
continue;
|
||
}
|
||
}
|
||
|
||
return serverlessFunctions;
|
||
};
|
||
|
||
const setNested = (root: Sources, parts: string[], value: string) => {
|
||
let cur: Sources = root;
|
||
for (let i = 0; i < parts.length; i++) {
|
||
const key = parts[i];
|
||
if (i === parts.length - 1) {
|
||
cur[key] = value;
|
||
} else {
|
||
cur[key] = (cur[key] ?? {}) as Sources;
|
||
cur = cur[key] as Sources;
|
||
}
|
||
}
|
||
};
|
||
|
||
const loadFolderContentIntoJson = async (
|
||
program: Program,
|
||
appPath: string,
|
||
): Promise<Sources> => {
|
||
const sources: Sources = {};
|
||
|
||
// Iterate only files the TS program knows about.
|
||
for (const sf of program.getSourceFiles()) {
|
||
const abs = sf.fileName;
|
||
|
||
// Skip .d.ts and anything outside sourcePath
|
||
if (sf.isDeclarationFile) continue;
|
||
if (!abs.startsWith(appPath + sep) && abs !== appPath) continue;
|
||
|
||
// Keep only TS/TSX files
|
||
if (!(abs.endsWith('.ts') || abs.endsWith('.tsx'))) continue;
|
||
|
||
// Optional extra guard (usually unnecessary if tsconfig excludes node_modules)
|
||
if (abs.includes(`${sep}node_modules${sep}`)) continue;
|
||
|
||
const relFromRoot = relative(appPath, abs);
|
||
const parts = relFromRoot.split(sep);
|
||
|
||
const content = await fs.readFile(abs, 'utf8');
|
||
setNested(sources, parts, content);
|
||
}
|
||
|
||
return sources;
|
||
};
|
||
|
||
export const extractTwentyAppConfig = (program: Program): Application => {
|
||
for (const sf of program.getSourceFiles()) {
|
||
if (sf.isDeclarationFile || !sf.fileName.endsWith('application.config.ts'))
|
||
continue;
|
||
|
||
let found: Application | undefined;
|
||
|
||
const visit = (node: any): void => {
|
||
// Look for "export default twentyAppConfig"
|
||
if (isExportAssignment(node) && isIdentifier(node.expression)) {
|
||
const varName = node.expression.text;
|
||
|
||
// find the corresponding variable declaration
|
||
for (const stmt of sf.statements) {
|
||
if (isVariableStatement(stmt)) {
|
||
for (const decl of stmt.declarationList.declarations) {
|
||
if (isIdentifier(decl.name) && decl.name.text === varName) {
|
||
if (
|
||
decl.initializer &&
|
||
isObjectLiteralExpression(decl.initializer)
|
||
) {
|
||
found = exprToValue(decl.initializer) as Application;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!found) forEachChild(node, visit);
|
||
};
|
||
|
||
visit(sf);
|
||
|
||
if (found) return found;
|
||
}
|
||
|
||
throw new Error('Could not find default exported ApplicationConfig');
|
||
};
|
||
|
||
const isGeneratedModuleUsedInProgram = (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) {
|
||
found = true;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
forEachChild(node, visit);
|
||
};
|
||
|
||
visit(sf);
|
||
|
||
if (found) return true;
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
export const loadManifest = async (
|
||
appPath: string,
|
||
): Promise<{
|
||
packageJson: PackageJson;
|
||
yarnLock: string;
|
||
manifest: AppManifest;
|
||
shouldGenerate: boolean;
|
||
}> => {
|
||
const packageJson = await parseJsoncFile(
|
||
await findPathFile(appPath, 'package.json'),
|
||
);
|
||
|
||
const yarnLock = await parseTextFile(
|
||
await findPathFile(appPath, 'yarn.lock'),
|
||
);
|
||
|
||
const { diagnostics, program } = await getTsProgramAndDiagnostics({
|
||
appPath,
|
||
});
|
||
|
||
formatAndWarnTsDiagnostics({
|
||
diagnostics,
|
||
});
|
||
|
||
const [objects, serverlessFunctions, application, sources] = [
|
||
collectObjects(program),
|
||
collectServerlessFunctions(program, appPath),
|
||
extractTwentyAppConfig(program),
|
||
await loadFolderContentIntoJson(program, appPath),
|
||
];
|
||
|
||
const shouldGenerate = isGeneratedModuleUsedInProgram(program);
|
||
|
||
return {
|
||
packageJson,
|
||
yarnLock,
|
||
manifest: {
|
||
application,
|
||
objects,
|
||
serverlessFunctions,
|
||
sources,
|
||
},
|
||
shouldGenerate,
|
||
};
|
||
};
|