Skip to content

perf: speed up graphql commands#502

Merged
rexxars merged 29 commits into
mainfrom
perf/optimize-graphql-deploy
Mar 9, 2026
Merged

perf: speed up graphql commands#502
rexxars merged 29 commits into
mainfrom
perf/optimize-graphql-deploy

Conversation

@rexxars
Copy link
Copy Markdown
Member

@rexxars rexxars commented Mar 2, 2026

Description

The graphql deploy command currently loads studio config (via Vite), resolves workspaces, then sends serialized schema types back to the main thread — where it compiles schemas and extracts the GraphQL spec sequentially. This PR restructures the pipeline so that schema compilation and extraction happen inside the worker thread, avoiding the round-trip overhead of serializing full schema types across the thread boundary.

Performance: ~15% faster on graphql deploy (measured via wall-clock benchmarks against basic-studio and worst-case-studio fixtures). The gain comes from doing more work in the worker and sending back only the extracted GraphQL spec rather than raw schema types.

Error handling: Schema errors (both from createSchema() and from extractFromSanitySchema()) are now caught per-API instead of failing the entire deployment. If one API has schema errors, the others still get processed and all errors are reported together.

graphql undeploy: Now uses a lightweight metadata-only path (resolveGraphQLApiMetadata) that reads workspace config without compiling schemas at all — it only needs projectId/dataset/tag to identify which API to undeploy.

What to review

  • extractGraphQLAPIs.ts / extractGraphQLAPIsWorker.ts — the new worker pipeline that does schema compilation + extraction in-thread. This is the core change.
  • resolveGraphQLApisFromWorkspaces.ts — shared workspace/source resolution logic extracted from the old worker, now used by both the full-compile and metadata-only paths.
  • deploy.ts — updated to consume pre-extracted GraphQL specs instead of compiling schemas itself. Per-API error handling for schemaErrors and extractionError.
  • getGraphQLAPIs.worker.ts — now serves graphql undeploy with a lightweight metadata-only path (no schema compilation).

Testing

  • Unit tests for extractGraphQLAPIsWorker (schema extraction, per-API error handling, Symbol serialization)
  • Unit tests for resolveGraphQLApisFromWorkspaces (both resolveGraphQLApis and resolveGraphQLApiMetadata — single/multi workspace, source inheritance, validation)
  • Unit tests for SchemaError (custom labels, formatted output)
  • Deploy command tests for per-API schema errors, extraction errors, warning-only errors, multi-API flag overrides
  • Integration test (graphqlAPIs.integration.test.ts) that runs the full extraction pipeline against real schema fixtures

@rexxars rexxars requested a review from a team as a code owner March 2, 2026 01:40
@rexxars rexxars requested review from laurenashpole and removed request for a team March 2, 2026 01:40
@rexxars rexxars marked this pull request as draft March 2, 2026 01:40
@rexxars rexxars removed the request for review from laurenashpole March 2, 2026 01:41
@claude
Copy link
Copy Markdown

claude Bot commented Mar 2, 2026

Claude finished @rexxars's task in 2m 54s —— View job


  • Gather context and read changed files
  • Check if previous flagged issues are resolved
  • Analyze new changes for issues
  • Post review findings

Review

Issue 1 (empty string projectId/dataset validation) — Fixed
The latest commit (af2ee4b) now correctly rejects empty strings in getGraphQLAPIs.worker.ts:55-59.


Issue 2 — resolveLocalPackage and default createSchema outside any try/catch

extractGraphQLAPIs.ts:122-125 — both the package resolution and the empty-schema call are still uncovered:

const {createSchema} = await deps.resolveLocalPackage<typeof import('sanity')>('sanity', workDir)
const defaultSchema = createSchema({name: 'default', types: []})

If resolveLocalPackage throws (version mismatch, missing build), the worker thread dies with a raw uncaught error rather than a structured message. The per-API try/catch loop on line 144 does not protect this setup path.

Fix this →


Issue 3 — Multiple unnamed unstable_sources silently collapsed to 'default'

getGraphQLAPIs.worker.ts:105 — unchanged from previous review. Two unstable_sources entries without explicit name fields both receive name: 'default'. resolveWorkspaceAndSource uses .find() so the second entry is silently dropped, producing incorrect undeploy behavior with no error.

Either detect and throw on duplicate names in extractSourceMetadata, or document that unnamed sources are unsupported for the metadata-only path:

// After building sources[], before return:
const names = sources.map((s) => s.name)
const hasDuplicates = names.length !== new Set(names).size
if (hasDuplicates) {
  throw new Error('Multiple unstable_sources without unique names are not supported for graphql undeploy')
}

Fix this →

@rexxars rexxars force-pushed the perf/optimize-graphql-deploy branch from d5ff00c to b847785 Compare March 2, 2026 02:25
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 2, 2026

Coverage Delta

File Statements
packages/@sanity/cli/src/actions/graphql/SchemaError.ts 100.0% (+ 100.0%)
packages/@sanity/cli/src/actions/graphql/extractFromSanitySchema.ts 87.0% (- 0.4%)
packages/@sanity/cli/src/actions/graphql/extractGraphQLAPIs.ts 98.0% (new)
packages/@sanity/cli/src/actions/graphql/extractGraphQLAPIs.worker.ts 0.0% (new)
packages/@sanity/cli/src/actions/graphql/gen1/index.ts 100.0% (±0%)
packages/@sanity/cli/src/actions/graphql/gen2/index.ts 100.0% (±0%)
packages/@sanity/cli/src/actions/graphql/gen3/generateTypeQueries.ts 95.8% (±0%)
packages/@sanity/cli/src/actions/graphql/gen3/index.ts 90.9% (- 0.8%)
packages/@sanity/cli/src/actions/graphql/getGraphQLAPIs.ts 100.0% (+ 6.3%)
packages/@sanity/cli/src/actions/graphql/getGraphQLAPIs.worker.ts 0.0% (±0%)
packages/@sanity/cli/src/actions/graphql/helpers.ts 100.0% (±0%)
packages/@sanity/cli/src/actions/graphql/resolveGraphQLApisFromWorkspaces.ts 97.6% (new)
packages/@sanity/cli/src/actions/schema/validateSchema.worker.ts 0.0% (±0%)
packages/@sanity/cli/src/commands/graphql/deploy.ts 91.6% (+ 6.9%)
packages/@sanity/cli/src/commands/graphql/undeploy.ts 100.0% (±0%)
packages/@sanity/cli/src/services/getUrlHeaders.ts 100.0% (±0%)
packages/@sanity/cli/src/util/isSchemaError.ts 100.0% (new)

Comparing 17 changed files against main @ 1f551d2ea701f15feff71e8af72739755179ba03

Overall Coverage

Metric Coverage
Statements 80.3% (+ 0.6%)
Branches 67.7% (+ 0.9%)
Functions 78.2% (+ 1.0%)
Lines 80.7% (+ 0.6%)

@rexxars rexxars force-pushed the perf/optimize-graphql-deploy branch from 804b55a to a698c65 Compare March 2, 2026 20:52
@rexxars
Copy link
Copy Markdown
Member Author

rexxars commented Mar 3, 2026

Claude is going to keep finding issue no matter how far I take this but I'm gonna call this readyish now. Will do a read-through and mark it ready.

@rexxars rexxars marked this pull request as ready for review March 3, 2026 18:40
@rexxars rexxars force-pushed the perf/optimize-graphql-deploy branch from d4fc584 to e757cb3 Compare March 3, 2026 18:48
@claude
Copy link
Copy Markdown

claude Bot commented Mar 3, 2026

Claude finished @rexxars's task in 4m 41s —— View job


Review

  • Gather context
  • Read changed files
  • Analyze for issues
  • Post review findings

The three issues flagged in the previous review remain unaddressed after the refactor. Nothing new introduced in the latest commits.

1. Empty string still passes projectId/dataset validation

getGraphQLAPIs.worker.ts:55-62toWorkspaceMetadata rejects non-string values but accepts ''. An empty projectId/dataset from defineConfig({projectId: ''}) passes the worker guard and only fails later in resolveGraphQLApiMetadata with a less precise message. The downstream check added in e228a1f (!ws.projectId) catches it, but the guard in the worker should match:

