Skip to content

feat: GraphQL client and generator - #8979

Open
tim-smart wants to merge 96 commits into
mainfrom
feat/graphql
Open

tim-smart wants to merge 96 commits into
mainfrom
feat/graphql

Conversation

@tim-smart

@tim-smart tim-smart commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Closes EFF-1911
Closes EFF-1977
Closes EFF-1978
Closes EFF-1979

Adds a typed GraphQL client to core effect as the experimental effect/graphql module, and a standalone generator, @effect/graphql-generator, that emits operations for it from .graphql documents. The generator needs only effect and @effect/platform-node. The lexer, parser, schema model and validation are built in, and graphql-js isn't needed.

effect/graphql (core effect)

  • GraphQL.query / mutation / subscription(name, { document, variables?, result }) are plain operation values. GraphQL.middleware(op, Tag) attaches middleware to one operation. Variables are passed decoded and encoded through their Schema, so custom scalar codecs apply to inputs and results.
  • GraphQLGroup.make(...ops) / merge(...groups). The generator emits one group per file. Group middleware runs outside operation middleware.
  • GraphQLMiddleware.Service<M, { requires?, error? }>()(key) tags whose value is { execute, subscribe }, plus mapRequest(f) for headers, auth and extensions.
  • GraphQLClient.make(group, { subscriptionRetry? }) gives one method per operation, called as (variables?, { headers?, urlParams?, context?, partial? }?). Queries and mutations return an Effect, subscriptions a Stream. Retryable transport failures resubscribe through the whole middleware chain (exponential from 500 ms x1.5, capped at 5 s, honouring retryAfter).
  • Any non-empty errors fails a call by default with a ResponseError, which carries the response's HTTP status (unset over graphql-ws and for graphql-sse events). A ResponseError is never retryable, whatever the status. { partial: true } on queries and mutations returns { data, errors }.
  • GraphQLClient.pages / items page forward through cursor connections over a nullable $after: String. A missing $after, pageInfo or nodes is a compile error.
  • GraphQLClient.makeTest(group)(handlers) is a typed mock client for tests, wired in with Layer.succeed(Service, ...). Optional handlers keyed by operation name receive the caller's variables and options and return decoded results (an Effect, or a Stream for subscriptions). Nothing is encoded, decoded or validated and no middleware runs. { partial: true } gives { data, errors: [] }, methods keep .operation so pages / items work, and an operation without a handler dies with an UnimplementedError.
  • GraphQLClient.layerTest(Service, group)(handlers) wraps makeTest in a service layer. Bind it once as static readonly layerTest = GraphQLClient.layerTest(Service, group) to use Service.layerTest({ Operation: () => Effect.succeed(result) }) in tests. Handler inference comes from the group, and a group that cannot implement the service is rejected.
  • GraphQLResolver.ordered(method, { variables, path, typename? }) and findById(method, { key, variables, path, typename? }) build a RequestResolver over a client method, matching results to keys by position (nodes(ids:)) or by key (searches, collections); GraphQLResolver.request(resolver) gives (key) => Effect. path is checked one key at a time, typename narrows a __typename union, and a null or missing entry fails with NoSuchElementError. Under ordered, a GraphQL error at [...path, i] fails only entry i; other GraphQL errors fail the batch, and so does pageInfo.hasNextPage: true next to the list. Delay and batch size are composed with .pipe.
  • Transports in GraphQLProtocol. The service's execute and subscribe answer { body, status? } rather than a bare body, so a scripted transport returns { body: { data } }. The client decodes body and copies status onto ExecutionResult, where middleware can read or rewrite it. GraphQLRequest carries the operation kind and urlParams alongside headers.
    • layerHttp({ url, queryMethod? }): POST for queries and mutations by default. queryMethod: "GET" sends queries as GET with query, operationName, variables and extensions as URL params, while mutations stay POST. Subscriptions use graphql-sse distinct mode on the same URL. Per-call or middleware urlParams are appended to every HTTP request.
    • layerWebSocket({ url, connectionParams?, headers?, keepAlive?, idleTimeout? }): one lazy, multiplexed graphql-ws socket with ack gating, client ping and close-code classification.
    • layerHttp({ url, subscriptions: { webSocket } }): POST plus graphql-ws subscriptions.
  • GraphQLClientError { operation, reason }, with ResponseError, TransportError, EncodeError, DecodeError and PaginationError reasons. All are Schema.TaggedErrors with isRetryable, and retryAfter is read from Retry-After.
  • GraphQL.otherTypename and GraphQL.enumLiterals, the lenient decoding helpers generated code uses for __typename unions and result enums.

