Fix twenty-sdk typecheck race condition (#18246)

## Fix SDK first-run build failure when generated API client is missing

### Problem

When running `twenty app:dev` for the first time, the build pipeline
crashes because:

1. The esbuild front-component watcher tries to resolve
`twenty-sdk/generated`, but the generated API client doesn't exist yet —
it's only created later in the pipeline after syncing with the server.
2. The typecheck plugin (`tsc --noEmit`) runs as a blocking esbuild
`onStart` hook, so even with stub files, type errors on the empty client
classes (e.g. `Property 'query' does not exist on type 'CoreApiClient'`)
cause the build to fail before the real client can be generated.

This creates a chicken-and-egg problem: the watchers need the generated
client to build, but the client is generated after the watchers produce
their output.

### Solution

**1. Stub generated client on startup**

Added `ensureGeneratedClientStub` to `ClientService` that writes minimal
placeholder files (`CoreApiClient`, `MetadataApiClient`) into
`node_modules/twenty-sdk/generated/` if the directory doesn't already
exist. This is called in `DevModeOrchestrator.start()` before any
watchers are created, so the `twenty-sdk/generated` import always
resolves.

**2. Skip typecheck on first sync round**

Made the esbuild typecheck plugin accept a `shouldSkipTypecheck`
callback. The orchestrator starts with `skipTypecheck = true` and passes
`() => this.skipTypecheck` through the watcher chain. After the real API
client is generated, the flag is flipped to `false`, so subsequent
rebuilds enforce full type checking with the real generated types.
This commit is contained in:
Charles Bochet
2026-02-25 23:47:08 +01:00
committed by GitHub
parent 4ea8245387
commit 0b4bf97f35
7 changed files with 59 additions and 8 deletions
+1 -1
View File
@@ -126,7 +126,7 @@
"configurations": {
"ci": {
"ci": true,
"maxWorkers": 3
"maxWorkers": 1
},
"coverage": {
"coverageReporters": ["lcov", "text"]
@@ -10,6 +10,7 @@ export const defineEntitiesTests = (appPath: string): void => {
const sortedFiles = files.map((f) => f.toString()).sort();
expect(sortedFiles).toEqual([
'api-client',
'manifest.json',
'package.json',
'public',
@@ -210,8 +210,12 @@ const createSdkGeneratedResolverPlugin = (appPath: string): esbuild.Plugin => ({
},
});
export type EsbuildWatcherFactoryOptions = RestartableWatcherOptions & {
shouldSkipTypecheck: () => boolean;
};
export const createLogicFunctionsWatcher = (
options: RestartableWatcherOptions,
options: EsbuildWatcherFactoryOptions,
): EsbuildWatcher =>
new EsbuildWatcher({
...options,
@@ -220,7 +224,7 @@ export const createLogicFunctionsWatcher = (
fileFolder: FileFolder.BuiltLogicFunction,
platform: 'node',
extraPlugins: [
createTypecheckPlugin(options.appPath),
createTypecheckPlugin(options.appPath, options.shouldSkipTypecheck),
createSdkGeneratedResolverPlugin(options.appPath),
],
banner: NODE_ESM_CJS_BANNER,
@@ -228,7 +232,7 @@ export const createLogicFunctionsWatcher = (
});
export const createFrontComponentsWatcher = (
options: RestartableWatcherOptions,
options: EsbuildWatcherFactoryOptions,
): EsbuildWatcher =>
new EsbuildWatcher({
...options,
@@ -237,7 +241,7 @@ export const createFrontComponentsWatcher = (
fileFolder: FileFolder.BuiltFrontComponent,
jsx: 'automatic',
extraPlugins: [
createTypecheckPlugin(options.appPath),
createTypecheckPlugin(options.appPath, options.shouldSkipTypecheck),
createSdkGeneratedResolverPlugin(options.appPath),
...getFrontComponentBuildPlugins(),
],
@@ -72,10 +72,17 @@ const toEsbuildErrors = (errors: TypecheckError[]): esbuild.PartialMessage[] =>
},
}));
export const createTypecheckPlugin = (appPath: string): esbuild.Plugin => ({
export const createTypecheckPlugin = (
appPath: string,
shouldSkipTypecheck: () => boolean,
): esbuild.Plugin => ({
name: 'typecheck',
setup: (build) => {
build.onStart(async () => {
if (shouldSkipTypecheck()) {
return;
}
const errors = await runTypecheck(appPath);
return { errors: toEsbuildErrors(errors) };
@@ -92,6 +92,31 @@ export class ClientService {
await fs.move(tempPath, outputPath);
}
async ensureGeneratedClientStub({
appPath,
}: {
appPath: string;
}): Promise<void> {
const outputPath = this.resolveGeneratedPath(appPath);
if (await fs.pathExists(join(outputPath, 'index.ts'))) {
return;
}
await fs.ensureDir(join(outputPath, 'core'));
await fs.ensureDir(join(outputPath, 'metadata'));
await fs.writeFile(
join(outputPath, 'core', 'index.ts'),
'export class CoreApiClient {}\n',
);
await fs.writeFile(
join(outputPath, 'metadata', 'index.ts'),
'export class MetadataApiClient {}\n',
);
await this.writeBarrelIndex(outputPath);
}
private resolveGeneratedPath(appPath: string): string {
return join(appPath, 'node_modules', 'twenty-sdk', GENERATED_DIR);
}
@@ -29,6 +29,8 @@ export class DevModeOrchestrator {
private syncTimer: NodeJS.Timeout | null = null;
private serverCheckInterval: NodeJS.Timeout | null = null;
private clientService: ClientService;
private skipTypecheck = true;
private checkServerStep: CheckServerOrchestratorStep;
private ensureValidTokensStep: EnsureValidTokensOrchestratorStep;
private buildManifestStep: BuildManifestOrchestratorStep;
@@ -44,7 +46,7 @@ export class DevModeOrchestrator {
const apiService = new ApiService({ disableInterceptors: true });
const configService = new ConfigService();
const clientService = new ClientService();
this.clientService = new ClientService();
const stepDeps = { state: this.state, notify: () => this.state.notify() };
this.checkServerStep = new CheckServerOrchestratorStep({
@@ -64,7 +66,7 @@ export class DevModeOrchestrator {
this.uploadFilesStep = new UploadFilesOrchestratorStep(stepDeps);
this.generateApiClientStep = new GenerateApiClientOrchestratorStep({
...stepDeps,
clientService,
clientService: this.clientService,
configService,
});
this.syncApplicationStep = new SyncApplicationOrchestratorStep({
@@ -75,6 +77,7 @@ export class DevModeOrchestrator {
...stepDeps,
scheduleSync: this.scheduleSync.bind(this),
onFileBuilt: this.handleFileBuilt.bind(this),
shouldSkipTypecheck: () => this.skipTypecheck,
});
}
@@ -84,6 +87,10 @@ export class DevModeOrchestrator {
await fs.ensureDir(outputDir);
await fs.emptyDir(outputDir);
await this.clientService.ensureGeneratedClientStub({
appPath: this.state.appPath,
});
await this.startWatchersStep.start();
this.serverCheckInterval = setInterval(() => {
@@ -200,6 +207,8 @@ export class DevModeOrchestrator {
appPath: this.state.appPath,
});
this.skipTypecheck = false;
await this.uploadFilesStep.copyAndUploadApiClientFiles(
this.state.appPath,
);
@@ -30,6 +30,7 @@ export class StartWatchersOrchestratorStep {
private scheduleSync: () => void;
private notify: () => void;
private onFileBuilt: (event: FileBuiltEvent) => void;
private shouldSkipTypecheck: () => boolean;
private manifestWatcher: ManifestWatcher | null = null;
private logicFunctionsWatcher: EsbuildWatcher | null = null;
@@ -43,11 +44,13 @@ export class StartWatchersOrchestratorStep {
scheduleSync: () => void;
notify: () => void;
onFileBuilt: (event: FileBuiltEvent) => void;
shouldSkipTypecheck: () => boolean;
}) {
this.state = options.state;
this.scheduleSync = options.scheduleSync;
this.notify = options.notify;
this.onFileBuilt = options.onFileBuilt;
this.shouldSkipTypecheck = options.shouldSkipTypecheck;
}
async start(): Promise<void> {
@@ -166,6 +169,7 @@ export class StartWatchersOrchestratorStep {
this.logicFunctionsWatcher = createLogicFunctionsWatcher({
appPath: this.state.appPath,
sourcePaths,
shouldSkipTypecheck: this.shouldSkipTypecheck,
handleBuildError: this.handleFileBuildError.bind(this),
handleFileBuilt: this.handleFileBuilt.bind(this),
});
@@ -179,6 +183,7 @@ export class StartWatchersOrchestratorStep {
this.frontComponentsWatcher = createFrontComponentsWatcher({
appPath: this.state.appPath,
sourcePaths,
shouldSkipTypecheck: this.shouldSkipTypecheck,
handleBuildError: this.handleFileBuildError.bind(this),
handleFileBuilt: this.handleFileBuilt.bind(this),
});