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:
@@ -126,7 +126,7 @@
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"ci": true,
|
||||
"maxWorkers": 3
|
||||
"maxWorkers": 1
|
||||
},
|
||||
"coverage": {
|
||||
"coverageReporters": ["lcov", "text"]
|
||||
|
||||
+1
@@ -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,
|
||||
);
|
||||
|
||||
+5
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user