From 4305a7dc847ea66e08c4f312084d6b172baccb2e Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Tue, 9 Jun 2026 11:08:06 +0200 Subject: [PATCH] fix(twenty-client-sdk): make genql codegen formatter prettier-3 compatible (fixes app-sync server crash) (#21354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Syncing a `twenty-sdk` app against a server (`twenty dev`) **crashes the server process**. The metadata migration completes, then the server-side `GqlTypeGenerator` regenerates typed clients via the vendored genql codegen in `twenty-client-sdk`, which throws and exits node: ``` ConfigError: Couldn't find plugin for AST format "estree". Plugins must be explicitly added to the standalone bundle. at .../packages/twenty-client-sdk/dist/generate.cjs Node.js v24.5.0 ← process exits ``` The CLI sees `ECONNRESET`; the app row still persists because the crash happens after the metadata commit. Any app sync takes the server down. ## Root cause The genql codegen formatter [`prettify.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-client-sdk/src/generate/genql/helpers/prettify.ts) was vendored (in #21339) targeting **prettier 2.8**: a synchronous `format()` and `prettier/parser-typescript`, which in 2.8 bundles the estree printer. `package.json` pins `prettier ^2.8.8`, but the monorepo actually resolves/bundles **prettier 3.8.3** (`^2.8.8` is silently unsatisfied — no 2.8.x is nested for this package, and the subpath imports are bundled from the hoisted 3.8.3). Under prettier 3 the estree printer must be added explicitly (`prettier/plugins/estree`) and `format()` is async — so the codegen throws. `prettier/parser-typescript` / `parser-graphql` don't even exist in prettier 3 (only `prettier/plugins/*`), so the declared `^2.8.8` was already inconsistent with what runs. ## Fix - `prettify`: switch to the prettier-3 entrypoints `prettier/plugins/{graphql,typescript,estree}`, `await` the async `format()`, and fall back to the unformatted (still valid) code on any failure so cosmetic formatting can never crash codegen again. - `RenderContext.toCode` + `clientTasks`: propagate the now-async `prettify` (await the four `toCode` call sites). - Bump the declared `prettier` dependency `^2.8.8 → ^3.8.3` to match what is actually used (only consumer; minimal lockfile diff). ## Verification (local, source server on :3000) - `twenty dev --once` now completes: `Registering application → Syncing manifest → Generating API client → ✓ Synced` with the **server staying up**. - The app integration test passes (full re-sync of 6 metadata objects + `MetadataApiClient`/`CoreApiClient` CRUD through the generated genql runtime). - `nx build twenty-client-sdk` (incl. `tsgo` typecheck) passes. Release note: this is a v2.11 blocker — without it, installing/syncing any app crashes the server. --- packages/twenty-client-sdk/package.json | 2 +- .../src/generate/genql/README.md | 9 +- .../src/generate/genql/helpers/prettify.ts | 36 ++++-- .../genql/render/common/RenderContext.ts | 2 +- .../src/generate/genql/runtime-templates.ts | 63 +++++++-- .../src/generate/genql/tasks/clientTasks.ts | 11 +- .../src/metadata/generated/index.ts | 122 ++++++++---------- packages/twenty-client-sdk/vite.config.ts | 1 + yarn.lock | 6 +- 9 files changed, 147 insertions(+), 105 deletions(-) diff --git a/packages/twenty-client-sdk/package.json b/packages/twenty-client-sdk/package.json index 7c31a91504..29468e5b77 100644 --- a/packages/twenty-client-sdk/package.json +++ b/packages/twenty-client-sdk/package.json @@ -52,7 +52,7 @@ "esbuild": "^0.28.0", "graphql": "^16.8.1", "lodash": "^4.17.21", - "prettier": "^2.8.8" + "prettier": "^3.8.3" }, "devDependencies": { "@types/lodash": "^4.17.15", diff --git a/packages/twenty-client-sdk/src/generate/genql/README.md b/packages/twenty-client-sdk/src/generate/genql/README.md index 7a37181aa9..22a6912a61 100644 --- a/packages/twenty-client-sdk/src/generate/genql/README.md +++ b/packages/twenty-client-sdk/src/generate/genql/README.md @@ -35,10 +35,11 @@ in abandoned and vulnerable transitive packages (`undici`, `native-fetch`, generated client still defaults its url/fetch to `undefined` (Twenty's wrapper supplies them) and the output is unchanged. -The generated output is byte-for-byte identical to what `@genql/cli@3.0.5` -produced; `prettier@^2.8` is retained for that reason. The runtime query path is -covered by `__tests__/generated-client-query.test.ts`, which drives a real -generated client against a mock transport. +The renderers are vendored verbatim from `@genql/cli@3.0.5`. Formatting now runs +on `prettier@^3` (the version the monorepo resolves): it needs the explicit +`prettier/plugins/estree` printer and an awaited, async `format()`. The runtime +query path is covered by `__tests__/generated-client-query.test.ts`, which drives +a real generated client against a mock transport. ## License diff --git a/packages/twenty-client-sdk/src/generate/genql/helpers/prettify.ts b/packages/twenty-client-sdk/src/generate/genql/helpers/prettify.ts index 010e824b89..af7f051e6b 100644 --- a/packages/twenty-client-sdk/src/generate/genql/helpers/prettify.ts +++ b/packages/twenty-client-sdk/src/generate/genql/helpers/prettify.ts @@ -1,17 +1,29 @@ // @ts-nocheck import prettier from 'prettier/standalone' import { BuiltInParserName } from 'prettier' -import parserGraphql from 'prettier/parser-graphql' -import parserTS from 'prettier/parser-typescript' +import * as parserGraphql from 'prettier/plugins/graphql' +import * as parserTS from 'prettier/plugins/typescript' +import * as parserEstree from 'prettier/plugins/estree' -export const prettify = (code: string, parser?: BuiltInParserName): string => { - // return code - return prettier.format(code, { - parser, - plugins: [parserGraphql, parserTS], - semi: false, - singleQuote: true, - trailingComma: 'all', - printWidth: 80, - }) +// Prettier 3 split the estree printer out of the TS parser and made format() +// async. Without prettier/plugins/estree the standalone bundle throws +// "Couldn't find plugin for AST format estree", which crashed the server-side +// GqlTypeGenerator during app sync. Formatting is best-effort cosmetic, so we +// fall back to the unformatted (still valid) code rather than ever throwing. +export const prettify = async ( + code: string, + parser?: BuiltInParserName, +): Promise => { + try { + return await prettier.format(code, { + parser, + plugins: [parserGraphql, parserTS, parserEstree], + semi: false, + singleQuote: true, + trailingComma: 'all', + printWidth: 80, + }) + } catch { + return code + } } diff --git a/packages/twenty-client-sdk/src/generate/genql/render/common/RenderContext.ts b/packages/twenty-client-sdk/src/generate/genql/render/common/RenderContext.ts index 7a104545ca..2177da9ca0 100644 --- a/packages/twenty-client-sdk/src/generate/genql/render/common/RenderContext.ts +++ b/packages/twenty-client-sdk/src/generate/genql/render/common/RenderContext.ts @@ -89,7 +89,7 @@ export class RenderContext { else return } - toCode(parser?: BuiltInParserName, pretty = false) { + async toCode(parser?: BuiltInParserName, pretty = false) { const blocks = [...this.codeBlocks] if (parser && (parser === 'typescript' || parser === 'babel')) { diff --git a/packages/twenty-client-sdk/src/generate/genql/runtime-templates.ts b/packages/twenty-client-sdk/src/generate/genql/runtime-templates.ts index f00ae7eb1c..e0e8e044d1 100644 --- a/packages/twenty-client-sdk/src/generate/genql/runtime-templates.ts +++ b/packages/twenty-client-sdk/src/generate/genql/runtime-templates.ts @@ -1,7 +1,18 @@ -// The generated client ships its own copy of the genql runtime. Upstream genql -// reads these files from disk at generation time; we import them as raw text so -// they are bundled into this package and copied verbatim into each generated -// client's `runtime/` folder (with a `// @ts-nocheck` header, as genql does). +// The generated client ships its own copy of the genql runtime. We import the +// files as raw text (Vite's `?raw`) so they are bundled into this package and +// copied verbatim into each generated client's `runtime/` folder (with a +// `// @ts-nocheck` header, as genql does). +// +// `?raw` only resolves under Vite. When this module runs outside a Vite bundle +// — e.g. `tsx scripts/generate-metadata-client.ts`, which backs the +// `generate-metadata-client` target and server-validation — the `?raw` imports +// are `undefined`, so we fall back to reading the sibling source files from +// disk. In the bundled build the imports are defined and the fallback is never +// reached, so the shipped runtime is unchanged. +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import batcher from './runtime/batcher.ts?raw'; import createClient from './runtime/createClient.ts?raw'; import error from './runtime/error.ts?raw'; @@ -12,14 +23,40 @@ import linkTypeMap from './runtime/linkTypeMap.ts?raw'; import typeSelection from './runtime/typeSelection.ts?raw'; import types from './runtime/types.ts?raw'; +const readTemplate = ( + bundled: string | undefined, + fileName: string, +): string => { + if (bundled !== undefined) return bundled; + + const runtimeDir = join(dirname(fileURLToPath(import.meta.url)), 'runtime'); + + return readFileSync(join(runtimeDir, fileName), 'utf-8'); +}; + export const RUNTIME_TEMPLATE_FILES: { name: string; content: string }[] = [ - { name: 'batcher.ts', content: batcher }, - { name: 'createClient.ts', content: createClient }, - { name: 'error.ts', content: error }, - { name: 'fetcher.ts', content: fetcher }, - { name: 'generateGraphqlOperation.ts', content: generateGraphqlOperation }, - { name: 'index.ts', content: index }, - { name: 'linkTypeMap.ts', content: linkTypeMap }, - { name: 'typeSelection.ts', content: typeSelection }, - { name: 'types.ts', content: types }, + { name: 'batcher.ts', content: readTemplate(batcher, 'batcher.ts') }, + { + name: 'createClient.ts', + content: readTemplate(createClient, 'createClient.ts'), + }, + { name: 'error.ts', content: readTemplate(error, 'error.ts') }, + { name: 'fetcher.ts', content: readTemplate(fetcher, 'fetcher.ts') }, + { + name: 'generateGraphqlOperation.ts', + content: readTemplate( + generateGraphqlOperation, + 'generateGraphqlOperation.ts', + ), + }, + { name: 'index.ts', content: readTemplate(index, 'index.ts') }, + { + name: 'linkTypeMap.ts', + content: readTemplate(linkTypeMap, 'linkTypeMap.ts'), + }, + { + name: 'typeSelection.ts', + content: readTemplate(typeSelection, 'typeSelection.ts'), + }, + { name: 'types.ts', content: readTemplate(types, 'types.ts') }, ]; diff --git a/packages/twenty-client-sdk/src/generate/genql/tasks/clientTasks.ts b/packages/twenty-client-sdk/src/generate/genql/tasks/clientTasks.ts index 1dff66ede0..247ffa770e 100644 --- a/packages/twenty-client-sdk/src/generate/genql/tasks/clientTasks.ts +++ b/packages/twenty-client-sdk/src/generate/genql/tasks/clientTasks.ts @@ -35,7 +35,10 @@ export const writeClientFiles = async ( const schemaGqlCtx = new RenderContext(schema, config); renderSchema(schema, schemaGqlCtx); - await writeFileToPath([output, schemaGqlFile], schemaGqlCtx.toCode('graphql')); + await writeFileToPath( + [output, schemaGqlFile], + await schemaGqlCtx.toCode('graphql'), + ); await ensurePath([output, 'runtime']); for (const { name, content } of RUNTIME_TEMPLATE_FILES) { @@ -49,21 +52,21 @@ export const writeClientFiles = async ( renderEnumsMaps(schema, schemaTypesCtx); await writeFileToPath( [output, schemaTypesFile], - '// @ts-nocheck\n' + schemaTypesCtx.toCode('typescript'), + '// @ts-nocheck\n' + (await schemaTypesCtx.toCode('typescript')), ); const typeMapCtx = new RenderContext(schema, config); renderTypeMap(schema, typeMapCtx); await writeFileToPath( [output, typeMapFileEsm], - `export default ${typeMapCtx.toCode()}`, + `export default ${await typeMapCtx.toCode()}`, ); const clientCtx = new RenderContext(schema, config); renderClientEsm(schema, clientCtx); await writeFileToPath( [output, clientFileEsm], - '// @ts-nocheck\n' + clientCtx.toCode('typescript', true), + '// @ts-nocheck\n' + (await clientCtx.toCode('typescript', true)), ); }; diff --git a/packages/twenty-client-sdk/src/metadata/generated/index.ts b/packages/twenty-client-sdk/src/metadata/generated/index.ts index 695fccc606..1551b4b313 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/index.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/index.ts @@ -1,83 +1,71 @@ // @ts-nocheck -import type { - QueryGenqlSelection, - Query, - MutationGenqlSelection, - Mutation, - SubscriptionGenqlSelection, - Subscription, -} from './schema' -import { - linkTypeMap, - createClient as createClientOriginal, - generateGraphqlOperation, - type FieldsSelection, - type GraphqlOperation, - type ClientOptions, - GenqlError, -} from './runtime' -export type { FieldsSelection } from './runtime' -export { GenqlError } -import types from './types' -export * from './schema' -const typeMap = linkTypeMap(types as any) -export interface Client { - query( - request: R & { __name?: string }, - ): Promise> +import type {QueryGenqlSelection,Query,MutationGenqlSelection,Mutation,SubscriptionGenqlSelection,Subscription} from './schema' + import { + linkTypeMap, + createClient as createClientOriginal, + generateGraphqlOperation, + type FieldsSelection, type GraphqlOperation, type ClientOptions, GenqlError + } from './runtime' + export type { FieldsSelection } from './runtime' + export { GenqlError } - mutation( - request: R & { __name?: string }, - ): Promise> -} + import types from './types' + export * from './schema' + const typeMap = linkTypeMap(types as any) -export const createClient = function (options?: ClientOptions): Client { + + export interface Client { + + query( + request: R & { __name?: string }, + ): Promise> + + mutation( + request: R & { __name?: string }, + ): Promise> + + } + + + export const createClient = +function(options?: ClientOptions): Client { return createClientOriginal({ - url: undefined, - - ...options, - queryRoot: typeMap.Query!, - mutationRoot: typeMap.Mutation!, - subscriptionRoot: typeMap.Subscription!, + url: undefined, + + ...options, + queryRoot: typeMap.Query!, + mutationRoot: typeMap.Mutation!, + subscriptionRoot: typeMap.Subscription!, }) as any } -export const everything = { - __scalar: true, -} + export const everything = { + __scalar: true + } + -export type QueryResult = FieldsSelection< - Query, - fields -> -export const generateQueryOp: ( - fields: QueryGenqlSelection & { __name?: string }, -) => GraphqlOperation = function (fields) { - return generateGraphqlOperation('query', typeMap.Query!, fields as any) -} -export type MutationResult = - FieldsSelection -export const generateMutationOp: ( - fields: MutationGenqlSelection & { __name?: string }, -) => GraphqlOperation = function (fields) { - return generateGraphqlOperation('mutation', typeMap.Mutation!, fields as any) -} + export type QueryResult = FieldsSelection + export const generateQueryOp: (fields: QueryGenqlSelection & { __name?: string }) => GraphqlOperation = function(fields) { + return generateGraphqlOperation('query', typeMap.Query!, fields as any) + } + -export type SubscriptionResult = - FieldsSelection -export const generateSubscriptionOp: ( - fields: SubscriptionGenqlSelection & { __name?: string }, -) => GraphqlOperation = function (fields) { - return generateGraphqlOperation( - 'subscription', - typeMap.Subscription!, - fields as any, - ) -} + export type MutationResult = FieldsSelection + export const generateMutationOp: (fields: MutationGenqlSelection & { __name?: string }) => GraphqlOperation = function(fields) { + return generateGraphqlOperation('mutation', typeMap.Mutation!, fields as any) + } + + + + export type SubscriptionResult = FieldsSelection + export const generateSubscriptionOp: (fields: SubscriptionGenqlSelection & { __name?: string }) => GraphqlOperation = function(fields) { + return generateGraphqlOperation('subscription', typeMap.Subscription!, fields as any) + } + // MetadataApiClient (auto-injected by twenty-client-sdk) // Ambient type stubs for the genql-generated code this template gets // injected into. They enable full typecheck/lint on this file. diff --git a/packages/twenty-client-sdk/vite.config.ts b/packages/twenty-client-sdk/vite.config.ts index b66146d2ad..34016baab3 100644 --- a/packages/twenty-client-sdk/vite.config.ts +++ b/packages/twenty-client-sdk/vite.config.ts @@ -59,6 +59,7 @@ export default defineConfig(() => { 'node:fs', 'node:path', 'node:os', + 'node:url', ], output: [ { diff --git a/yarn.lock b/yarn.lock index ac9b2af1eb..2f9b29eae5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -48890,7 +48890,7 @@ __metadata: languageName: node linkType: hard -"prettier@npm:2.8.8, prettier@npm:^2.0.0, prettier@npm:^2.8.8": +"prettier@npm:2.8.8, prettier@npm:^2.0.0": version: 2.8.8 resolution: "prettier@npm:2.8.8" bin: @@ -48899,7 +48899,7 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^3.1.1, prettier@npm:^3.2.5, prettier@npm:^3.4.2, prettier@npm:^3.5.3": +"prettier@npm:^3.1.1, prettier@npm:^3.2.5, prettier@npm:^3.4.2, prettier@npm:^3.5.3, prettier@npm:^3.8.3": version: 3.8.3 resolution: "prettier@npm:3.8.3" bin: @@ -56174,7 +56174,7 @@ __metadata: esbuild: "npm:^0.28.0" graphql: "npm:^16.8.1" lodash: "npm:^4.17.21" - prettier: "npm:^2.8.8" + prettier: "npm:^3.8.3" tsc-alias: "npm:^1.8.16" twenty-shared: "workspace:*" typescript: "npm:^5.9.3"