@effect/graphql-generator

  • graphqlgen [--config <path>] [--watch | --check], built on effect/cli, loads graphql.config.ts (defineConfig from @effect/graphql-generator/Config, using graphql-config's schema / documents keys). The config is loaded with a plain import(), so it needs Bun, Deno or Node >= 22.18.
  • The schema can be SDL or introspection JSON. Documents are globs. Scalars map to "<module>#<export>" Schema codecs, and unmapped custom scalars decode as Schema.Json with one warning.
  • foo.graphql generates foo.graphql.ts next to it: one operation value per definition, a Variables / Result type namespace per operation, a Schema per fragment and a group per file. One shared module holds the scalars, enums, input objects and __typename unions the operations reach.
  • Interfaces and unions decode as __typename unions with a lenient catch-all member. Also covered: @skip / @include (optional keys), @oneOf inputs, subscriptions, descriptions and @deprecated as JSDoc.
  • Writes are all-or-nothing, with located diagnostics on stderr. Exit codes: 0 for success (including warnings), 1 for generation or file I/O errors and stale --check output, 2 for config errors or invalid CLI usage (including unknown flags and --watch --check). Stale generated files are deleted, but only when they carry the generated header.
  • --watch debounces changes, caches parsed inputs, writes only changed files, keeps the last good output on error and re-imports the config when it changes.
  • Generator.generate(config, { cwd }) is the programmatic core. It never writes to disk or stdout.
  • The acceptance fixture is GitHub's public schema, vendored as SDL and introspection JSON. Generator snapshots are committed as real .graphql.ts files that pnpm check typechecks, and a runtime test runs them against a mock HttpClient.

Docs

  • packages/tools/graphql-generator/README.md covers install, runtime requirement, config, scalars, the lint/format exclusion for *.graphql.ts, CLI flags and exit codes, watch mode, paging with $after, partial results and batching.
  • ai-docs/src/52_graphql/ has a getting-started example (an inline query shaped like generated output, an HTTP client service with auth middleware, a call with a note on partial results, and cursor paging), a testing example that replaces the client service with a bound GitHub.layerTest helper, and a batching example with GraphQLResolver.ordered. They have no generated fixtures or generator dependency. LLMS.md is regenerated. Subscriptions and error handling are covered in the effect/graphql API reference.

Other changes

  • @effect/graphql-generator is added to the changesets fixed version group, the tsconfig.tests.json paths, the vitest projects and the root README catalog.
  • Generator test fixtures (*.graphql.ts) are excluded from oxlint and dprint.

The Socket handshake-headers change that graphql-ws needs shipped separately on main.

@changeset-bot

changeset-bot Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bad942e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 33 packages
Name Type
effect Patch
@effect/graphql-generator Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-cloudflare Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/ai-typesafe Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-janitor effect-janitor Bot added the 4.0 label Oct 10, 2026

@tim-smart tim-smart left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stage-1 verdict: changes requested. The existing runtime suite passes (65 tests), and type tests pass on TS 5.9.3 and 6.0.3 (33 assertions per target), but the paging cursor constraint and null-data handling have uncovered contract gaps. The public make implementation also retains the cast/any pattern explicitly excluded by EFF-1913 and the EFF-1830 rough-draft verdict.

SSE, WebSocket, and the HTTP subscriptions option are intentionally deferred to stage 8; the HTTP subscribe stub is not a finding. No production code or tests were modified. This is a COMMENT review because the authenticated GitHub account is also the PR author and cannot submit REQUEST_CHANGES on its own PR.

Comment thread packages/effect/src/graphql/GraphQLClient.ts
Comment thread packages/effect/src/graphql/GraphQLClient.ts Outdated
Comment thread packages/effect/src/graphql/GraphQLClient.ts
tim-smart added a commit that referenced this pull request Oct 10, 2026
- pages / items reject a required or non-string $after variable and
  accept an optional string-only one
- data: null without errors is a DecodeError even when the result codec
  accepts null, on both the strict and the partial path

These fail against a9ff888 on purpose; the fixes land in a separate
implementation run.

@tim-smart tim-smart left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed stage 1 at 6f13daa. The three previous findings are addressed; I found no remaining production correctness blocker in this follow-up.

  • Paging now requires an optional after variable accepting string. I accept the Architect's clarification that after?: string is also valid: the helper omits it initially and never sends null. Required and non-string cursors are rejected.
  • Null or missing data without errors now produces DecodeError before either result decoder runs, including codecs accepting null.
  • The client construction now carries operation-specific types through the runtime and method implementations. The remaining mapped-record, middleware-next and paging-overload boundaries are confined and documented. I no longer consider the make assertion a blocker; it is not defining the public API from an untyped implementation.

The retryableOnly change is correct on inspection. It rejects non-GraphQLClientError and non-retryable inputs before calling the schedule step. Cause.done is handled by Channel.retry by re-failing with the original stream error, not by completing successfully or replacing it with the schedule's terminal value.

One non-blocking test follow-up remains in packages/effect/test/graphql/GraphQLClient.subscriptions.test.ts: add a focused middleware subscribe failure regression. Assert the exact middleware error is preserved and there is only one subscription attempt. Cover the default schedule with a null error (which would expose the old retryAfter property-access defect), and a custom schedule with a step callback that must not execute (which pins the early guard rather than merely eventual non-retryability). This is worth a separate test-only run because none of the current nine subscription tests exercises a middleware error.

Validation rerun: 66 runtime tests pass; 39 type assertions per target pass on TS 5.9.3 and 6.0.3 (78 total); pnpm check passes. No source or tests changed. SSE/WebSocket transport work remains deferred to stage 8.

API correctness gate: pass. Recommend the focused retry regression before closing out stage-1 validation. This is a COMMENT review because the authenticated account is also the PR author.

@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 38.84 KB 38.84 KB 0.00 KB (0.00%)
basic.ts 7.07 KB 7.07 KB 0.00 KB (0.00%)
batching.ts 10.68 KB 10.68 KB 0.00 KB (0.00%)
brand.ts 6.73 KB 6.73 KB 0.00 KB (0.00%)
cache.ts 10.92 KB 10.92 KB 0.00 KB (0.00%)
config.ts 22.32 KB 22.32 KB 0.00 KB (0.00%)
differ.ts 21.35 KB 21.35 KB 0.00 KB (0.00%)
http-client.ts 22.79 KB 22.79 KB 0.00 KB (0.00%)
http-router.ts 34.35 KB 34.35 KB 0.00 KB (0.00%)
logger.ts 11.25 KB 11.25 KB 0.00 KB (0.00%)
metric.ts 9.04 KB 9.04 KB 0.00 KB (0.00%)
optic.ts 6.96 KB 6.96 KB 0.00 KB (0.00%)
pubsub.ts 15.24 KB 15.24 KB 0.00 KB (0.00%)
queue.ts 12.16 KB 12.16 KB 0.00 KB (0.00%)
schedule.ts 11.27 KB 11.27 KB 0.00 KB (0.00%)
schema-bigdecimal.ts 13.78 KB 13.78 KB 0.00 KB (0.00%)
schema-binary.ts 40.03 KB 40.03 KB 0.00 KB (0.00%)
schema-class.ts 21.11 KB 21.11 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 32.16 KB 32.16 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 27.33 KB 27.33 KB 0.00 KB (0.00%)
schema-string-transformation.ts 14.54 KB 14.54 KB 0.00 KB (0.00%)
schema-string.ts 12.04 KB 12.04 KB 0.00 KB (0.00%)
schema-template-literal.ts 16.11 KB 16.11 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 38.37 KB 38.37 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 25.36 KB 25.36 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 20.28 KB 20.28 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 20.49 KB 20.49 KB 0.00 KB (0.00%)
schema-toFormatter.ts 20.59 KB 20.59 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 25.06 KB 25.06 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 20.57 KB 20.57 KB 0.00 KB (0.00%)
schema.ts 20.33 KB 20.33 KB 0.00 KB (0.00%)
stm.ts 13.29 KB 13.29 KB 0.00 KB (0.00%)
stream.ts 10.00 KB 10.00 KB 0.00 KB (0.00%)

tim-smart added a commit that referenced this pull request Oct 10, 2026
…ng, comment characters

Tests-only run after the stage-2 review of PR #8979.

- Descriptions on operations, fragments and variable definitions, which
  the September 2025 edition (the current published edition EFF-1829
  names) adds to the executable grammar: conformance cases, AST shape
  expectations, the shorthand/field/end-of-input diagnostics, and the
  extension diagnostic reworded as graphql-js 16.12+ words it. The
  compact printer drops these descriptions, so print/parse round trips
  are compared without them.
- Compact printing settled as token-minimal: a single space only where
  two adjacent non-punctuator tokens would otherwise merge. Three value
  literals lose the separator after `]` or `}`.
- Comments and other positions must reject unpaired surrogates, which
  are not source characters under the "any Unicode scalar value" rule;
  astral and control characters in comments stay valid.

Production code is untouched; the new expectations fail until the
follow-up implementation run lands.
tim-smart added a commit that referenced this pull request Oct 10, 2026
…r, attribution

Follow-up to the stage-2 review of PR #8979; production code only.

- Descriptions on operations, fragments and variable definitions, as the
  September 2025 edition allows. The AST nodes gain `description`, the
  parser reads it, the shorthand rejects it with graphql-js's wording,
  and the extension diagnostic uses the 16.12+ wording. The printer
  drops descriptions from the compact document.
- Comments are scanned by Unicode scalar value, so an unpaired
  surrogate in a comment is reported like one anywhere else.
- The printer is token-minimal: arguments, list values and object
  fields no longer get a separator when punctuation already separates
  them.
- Lexer.ts and Parser.ts carry the graphql-js v16.14.2 MIT notice
  (Copyright (c) GraphQL Contributors), since both adapt its structure.
  Headers now name the September 2025 edition.

210 of 210 generator tests pass; pnpm lint, check and jsdocs --check
pass.
Comment thread packages/effect/src/graphql/GraphQL.ts Outdated
Comment thread packages/effect/src/graphql/GraphQLMiddleware.ts Outdated
Comment thread packages/effect/src/graphql/GraphQLProtocol.ts Outdated
Comment thread packages/effect/src/graphql/GraphQLProtocol.ts Outdated
tim-smart added a commit that referenced this pull request Oct 10, 2026
- pages / items reject a required or non-string $after variable and
  accept an optional string-only one
- data: null without errors is a DecodeError even when the result codec
  accepts null, on both the strict and the partial path

These fail against a9ff888 on purpose; the fixes land in a separate
implementation run.
tim-smart added a commit that referenced this pull request Oct 10, 2026
…ng, comment characters

Tests-only run after the stage-2 review of PR #8979.

- Descriptions on operations, fragments and variable definitions, which
  the September 2025 edition (the current published edition EFF-1829
  names) adds to the executable grammar: conformance cases, AST shape
  expectations, the shorthand/field/end-of-input diagnostics, and the
  extension diagnostic reworded as graphql-js 16.12+ words it. The
  compact printer drops these descriptions, so print/parse round trips
  are compared without them.
- Compact printing settled as token-minimal: a single space only where
  two adjacent non-punctuator tokens would otherwise merge. Three value
  literals lose the separator after `]` or `}`.
- Comments and other positions must reject unpaired surrogates, which
  are not source characters under the "any Unicode scalar value" rule;
  astral and control characters in comments stay valid.

Production code is untouched; the new expectations fail until the
follow-up implementation run lands.
tim-smart added a commit that referenced this pull request Oct 10, 2026
…r, attribution

Follow-up to the stage-2 review of PR #8979; production code only.

- Descriptions on operations, fragments and variable definitions, as the
  September 2025 edition allows. The AST nodes gain `description`, the
  parser reads it, the shorthand rejects it with graphql-js's wording,
  and the extension diagnostic uses the 16.12+ wording. The printer
  drops descriptions from the compact document.
- Comments are scanned by Unicode scalar value, so an unpaired
  surrogate in a comment is reported like one anywhere else.
- The printer is token-minimal: arguments, list values and object
  fields no longer get a separator when punctuation already separates
  them.
- Lexer.ts and Parser.ts carry the graphql-js v16.14.2 MIT notice
  (Copyright (c) GraphQL Contributors), since both adapt its structure.
  Headers now name the September 2025 edition.

210 of 210 generator tests pass; pnpm lint, check and jsdocs --check
pass.
Comment thread LLMS.md
Comment thread packages/effect/src/graphql/GraphQLProtocol.ts Outdated
Comment thread packages/effect/src/graphql/GraphQL.ts Outdated
Comment thread packages/effect/src/graphql/GraphQL.ts Outdated
Comment thread packages/effect/src/graphql/GraphQL.ts Outdated
Comment thread ai-docs/src/52_graphql/index.md Outdated
@Tarekkharsa

Tarekkharsa commented Oct 11, 2026 •

Copy link
Copy Markdown

We tried the pkg.pr.new build at 091d1a1 against a production CMS GraphQL API that we call today from an Effect v4 server through gql.tada and our own Effect wrapper.

Setup

  • Today: effect 4.0.0, gql.tada 1.11.3, graphql 17.0.2, Node 24.14.1.
  • TypeScript: we type-check with the native tsc 7.0.2. gql.tada still needs the classic TS API (its CLI and the graphqlsp plugin), so we keep typescript aliased to @typescript/typescript6@6.0.2 next to TS 7. Native TS 7 support in gql.tada was closed as not planned (@gql.tada/internal fails with TypeScript native-preview: No exports main defined 0no-co/gql.tada#519). This split is a main reason we want a generator that emits plain TypeScript.
  • Spike: effect, @effect/graphql-generator and @effect/platform-node from pkg.pr.new at 091d1a1.
  • Input: our real schema (SDL, about 30k lines), 61 operations and 56 fragments from 117 documents.

Results

  • Generation: exit 0, 0 errors, 0 warnings, 0.24 s (median of 3). Output: 63 files, 5,769 lines, 287 KB. Our gql.tada output (graphql-env.d.ts plus the turbo cache) is 2.95 MB.
  • Type check of all generated code, plus a call site (GraphQLClient.make over 3 merged groups, one partial: true call): 0 errors and no TS2589 on TS 7.0.2 (check time 0.27 s) and on TS 5.9. The review notes say the type tests run on 5.9.3 and 6.0.3, so this may be a useful TS 7 data point.
  • Runtime decode of our 61 test fixtures: 51 decode. The 10 failures were real problems in our fixtures (8 missing __typename, which the generator now selects on abstract types; 2 timestamps outside the 32-bit Int range). Our current client detects none of them, because it casts data.
  • Decode cost: 0.03 to 0.06 ms for 4 to 8 KB responses, about 1.5x a JSON round trip.

Gaps we hit

  1. ResponseError has no HTTP status. A 401/403 with a GraphQL errors body and a 200 with errors look the same to the caller. We treat 401/403 as auth verdicts that are never retried or cached, so we need the status. Could ResponseError carry status (and maybe the headers)?
  2. layerHttp sends only POST (postRequest). For CDN caching of public reads we need GET for queries (GraphQL over HTTP allows it), or a hook to build the request.
  3. Per-call URL params: our CMS takes a preview token as a query param. Today this works only with a header (from middleware or the { headers } option) plus an HttpClient.mapRequest under layerHttp that moves it into the URL. Could GraphQLRequest carry URL params, or could middleware reach the HttpClientRequest?
  4. Batching docs: the README points to GraphQLProtocol.execute for hand-written aliased documents, and that skips middleware. A GraphQL.query(name, { document, result: Schema.Json }) built at runtime and put in a group keeps the middleware. It worked for us, and it may be worth documenting.
  5. Testing: a GraphQLProtocol layer that returns fixtures by operationName worked well, and the fixtures still go through the real decode. A small GraphQLProtocol.layerTest(handlers) helper could make this the default.

We are happy to open PRs for 1 and 2 if the direction fits.

Runnable reproductions for each gap, with an invented schema and TS 7.0.2 / 6.0 side by side: https://github.com/Tarekkharsa/effect-graphql-feedback

@tim-smart

Copy link
Copy Markdown
Contributor Author

We tried the pkg.pr.new build at 091d1a1 against a production CMS GraphQL API that we call today from an Effect v4 server through gql.tada and our own Effect wrapper.

Thanks for testing it out! The gaps should be addressed now.

@Tarekkharsa

Copy link
Copy Markdown

Thanks, that was fast! I re-ran the probes against 7ee8dca: https://github.com/Tarekkharsa/effect-graphql-feedback

  • ResponseError.status: a 401 and a 200 with an errors body now give status: 401 and status: 200.
  • queryMethod: "GET": the query goes out as GET with query, operationName and variables as URL params. POST stays the default.
  • urlParams: both the per-call option and middleware put the param on the URL, with no HttpClient.mapRequest workaround.
  • Batching docs: the runtime GraphQL.query(..., { result: Schema.Json }) pattern keeps middleware.

Generation, --check and type checks on TS 7.0.2 and 6.0 still pass with no changes to the generated output.

One note: GraphQLProtocol.execute now returns { body, status }, so a hand-written fixture protocol for tests had to change. That is a small argument for the test helper from point 5 (a GraphQLProtocol layer that serves fixtures by operation name), so tests do not depend on that shape. I'm happy to open a PR for it if you want it.

Comment thread ai-docs/src/52_graphql/30_batching.ts Outdated
Comment thread ai-docs/src/52_graphql/30_batching.ts Outdated
Comment thread ai-docs/src/52_graphql/30_batching.ts Outdated
Comment thread ai-docs/src/52_graphql/30_batching.ts Outdated
Comment thread ai-docs/src/52_graphql/10_basics.ts Outdated
Comment thread .changeset/graphql-client.md Outdated

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants