Improve API Client usage and add Typescript check (#18023)

## Summary


https://github.com/user-attachments/assets/1e75cc9d-d9d2-4ef2-99f9-34450f5d8de7



Add background incremental type checking (`tsc --watch`) to the SDK dev
mode, so type regressions are caught when the generated API client
changes — without requiring a full rebuild of source files.

Previously, removing a field from the data model would regenerate the
API client, but existing front components/logic functions referencing
the removed field wouldn't surface type errors (since their source
didn't change, esbuild wouldn't rebuild them).

## What changed

- **Background `tsc --watch`**: a long-lived TypeScript watcher runs
alongside esbuild watchers, incrementally re-checking all files when the
generated client changes. Only logs on state transitions (errors appear
/ errors clear) to stay quiet.
- **Atomic client generation**: API client is now generated into a temp
directory and swapped in atomically, avoiding a race condition where
`tsc --watch` could see an empty `generated/` directory
mid-regeneration.
- **Step decoupling**: orchestrator steps no longer receive
`uploadFilesStep` directly. Instead, they use callbacks (`onFileBuilt`,
`onApiClientGenerated`), and each step manages its own `builtFileInfos`
state.
- **`apiClientChecksum` omitted from `ApplicationConfig`**: it's a
build-time computed value, same as `packageJsonChecksum`.
<img width="327" height="177" alt="image"
src="https://github.com/user-attachments/assets/02bd25bb-fa41-42b0-8d96-01c51bd4580c"
/>

