fix(twenty-client-sdk): make genql codegen formatter prettier-3 compatible (fixes app-sync server crash) (#21354)

## 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.
This commit is contained in:
Charles Bochet
2026-06-09 11:08:06 +02:00
committed by GitHub
parent 1d81bdbb22
commit 4305a7dc84
9 changed files with 147 additions and 105 deletions
+1 -1
View File
@@ -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",
@@ -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
@@ -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<string> => {
try {
return await prettier.format(code, {
parser,
plugins: [parserGraphql, parserTS, parserEstree],
semi: false,
singleQuote: true,
trailingComma: 'all',
printWidth: 80,
})
} catch {
return code
}
}
@@ -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')) {
@@ -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') },
];
@@ -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)),
);
};
@@ -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<R extends QueryGenqlSelection>(
request: R & { __name?: string },
): Promise<FieldsSelection<Query, R>>
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<R extends MutationGenqlSelection>(
request: R & { __name?: string },
): Promise<FieldsSelection<Mutation, R>>
}
import types from './types'
export * from './schema'
const typeMap = linkTypeMap(types as any)
export const createClient = function (options?: ClientOptions): Client {
export interface Client {
query<R extends QueryGenqlSelection>(
request: R & { __name?: string },
): Promise<FieldsSelection<Query, R>>
mutation<R extends MutationGenqlSelection>(
request: R & { __name?: string },
): Promise<FieldsSelection<Mutation, R>>
}
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<fields extends QueryGenqlSelection> = FieldsSelection<
Query,
fields
>
export const generateQueryOp: (
fields: QueryGenqlSelection & { __name?: string },
) => GraphqlOperation = function (fields) {
return generateGraphqlOperation('query', typeMap.Query!, fields as any)
}
export type MutationResult<fields extends MutationGenqlSelection> =
FieldsSelection<Mutation, fields>
export const generateMutationOp: (
fields: MutationGenqlSelection & { __name?: string },
) => GraphqlOperation = function (fields) {
return generateGraphqlOperation('mutation', typeMap.Mutation!, fields as any)
}
export type QueryResult<fields extends QueryGenqlSelection> = FieldsSelection<Query, fields>
export const generateQueryOp: (fields: QueryGenqlSelection & { __name?: string }) => GraphqlOperation = function(fields) {
return generateGraphqlOperation('query', typeMap.Query!, fields as any)
}
export type SubscriptionResult<fields extends SubscriptionGenqlSelection> =
FieldsSelection<Subscription, fields>
export const generateSubscriptionOp: (
fields: SubscriptionGenqlSelection & { __name?: string },
) => GraphqlOperation = function (fields) {
return generateGraphqlOperation(
'subscription',
typeMap.Subscription!,
fields as any,
)
}
export type MutationResult<fields extends MutationGenqlSelection> = FieldsSelection<Mutation, fields>
export const generateMutationOp: (fields: MutationGenqlSelection & { __name?: string }) => GraphqlOperation = function(fields) {
return generateGraphqlOperation('mutation', typeMap.Mutation!, fields as any)
}
export type SubscriptionResult<fields extends SubscriptionGenqlSelection> = FieldsSelection<Subscription, fields>
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.
@@ -59,6 +59,7 @@ export default defineConfig(() => {
'node:fs',
'node:path',
'node:os',
'node:url',
],
output: [
{
+3 -3
View File
@@ -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"