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:
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user