<img width="529" height="452" alt="image"
src="https://github.com/user-attachments/assets/61f6e968-365b-4a5b-8f2b-a8419d6b1bd3"
/>
This commit is contained in:
Charles Bochet
2026-02-18 13:26:30 +01:00
committed by GitHub
parent 2455c859b4
commit 7332379d26
15 changed files with 364 additions and 81 deletions
@@ -28,6 +28,7 @@ export const EXPECTED_MANIFEST: Manifest = {
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e',
packageJsonChecksum: '2851d0e2c3621a57e1fd103a245b6fde',
apiClientChecksum: null,
},
frontComponents: [
{
@@ -11,6 +11,7 @@ export const EXPECTED_MANIFEST: Manifest = {
defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
packageJsonChecksum: '93ae1e2eb3db18351d06f43550700dcc',
yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e',
apiClientChecksum: null,
},
publicAssets: [],
fields: [],
@@ -8,6 +8,7 @@ import {
type RestartableWatcher,
type RestartableWatcherOptions,
} from '@/cli/utilities/build/common/restartable-watcher-interface';
import { createTypecheckPlugin } from '@/cli/utilities/build/common/typecheck-plugin';
import * as esbuild from 'esbuild';
import path from 'path';
import { OUTPUT_DIR, NODE_ESM_CJS_BANNER } from 'twenty-shared/application';
@@ -214,7 +215,10 @@ export const createLogicFunctionsWatcher = (
externalModules: LOGIC_FUNCTION_EXTERNAL_MODULES,
fileFolder: FileFolder.BuiltLogicFunction,
platform: 'node',
extraPlugins: [createSdkGeneratedResolverPlugin(options.appPath)],
extraPlugins: [
createTypecheckPlugin(options.appPath),
createSdkGeneratedResolverPlugin(options.appPath),
],
banner: NODE_ESM_CJS_BANNER,
},
});
@@ -229,6 +233,7 @@ export const createFrontComponentsWatcher = (
fileFolder: FileFolder.BuiltFrontComponent,
jsx: 'automatic',
extraPlugins: [
createTypecheckPlugin(options.appPath),
createSdkGeneratedResolverPlugin(options.appPath),
...getFrontComponentBuildPlugins(),
],
@@ -0,0 +1,103 @@
import { spawn, type ChildProcess } from 'node:child_process';
import * as fs from 'fs-extra';
import path from 'node:path';
import {
parseTscOutputLine,
type TypecheckError,
} from '@/cli/utilities/build/common/typecheck-plugin';
export type TscWatcherOptions = {
appPath: string;
onErrors: (errors: TypecheckError[]) => void;
};
export class TscWatcher {
private appPath: string;
private onErrors: (errors: TypecheckError[]) => void;
private process: ChildProcess | null = null;
private pendingErrors: TypecheckError[] = [];
private buffer = '';
private hasErrors = false;
constructor(options: TscWatcherOptions) {
this.appPath = options.appPath;
this.onErrors = options.onErrors;
}
async start(): Promise<void> {
const tscPath = path.join(this.appPath, 'node_modules', '.bin', 'tsc');
if (!(await fs.pathExists(tscPath))) {
return;
}
const tsconfigPath = path.join(this.appPath, 'tsconfig.json');
this.process = spawn(
tscPath,
['--watch', '--noEmit', '--pretty', 'false', '-p', tsconfigPath],
{ cwd: this.appPath, stdio: ['ignore', 'pipe', 'pipe'] },
);
this.process.on('error', () => {
this.process = null;
});
this.process.stdout?.on('data', (chunk: Buffer) => {
this.handleOutput(chunk.toString());
});
this.process.stderr?.on('data', (chunk: Buffer) => {
this.handleOutput(chunk.toString());
});
}
close(): void {
this.process?.kill();
this.process = null;
}
private handleOutput(data: string): void {
this.buffer += data;
const lines = this.buffer.split('\n');
this.buffer = lines.pop() ?? '';
for (const line of lines) {
this.processLine(line);
}
}
private processLine(line: string): void {
if (
line.includes('Starting compilation in watch mode...') ||
line.includes('Starting incremental compilation...')
) {
this.pendingErrors = [];
return;
}
if (line.includes('Watching for file changes.')) {
const hadErrors = this.hasErrors;
this.hasErrors = this.pendingErrors.length > 0;
if (this.hasErrors || hadErrors) {
this.onErrors(this.pendingErrors);
}
this.pendingErrors = [];
return;
}
const error = parseTscOutputLine(line);
if (error) {
this.pendingErrors.push(error);
}
}
}
@@ -0,0 +1,84 @@
import { execFile } from 'node:child_process';
import type * as esbuild from 'esbuild';
import path from 'node:path';
export type TypecheckError = {
text: string;
file: string;
line: number;
column: number;
};
const TSC_ERROR_REGEX = /^(.+)\((\d+),(\d+)\): error TS\d+: (.+)$/;
export const parseTscOutputLine = (line: string): TypecheckError | null => {
const match = line.match(TSC_ERROR_REGEX);
if (!match) {
return null;
}
const [, filePath, lineStr, columnStr, text] = match;
return {
text,
file: filePath,
line: Number(lineStr),
column: Number(columnStr) - 1,
};
};
const parseTscOutput = (output: string): TypecheckError[] => {
const errors: TypecheckError[] = [];
for (const line of output.split('\n')) {
const error = parseTscOutputLine(line);
if (error) {
errors.push(error);
}
}
return errors;
};
export const runTypecheck = (appPath: string): Promise<TypecheckError[]> => {
const tsconfigPath = path.join(appPath, 'tsconfig.json');
const tscPath = path.join(appPath, 'node_modules', '.bin', 'tsc');
return new Promise((resolve) => {
execFile(
tscPath,
['--noEmit', '--pretty', 'false', '-p', tsconfigPath],
{ cwd: appPath },
(_error, stdout, stderr) => {
resolve(parseTscOutput(stderr || stdout));
},
);
});
};
const toEsbuildErrors = (errors: TypecheckError[]): esbuild.PartialMessage[] =>
errors.map((error) => ({
text: error.text,
location: {
file: error.file,
line: error.line,
column: error.column,
lineText: '',
length: 0,
namespace: '',
suggestion: '',
},
}));
export const createTypecheckPlugin = (appPath: string): esbuild.Plugin => ({
name: 'typecheck',
setup: (build) => {
build.onStart(async () => {
const errors = await runTypecheck(appPath);
return { errors: toEsbuildErrors(errors) };
});
},
});
@@ -13,6 +13,7 @@ const validApplication: ApplicationManifest = {
defaultRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
packageJsonChecksum: '98592af7-4be9-4655-b5c4-9bef307a996c',
yarnLockChecksum: '580ee05f-15fe-4146-bac2-6c382483c94e',
apiClientChecksum: null,
};
const validField: FieldManifest = {
@@ -101,6 +101,7 @@ export const buildManifest = async (
...extract.config,
yarnLockChecksum: null,
packageJsonChecksum: null,
apiClientChecksum: null,
};
errors.push(...extract.errors);
applicationFilePaths.push(relativePath);
@@ -1,3 +1,4 @@
import crypto from 'crypto';
import { relative } from 'path';
import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
@@ -88,5 +89,30 @@ export const manifestUpdateChecksums = ({
}
}
}
const apiClientChecksums: string[] = [];
for (const [builtPath, { fileFolder }] of builtFileInfos.entries()) {
const rootBuiltPath = relative(OUTPUT_DIR, builtPath);
if (
fileFolder === FileFolder.Dependencies &&
rootBuiltPath.startsWith('api-client/')
) {
const entry = builtFileInfos.get(builtPath);
if (entry) {
apiClientChecksums.push(entry.checksum);
}
}
}
if (apiClientChecksums.length > 0) {
result.application.apiClientChecksum = crypto
.createHash('md5')
.update(apiClientChecksums.sort().join(''))
.digest('hex');
}
return result;
};
@@ -22,6 +22,7 @@ export class ClientService {
authToken?: string;
}): Promise<void> {
const outputPath = this.resolveGeneratedPath(appPath);
const tempPath = `${outputPath}.tmp`;
const getSchemaResponse = await this.apiService.getSchema({ authToken });
@@ -33,12 +34,12 @@ export class ClientService {
const { data: schema } = getSchemaResponse;
await fs.ensureDir(outputPath);
await fs.emptyDir(outputPath);
await fs.ensureDir(tempPath);
await fs.emptyDir(tempPath);
await generate({
schema,
output: outputPath,
output: tempPath,
scalarTypes: {
DateTime: 'string',
JSON: 'Record<string, unknown>',
@@ -46,7 +47,10 @@ export class ClientService {
},
});
await this.injectTwentyClient(outputPath);
await this.injectTwentyClient(tempPath);
await fs.remove(outputPath);
await fs.move(tempPath, outputPath);
}
private resolveGeneratedPath(appPath: string): string {
@@ -70,22 +74,16 @@ const defaultOptions: ClientOptions = {
export default class Twenty {
private client: Client;
private apiUrl: string;
private authorizationToken: string;
constructor(options?: ClientOptions) {
const merged: ClientOptions = {
this.client = createClient({
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...(options?.headers ?? {}),
},
};
this.client = createClient(merged);
this.apiUrl = merged.url;
this.authorizationToken = merged.headers.Authorization;
});
}
query<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
@@ -95,41 +93,6 @@ export default class Twenty {
mutation<R extends MutationGenqlSelection>(request: R & { __name?: string }) {
return this.client.mutation(request);
}
async uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string = 'application/octet-stream',
fileFolder: string = 'Attachment',
): Promise<{ path: string; token: string }> {
const form = new FormData();
form.append('operations', JSON.stringify({
query: \`mutation UploadFile($file: Upload!, $fileFolder: FileFolder) {
uploadFile(file: $file, fileFolder: $fileFolder) { path token }
}\`,
variables: { file: null, fileFolder },
}));
form.append('map', JSON.stringify({ '0': ['variables.file'] }));
form.append('0', new Blob([fileBuffer], { type: contentType }), filename);
const response = await fetch(\`\${this.apiUrl}/graphql\`, {
method: 'POST',
headers: {
Authorization: this.authorizationToken,
},
body: form,
});
const result = await response.json();
if (result.errors) {
throw new GenqlError(result.errors, result.data);
}
return result.data.uploadFile;
}
}
`;
@@ -7,7 +7,10 @@ import { CheckServerOrchestratorStep } from '@/cli/utilities/dev/orchestrator/st
import { EnsureValidTokensOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/ensure-valid-tokens-orchestrator-step';
import { GenerateApiClientOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step';
import { ResolveApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/resolve-application-orchestrator-step';
import { StartWatchersOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step';
import {
StartWatchersOrchestratorStep,
type FileBuiltEvent,
} from '@/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step';
import { SyncApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
import { UploadFilesOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step';
import * as fs from 'fs-extra';
@@ -69,7 +72,7 @@ export class DevModeOrchestrator {
this.startWatchersStep = new StartWatchersOrchestratorStep({
...stepDeps,
scheduleSync: this.scheduleSync.bind(this),
uploadFilesStep: this.uploadFilesStep,
onFileBuilt: this.handleFileBuilt.bind(this),
});
}
@@ -90,6 +93,16 @@ export class DevModeOrchestrator {
return this.state;
}
private handleFileBuilt(event: FileBuiltEvent): void {
if (this.state.steps.uploadFiles.output.fileUploader) {
this.uploadFilesStep.uploadFile(
event.builtPath,
event.sourcePath,
event.fileFolder,
);
}
}
private scheduleSync(): void {
if (this.syncTimer) {
clearTimeout(this.syncTimer);
@@ -150,11 +163,9 @@ export class DevModeOrchestrator {
}
}
if (this.state.hasObjectsOrFieldsChanged(buildResult.manifest!)) {
await this.generateApiClientStep.execute({
appPath: this.state.appPath,
});
}
const objectsOrFieldsChanged = this.state.hasObjectsOrFieldsChanged(
buildResult.manifest!,
);
await this.uploadFilesStep.waitForUploads();
@@ -163,6 +174,16 @@ export class DevModeOrchestrator {
builtFileInfos: this.state.steps.uploadFiles.output.builtFileInfos,
appPath: this.state.appPath,
});
if (objectsOrFieldsChanged) {
await this.generateApiClientStep.execute({
appPath: this.state.appPath,
});
await this.uploadFilesStep.copyAndUploadApiClientFiles(
this.state.appPath,
);
}
}
private async initializePipeline(manifest: Manifest): Promise<boolean> {
@@ -4,15 +4,23 @@ import {
type EsbuildWatcher,
} from '@/cli/utilities/build/common/esbuild-watcher';
import { FileUploadWatcher } from '@/cli/utilities/build/common/file-upload-watcher';
import { TscWatcher } from '@/cli/utilities/build/common/tsc-watcher';
import { type TypecheckError } from '@/cli/utilities/build/common/typecheck-plugin';
import { type ManifestBuildResult } from '@/cli/utilities/build/manifest/manifest-update-checksums';
import { ManifestWatcher } from '@/cli/utilities/build/manifest/manifest-watcher';
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { type UploadFilesOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step';
import type { Location } from 'esbuild';
import { type EventName } from 'chokidar/handler.js';
import { ASSETS_DIR } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
export type FileBuiltEvent = {
fileFolder: FileFolder;
builtPath: string;
sourcePath: string;
checksum: string;
};
export type StartWatchersOrchestratorStepOutput = {
watchersStarted: boolean;
};
@@ -21,24 +29,25 @@ export class StartWatchersOrchestratorStep {
private state: OrchestratorState;
private scheduleSync: () => void;
private notify: () => void;
private uploadFilesStep: UploadFilesOrchestratorStep;
private onFileBuilt: (event: FileBuiltEvent) => void;
private manifestWatcher: ManifestWatcher | null = null;
private logicFunctionsWatcher: EsbuildWatcher | null = null;
private frontComponentsWatcher: EsbuildWatcher | null = null;
private assetWatcher: FileUploadWatcher | null = null;
private dependencyWatcher: FileUploadWatcher | null = null;
private tscWatcher: TscWatcher | null = null;
constructor(options: {
state: OrchestratorState;
scheduleSync: () => void;
notify: () => void;
uploadFilesStep: UploadFilesOrchestratorStep;
onFileBuilt: (event: FileBuiltEvent) => void;
}) {
this.state = options.state;
this.scheduleSync = options.scheduleSync;
this.notify = options.notify;
this.uploadFilesStep = options.uploadFilesStep;
this.onFileBuilt = options.onFileBuilt;
}
async start(): Promise<void> {
@@ -74,6 +83,8 @@ export class StartWatchersOrchestratorStep {
}
async close(): Promise<void> {
this.tscWatcher?.close();
await Promise.all([
this.manifestWatcher?.close(),
this.logicFunctionsWatcher?.close(),
@@ -117,32 +128,20 @@ export class StartWatchersOrchestratorStep {
this.notify();
}
private handleFileBuilt({
fileFolder,
builtPath,
sourcePath,
checksum,
}: {
fileFolder: FileFolder;
builtPath: string;
sourcePath: string;
checksum: string;
}): void {
private handleFileBuilt(event: FileBuiltEvent): void {
this.state.addEvent({
message: `Successfully built ${builtPath}`,
message: `Successfully built ${event.builtPath}`,
status: 'success',
});
this.state.steps.uploadFiles.output.builtFileInfos.set(builtPath, {
checksum,
builtPath,
sourcePath,
fileFolder,
this.state.steps.uploadFiles.output.builtFileInfos.set(event.builtPath, {
checksum: event.checksum,
builtPath: event.builtPath,
sourcePath: event.sourcePath,
fileFolder: event.fileFolder,
});
if (this.state.steps.uploadFiles.output.fileUploader) {
this.uploadFilesStep.uploadFile(builtPath, sourcePath, fileFolder);
}
this.onFileBuilt(event);
this.notify();
this.scheduleSync();
@@ -153,6 +152,7 @@ export class StartWatchersOrchestratorStep {
frontComponents: string[],
): Promise<void> {
await Promise.all([
this.startTscWatcher(),
this.startLogicFunctionsWatcher(logicFunctions),
this.startFrontComponentsWatcher(frontComponents),
this.startAssetWatcher(),
@@ -207,4 +207,31 @@ export class StartWatchersOrchestratorStep {
this.dependencyWatcher.start();
}
private async startTscWatcher(): Promise<void> {
this.tscWatcher = new TscWatcher({
appPath: this.state.appPath,
onErrors: this.handleTypecheckErrors.bind(this),
});
await this.tscWatcher.start();
}
private handleTypecheckErrors(errors: TypecheckError[]): void {
if (errors.length === 0) {
this.state.addEvent({
message: 'Typecheck passed',
status: 'success',
});
} else {
this.state.applyStepEvents(
errors.map((error) => ({
message: `Type error in ${error.file}(${error.line},${error.column}): ${error.text}`,
status: 'error' as const,
})),
);
}
this.notify();
}
}
@@ -3,7 +3,13 @@ import {
type OrchestratorStateBuiltFileInfo,
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { FileUploader } from '@/cli/utilities/file/file-uploader';
import { type FileFolder } from 'twenty-shared/types';
import crypto from 'crypto';
import * as fs from 'fs-extra';
import { join } from 'path';
import { OUTPUT_DIR } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
const API_CLIENT_FILES = ['types.ts', 'schema.ts'];
export type UploadFilesOrchestratorStepOutput = {
fileUploader: FileUploader | null;
@@ -103,6 +109,48 @@ export class UploadFilesOrchestratorStep {
this.notify();
}
async copyAndUploadApiClientFiles(appPath: string): Promise<void> {
const generatedDir = join(
appPath,
'node_modules',
'twenty-sdk',
'generated',
);
if (!(await fs.pathExists(generatedDir))) {
return;
}
const outputDir = join(appPath, OUTPUT_DIR, 'api-client');
await fs.ensureDir(outputDir);
for (const fileName of API_CLIENT_FILES) {
const absoluteSourcePath = join(generatedDir, fileName);
if (!(await fs.pathExists(absoluteSourcePath))) {
continue;
}
await fs.copy(absoluteSourcePath, join(outputDir, fileName));
const content = await fs.readFile(absoluteSourcePath);
const checksum = crypto.createHash('md5').update(content).digest('hex');
const builtPath = join(OUTPUT_DIR, 'api-client', fileName);
const sourcePath = join('api-client', fileName);
this.state.steps.uploadFiles.output.builtFileInfos.set(builtPath, {
checksum,
builtPath,
sourcePath,
fileFolder: FileFolder.Dependencies,
});
this.uploadFile(builtPath, sourcePath, FileFolder.Dependencies);
}
}
private uploadPendingFiles(): void {
for (const [
builtPath,
@@ -2,5 +2,5 @@ import { type ApplicationManifest } from 'twenty-shared/application';
export type ApplicationConfig = Omit<
ApplicationManifest,
'packageJsonChecksum' | 'yarnLockChecksum'
'packageJsonChecksum' | 'yarnLockChecksum' | 'apiClientChecksum'
>;
@@ -70,6 +70,7 @@ describe('syncApplication', () => {
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
apiClientChecksum: null,
},
roles: [
{
@@ -21,4 +21,5 @@ export type ApplicationManifest = SyncableEntityOptions & {
marketplaceData?: ApplicationMarketplaceData;
packageJsonChecksum: string | null;
yarnLockChecksum: string | null;
apiClientChecksum: string | null;
};