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": { "configurations": {
"ci": { "ci": {
"ci": true, "ci": true,
"maxWorkers": 3 "maxWorkers": 1
}, },
"coverage": { "coverage": {
"coverageReporters": ["lcov", "text"] "coverageReporters": ["lcov", "text"]
@@ -10,6 +10,7 @@ export const defineEntitiesTests = (appPath: string): void => {
const sortedFiles = files.map((f) => f.toString()).sort(); const sortedFiles = files.map((f) => f.toString()).sort();
expect(sortedFiles).toEqual([ expect(sortedFiles).toEqual([
'api-client',
'manifest.json', 'manifest.json',
'package.json', 'package.json',
'public', 'public',
@@ -210,8 +210,12 @@ const createSdkGeneratedResolverPlugin = (appPath: string): esbuild.Plugin => ({
}, },
}); });
export type EsbuildWatcherFactoryOptions = RestartableWatcherOptions & {
shouldSkipTypecheck: () => boolean;
};
export const createLogicFunctionsWatcher = ( export const createLogicFunctionsWatcher = (
options: RestartableWatcherOptions, options: EsbuildWatcherFactoryOptions,
): EsbuildWatcher => ): EsbuildWatcher =>
new EsbuildWatcher({ new EsbuildWatcher({
...options, ...options,
@@ -220,7 +224,7 @@ export const createLogicFunctionsWatcher = (
fileFolder: FileFolder.BuiltLogicFunction, fileFolder: FileFolder.BuiltLogicFunction,
platform: 'node', platform: 'node',
extraPlugins: [ extraPlugins: [
createTypecheckPlugin(options.appPath), createTypecheckPlugin(options.appPath, options.shouldSkipTypecheck),
createSdkGeneratedResolverPlugin(options.appPath), createSdkGeneratedResolverPlugin(options.appPath),
], ],
banner: NODE_ESM_CJS_BANNER, banner: NODE_ESM_CJS_BANNER,
@@ -228,7 +232,7 @@ export const createLogicFunctionsWatcher = (
}); });
export const createFrontComponentsWatcher = ( export const createFrontComponentsWatcher = (
options: RestartableWatcherOptions, options: EsbuildWatcherFactoryOptions,
): EsbuildWatcher => ): EsbuildWatcher =>
new EsbuildWatcher({ new EsbuildWatcher({
...options, ...options,
@@ -237,7 +241,7 @@ export const createFrontComponentsWatcher = (
fileFolder: FileFolder.BuiltFrontComponent, fileFolder: FileFolder.BuiltFrontComponent,
jsx: 'automatic', jsx: 'automatic',
extraPlugins: [ extraPlugins: [
createTypecheckPlugin(options.appPath), createTypecheckPlugin(options.appPath, options.shouldSkipTypecheck),
createSdkGeneratedResolverPlugin(options.appPath), createSdkGeneratedResolverPlugin(options.appPath),
...getFrontComponentBuildPlugins(), ...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', name: 'typecheck',
setup: (build) => { setup: (build) => {
build.onStart(async () => { build.onStart(async () => {
if (shouldSkipTypecheck()) {
return;
}
const errors = await runTypecheck(appPath); const errors = await runTypecheck(appPath);
return { errors: toEsbuildErrors(errors) }; return { errors: toEsbuildErrors(errors) };
@@ -92,6 +92,31 @@ export class ClientService {
await fs.move(tempPath, outputPath); 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 { private resolveGeneratedPath(appPath: string): string {
return join(appPath, 'node_modules', 'twenty-sdk', GENERATED_DIR); return join(appPath, 'node_modules', 'twenty-sdk', GENERATED_DIR);
} }
@@ -29,6 +29,8 @@ export class DevModeOrchestrator {
private syncTimer: NodeJS.Timeout | null = null; private syncTimer: NodeJS.Timeout | null = null;
private serverCheckInterval: NodeJS.Timeout | null = null; private serverCheckInterval: NodeJS.Timeout | null = null;
private clientService: ClientService;
private skipTypecheck = true;
private checkServerStep: CheckServerOrchestratorStep; private checkServerStep: CheckServerOrchestratorStep;
private ensureValidTokensStep: EnsureValidTokensOrchestratorStep; private ensureValidTokensStep: EnsureValidTokensOrchestratorStep;
private buildManifestStep: BuildManifestOrchestratorStep; private buildManifestStep: BuildManifestOrchestratorStep;
@@ -44,7 +46,7 @@ export class DevModeOrchestrator {
const apiService = new ApiService({ disableInterceptors: true }); const apiService = new ApiService({ disableInterceptors: true });
const configService = new ConfigService(); const configService = new ConfigService();
const clientService = new ClientService(); this.clientService = new ClientService();
const stepDeps = { state: this.state, notify: () => this.state.notify() }; const stepDeps = { state: this.state, notify: () => this.state.notify() };
this.checkServerStep = new CheckServerOrchestratorStep({ this.checkServerStep = new CheckServerOrchestratorStep({
@@ -64,7 +66,7 @@ export class DevModeOrchestrator {
this.uploadFilesStep = new UploadFilesOrchestratorStep(stepDeps); this.uploadFilesStep = new UploadFilesOrchestratorStep(stepDeps);
this.generateApiClientStep = new GenerateApiClientOrchestratorStep({ this.generateApiClientStep = new GenerateApiClientOrchestratorStep({
...stepDeps, ...stepDeps,
clientService, clientService: this.clientService,
configService, configService,
}); });
this.syncApplicationStep = new SyncApplicationOrchestratorStep({ this.syncApplicationStep = new SyncApplicationOrchestratorStep({
@@ -75,6 +77,7 @@ export class DevModeOrchestrator {
...stepDeps, ...stepDeps,
scheduleSync: this.scheduleSync.bind(this), scheduleSync: this.scheduleSync.bind(this),
onFileBuilt: this.handleFileBuilt.bind(this), onFileBuilt: this.handleFileBuilt.bind(this),
shouldSkipTypecheck: () => this.skipTypecheck,
}); });
} }
@@ -84,6 +87,10 @@ export class DevModeOrchestrator {
await fs.ensureDir(outputDir); await fs.ensureDir(outputDir);
await fs.emptyDir(outputDir); await fs.emptyDir(outputDir);
await this.clientService.ensureGeneratedClientStub({
appPath: this.state.appPath,
});
await this.startWatchersStep.start(); await this.startWatchersStep.start();
this.serverCheckInterval = setInterval(() => { this.serverCheckInterval = setInterval(() => {
@@ -200,6 +207,8 @@ export class DevModeOrchestrator {
appPath: this.state.appPath, appPath: this.state.appPath,
}); });
this.skipTypecheck = false;
await this.uploadFilesStep.copyAndUploadApiClientFiles( await this.uploadFilesStep.copyAndUploadApiClientFiles(
this.state.appPath, this.state.appPath,
); );
@@ -30,6 +30,7 @@ export class StartWatchersOrchestratorStep {
private scheduleSync: () => void; private scheduleSync: () => void;
private notify: () => void; private notify: () => void;
private onFileBuilt: (event: FileBuiltEvent) => void; private onFileBuilt: (event: FileBuiltEvent) => void;
private shouldSkipTypecheck: () => boolean;
private manifestWatcher: ManifestWatcher | null = null; private manifestWatcher: ManifestWatcher | null = null;
private logicFunctionsWatcher: EsbuildWatcher | null = null; private logicFunctionsWatcher: EsbuildWatcher | null = null;
@@ -43,11 +44,13 @@ export class StartWatchersOrchestratorStep {
scheduleSync: () => void; scheduleSync: () => void;
notify: () => void; notify: () => void;
onFileBuilt: (event: FileBuiltEvent) => void; onFileBuilt: (event: FileBuiltEvent) => void;
shouldSkipTypecheck: () => boolean;
}) { }) {
this.state = options.state; this.state = options.state;
this.scheduleSync = options.scheduleSync; this.scheduleSync = options.scheduleSync;
this.notify = options.notify; this.notify = options.notify;
this.onFileBuilt = options.onFileBuilt; this.onFileBuilt = options.onFileBuilt;
this.shouldSkipTypecheck = options.shouldSkipTypecheck;
} }
async start(): Promise<void> { async start(): Promise<void> {
@@ -166,6 +169,7 @@ export class StartWatchersOrchestratorStep {
this.logicFunctionsWatcher = createLogicFunctionsWatcher({ this.logicFunctionsWatcher = createLogicFunctionsWatcher({
appPath: this.state.appPath, appPath: this.state.appPath,
sourcePaths, sourcePaths,
shouldSkipTypecheck: this.shouldSkipTypecheck,
handleBuildError: this.handleFileBuildError.bind(this), handleBuildError: this.handleFileBuildError.bind(this),
handleFileBuilt: this.handleFileBuilt.bind(this), handleFileBuilt: this.handleFileBuilt.bind(this),
}); });
@@ -179,6 +183,7 @@ export class StartWatchersOrchestratorStep {
this.frontComponentsWatcher = createFrontComponentsWatcher({ this.frontComponentsWatcher = createFrontComponentsWatcher({
appPath: this.state.appPath, appPath: this.state.appPath,
sourcePaths, sourcePaths,
shouldSkipTypecheck: this.shouldSkipTypecheck,
handleBuildError: this.handleFileBuildError.bind(this), handleBuildError: this.handleFileBuildError.bind(this),
handleFileBuilt: this.handleFileBuilt.bind(this), handleFileBuilt: this.handleFileBuilt.bind(this),
}); });