// Current — allows empty string
if (!('projectId' in config) || typeof config.projectId !== 'string') {

// Fix
if (!('projectId' in config) || !config.projectId) {

Same for dataset at lines 59-62.


2. createSchema called outside per-API try/catch

extractGraphQLAPIs.ts:148-151createSchema is called before the per-API loop. If resolveLocalPackage returns something unexpected (version mismatch, malformed build), this throws a TypeError that propagates uncaught out of extractGraphQLAPIsWorker, crashing the worker thread. The per-API error handling (lines 189-223) does not protect this path.

const {createSchema} = await deps.resolveLocalPackage<typeof import('sanity')>('sanity', workDir)
// Add before line 151:
if (typeof createSchema !== 'function') {
  throw new TypeError('Expected `createSchema` from `sanity` to be a function')
}
const defaultSchema = createSchema({name: 'default', types: []})

3. Multiple unnamed unstable_sources silently collapsed to 'default'

getGraphQLAPIs.worker.ts:101-102 — if a user configures two unstable_sources without name fields, both get assigned name: 'default'. resolveWorkspaceAndSource uses .find(), so the second entry is silently ignored, producing incorrect undeploy behavior with no error. Either detect and reject duplicates, or document that unnamed sources are unsupported in this path:

const sourceName =
  'name' in source && typeof source.name === 'string' ? source.name : 'default'
// Two sources without names → both 'default' → second silently dropped

@rexxars rexxars requested review from binoy14 and mttdnt March 4, 2026 00:21
@rexxars rexxars force-pushed the perf/optimize-graphql-deploy branch from 219913c to adc8006 Compare March 5, 2026 23:02
binoy14
binoy14 previously approved these changes Mar 6, 2026
const mockStudioWorkerTask = vi.hoisted(() => vi.fn())

vi.mock('node:worker_threads', () => ({
isMainThread: true,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ah smart, good way to run worker tests 🤔

rexxars and others added 13 commits March 9, 2026 15:02
The getGraphQLAPIs worker (used by `graphql undeploy --api`) called
getStudioWorkspaces() which runs resolveConfig() — compiling all
schema types only to immediately discard them. Replace with a direct
doImport() of the raw config to extract only workspace metadata
(projectId, dataset, name), skipping resolveConfig() entirely.

The worker still runs inside studioWorkerTask (Vite environment) for
safe TS/browser handling — only the unnecessary schema compilation
is removed.

Remove SchemaError handling from getGraphQLAPIs and undeploy since
schema errors cannot occur without schema compilation.
- Include original error message in non-SchemaError failures instead of
  the generic "Failed to resolve GraphQL APIs"
- Re-throw when schema validation only contains warnings so the error
  isn't silently swallowed (previously exited with no APIs and no message)
- Add tests for isSchemaError, warning-only schema errors, and error
  message preservation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cover the previously untested resolveGraphQLApiMetadata and
resolveGraphQLApiMetadataFromConfig paths (lines 84-153), including
single workspace fallback, multi-workspace config resolution, workspace
not found, missing workspace name, empty config, dataset/projectId
override vs fallback, and optional field preservation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Report all per-API errors instead of exiting on the first failure
- Remove duplicate isStudioConfig from getGraphQLAPIs worker, import from cli-core
- Add debug logging for non-SchemaError extraction failures
- Add projectId/dataset assertion in resolveGraphQLApiMetadata
- Add tests for per-API error branches and multi-API flag overrides

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Filter individual problems within configErrors groups to only include
  error-severity ones, preventing warning-level noise in output
- Add source support to the metadata path so resolveGraphQLApiMetadata
  checks both workspace and source count, and resolves the `source`
  property from GraphQL API configs (matching the deploy path behavior)
- Parallelize independent getCliConfig/findStudioConfigPath calls with
  Promise.all in both orchestrators
- Remove process.chdir from integration test since functions accept cwd

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
resolveGraphQLAPIsFromConfig unconditionally overwrote apiDef.dataset
and apiDef.projectId with source values, ignoring explicit overrides
in the CLI graphql config. The metadata path already handled this
correctly with nullish coalescing. Align the deploy path to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…orker

- Remove unused `api` property from extractGraphQLAPIs worker payload
  and WorkerData interface (only `graphql` config is read)
- Fix source metadata extraction to inherit projectId/dataset from the
  parent workspace when sources omit them (matching resolveConfig
  behavior), instead of silently skipping those sources
- Remove dead typeof string guard in deploy.ts — already narrowed by
  the outer `if (apiDef.id)` check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…d deploys

- Add missing %O format specifier for error arg in worker debug log
- Fix --force --dry-run path: don't call spin.succeed() over the
  spin.warn() state already set by isResultValid(), and show the
  correct message with breaking changes instead of "no breaking changes"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When --api is specified, undeploy used process.cwd() to resolve the
studio config, which fails if run from a subdirectory. Use
this.getProjectRoot() to match deploy.ts behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
rexxars and others added 16 commits March 9, 2026 15:03
These optional properties allow explicit overrides in the graphql config,
taking precedence over the workspace/source configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1. deploy.ts: add debug() call inside SchemaError branch so schema
   validation failures appear in debug traces

2. resolveGraphQLApisFromWorkspaces.ts: extract shared workspace/source
   resolution into resolveWorkspaceAndSource() helper — eliminates ~50
   lines of duplicated logic between the metadata and full-compile paths

3. isSchemaError.ts: add Array.isArray check on _validation to prevent
   .map() from throwing if _validation is a non-array truthy value

4. getGraphQLAPIs.worker.ts: document the invariant that the workspace
   name default ('default') must match resolveConfig()'s convention

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GraphQLAPIConfig is the public interface for sanity.cli.ts graphql
config. Dataset and projectId are resolved from the workspace/source
configuration, not user-specified overrides. Use source values directly
in the resolution functions instead of ?? fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- deploy.ts: print API name before per-API schema errors so multi-API
  debugging shows which API (dataset/tag) failed
- extractGraphQLAPIs.worker.ts: handle Sanity's internal SchemaError
  (from createSchema()) in the per-API catch block via isSchemaError(),
  preserving structured problemGroups instead of degrading to a plain
  extractionError string
- resolveGraphQLApisFromWorkspaces.ts: fix lodash-es barrel import lint
  error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1. deploy.ts: use schemaErrors?.length instead of truthy check to
   avoid treating an empty array as an error

2. extractGraphQLAPIs.worker.ts: guard against empty _validation on
   Sanity's internal SchemaError — falls through to extractionError
   with the error message instead of storing an empty schemaErrors array

3. getGraphQLAPIs.test.ts: add test for !isMainThread guard

4. isSchemaError.ts: narrow return type to assert _validation is a
   SchemaValidationProblemGroup[] array (intersected with Schema so
   validateSchema.worker.ts can still pass err.schema as Schema)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- extractGraphQLAPIs.worker.ts: only push schemaErrors when there are
  error-severity groups, matching the global path which re-throws
  warning-only errors. Previously, warning-only groups were stored as
  schemaErrors and blocked deployment with exit code 1.

- deploy-errors.test.ts: replace overly broad regex with specific
  assertion for the SchemaError path. An invalid type reference triggers
  validation during resolveConfig(), not per-API extraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The invalid schema test causes a stack overflow in CI (not a SchemaError),
so the assertion must accept both error paths. Wrap process.chdir in
try/finally to prevent cascade failures to subsequent tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the unsafe process.chdir(testCwd) / process.chdir(cwd) pattern
with testCommand's mocks.projectRoot option. The entire extractGraphQLAPIs
chain is parameter-driven (uses workDir, not process.cwd()), so mocking
getProjectRoot() is sufficient. This eliminates shared global state
mutation and the need for try/finally cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e-level config

SchemaError.print() now accepts an optional label so deploy.ts can pass
the API-specific context directly, avoiding two consecutive warning lines.

Also guard empty-string projectId/dataset on sources in both
extractSourceMetadata (falls back to workspace defaults) and
resolveGraphQLApiMetadata (throws a descriptive error).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@rexxars rexxars force-pushed the perf/optimize-graphql-deploy branch from adc8006 to af2ee4b Compare March 9, 2026 22:10
@rexxars rexxars requested a review from binoy14 March 9, 2026 22:22
@rexxars rexxars enabled auto-merge (squash) March 9, 2026 22:26
@rexxars rexxars merged commit a52d59b into main Mar 9, 2026
33 checks passed
@rexxars rexxars deleted the perf/optimize-graphql-deploy branch March 9, 2026 22:38
This was referenced Mar 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants