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