Contributor-facing material for anyone extending the dev-loop surface, the federation wrap, or the native-runtime packaging. For the consumer-facing inner loop (how to run it, connect an editor, and connect an agent), see How-to: The dev loop and How-to: Agent context over MCP.
Dev loop: how the goal is wired internally
The dev goal runs five cooperating components in one JVM:
-
LSP server binds the TCP port (default
8487) and speaks LSP to whatever editor or agent connects. It serves diagnostics, hover, completion, go-to-definition, and find-references off the most recent classifiedGraphitronSchemaand the validator’s report on it. State is in-memory only; no LSP cache lives on disk. ItsdidSavenotification is the primary fast path into the generator dispatch when an editor is attached. -
MCP server binds a second loopback port (
8488) and speaks the Model Context Protocol over Streamable HTTP to an MCP-aware agent. It serves the handshakeinstructionsstring, anaboutprompt, adirectivesresource, and a set of read-only tools that answer from the session’s fact store: catalog discovery (catalog.tables,catalog.describe, and the semanticcatalog.search),schema,codefor the@service/@condition/@recordbindings,diagnosticsanddiagnostics.aggregate,status, anddocs.searchover the bundled manual. What the dev loop hands it is the session’s store handle and reader, so it reads what the latest pass captured and never feeds the generator dispatch; the connection is read-only. It lives in its owngraphitron-mcpmodule so its heavy native (semantic-index) dependencies stay off the plugin’s own compile surface, and that module compiles against the store’s schema and jOOQ alone. Hosted in embedded Jetty; the literal8488is pinned by aDevMojoTestassertion onDevMojo.DEFAULT_MCP_PORT. -
Schema watcher is a
WatchServiceover the consumer’s.graphqlssource roots; the headless fallback when no editor is attached. On a save, it debounces same-file events that arrive in clusters (some editors emit severalMODIFYevents per save) and signals the generator dispatch. The LSPdidSavepath feeds the same debounce, so the two routes coalesce on a single regen when both fire. -
Classpath watcher is a
WatchServiceover the consumer’s compiled jOOQ output (target/classes/<jooqPackage>/). Whenmvn compilein another terminal lands new.classfiles for jOOQ tables and columns, the watcher signals the generator dispatch the same way a schema save does. This is what lets a jOOQ schema regen pick up automatically without adev-session restart. -
Generator dispatch is the lifecycle thread that consumes wake-up events from either watcher, loads the (possibly-updated) classpath, runs the generator over the (possibly-updated)
.graphqlssources, and feeds the resultingGraphitronSchemaand emit results to the LSP server’s in-memory state and to disk via the idempotent writer.
The session mints four store readers, not one, and each states a ReadBudget the compiler forces it to state. Three go to the LSP behind one StoreAccess: an interactive one for the cursor grain (answering, every surface an editor blocks a cursor on), an annotation one for the surfaces that answer about a region of a document rather than a coordinate in it (annotating, which is inlay hints), and a session-wide one with a larger budget for the whole-workspace diagnostics drain and the directive-vocabulary load (answeringAll and readingSessionGraph). The fourth goes to the MCP server at turn scale.
The split is by who is waiting, not by what budget they wait under: a reader serializes its reads, so sharing one queues a hover behind whatever is already running for no better reason than sharing a connection. The annotation reader is the case that makes the distinction concrete, since it carries the same budget as the interactive one and is a separate reader anyway. Its work scales with the region an editor happens to be showing rather than with the cursor, an editor reissues it on every scroll, and nobody is looking at a hint the way they are looking at a jump; over a large schema with every hint axis enabled such a request can spend the whole interactive budget and be aborted, and before it had a reader of its own every hover and jump queued behind it waited that out. So when adding a surface, pick the door by asking who is blocked on the answer, and read StoreAccess’s class javadoc for the reasoning in full. A statement that overruns its budget is aborted by the database and arrives as a `StoreAnswer.OutOfBudget arm, which each surface answers in an exhaustive switch: interactive surfaces keep what they were showing, the drain publishes nothing at all rather than an empty list that would clear the developer’s warnings, the vocabulary keeps its last good value, and an MCP tool fails the call rather than returning an empty result an agent would read as absence. The budgets bound a statement, not a request; where a request’s cost matters the bound is the budget times the statement count the *StatementCountTest tier pins.
One JVM, two loopback ports (LSP and MCP), one process tree. There’s no daemon, no client/server split, no shared cache directory. The Mojo binds, watches, regenerates, and serves; on Ctrl+C the JVM shutdown hook closes the LSP socket, the MCP server, the WatchService instances, and the debounce executor cleanly.
The idempotent-write coupling is what makes the loop usable as an editor backend. Because JavaFile.writeToPath writes only files whose rendered content actually changed (SHA-256 comparison) and deletes orphans in rewrite-owned sub-packages, a schema edit that touches one type rewrites that type’s files and leaves every other generated file byte-identical on disk. The IDE’s incremental compiler, Quarkus quarkus:dev, and Spring Boot DevTools all detect changes by mtime; unchanged files keep their mtimes, so only the actually-changed files trigger an IDE recompile. Two effects fall out: editor latency is proportional to the edit (not to the schema size), and git diff after a dev session shows only what a human-readable summary of the schema edit would predict.
flowchart TD
subgraph JVM["dev JVM (one process)"]
Editor["editor / agent"] -. LSP/TCP :8487 .-> LSP["LSP server"]
Editor -. MCP/HTTP :8488 .-> MCP["MCP server<br/>(read-only tools +<br/>about prompt)"]
LSP -- didSave<br/>(primary) --> Disp["generator dispatch"]
SW["schema watcher<br/>(.graphqls,<br/>headless fallback)"] -- debounced<br/>save events --> Disp
CW["classpath watcher<br/>(target/classes/<jooq>)"] -- .class change<br/>events --> Disp
Disp -- run --> Gen["generator<br/>(classify + emit)"]
Gen -- in-memory<br/>GraphitronSchema --> LSP
Gen -- emit results --> Writer["JavaFile.writeToPath<br/>(idempotent + orphan sweep)"]
Writer -- only-changed<br/>files written --> Sources["target/generated-sources/graphitron"]
end
Sources -- mtime change --> IDE["IDE recompile<br/>(IntelliJ / Quarkus / DevTools)"]
Federation: how the wrap is wired
@link is the opt-in. Graphitron.buildSchema(…) checks the parsed SDL for an @link to a federation spec; if present, it routes through federation-graphql-java-support to wrap the schema with the _Service.sdl field, the _Entity union, and the _entities resolver. If absent, the build skips the wrap entirely and emits a vanilla schema. A consumer who wants federation just adds the @link; a consumer who doesn’t gets no federation surface, no _entities, no _Service. The opt-in is the SDL declaration, nothing else.
<schemaInput tag> is the second federation entry point. It exists because @tag(name: "…") directives are only meaningful to a federation gateway, and a consumer setting tag values has implicitly committed to federation 2. The plugin synthesises an @link with import: ["@tag"] if none was declared, fails the build if @link is declared but "@tag" is missing from import, and stays out of the way if neither is set. The decision lives in the plugin so it’s visible at build configuration; the runtime never sees the synthesis logic.
Graphitron.buildSchema does the wrap, not the consumer. A second Federation.transform(…) call by the consumer would double-add _Service and _Entity and break composition; the contract is the consumer never wraps. The two-arg form takes a federation customizer (fed → fed.fetchEntities(…)) for cases where a consumer needs to override fetchEntities for hand-rolled entity types.
The fetchEntities seam lives where it does because the default fetcher only knows about Graphitron-classified types: @node types resolve via the NodeId path, types with a @key directive resolve via column-value lookup, and both share the same per-type batched SELECT. Anything outside that classification surface (hand-rolled objects, types from a non-Graphitron source) needs a custom fetcher; the customizer lets the consumer plug one in without touching `buildSchema’s wiring.
For the consumer-facing federation transport (the @link declaration, the two-arg buildSchema customizer, the don’t-double-wrap rule), see How-to: Apollo Federation transport.
sequenceDiagram
participant SDL as .graphqls SDL
participant Build as Graphitron.buildSchema
participant Wrap as federation-graphql-java-support
participant Engine as graphql-java engine
participant Fetch as _entities resolver
participant DB as PostgreSQL
SDL->>Build: parse + classify
alt @link present
Build->>Wrap: wrap(schema, fetchEntities)
Wrap-->>Build: federation-wrapped schema<br/>(adds _Service.sdl, _entities)
else no @link
Build-->>Engine: vanilla schema (no federation surface)
end
Build-->>Engine: ready
Engine->>Fetch: _entities([{__typename, key…}, …])
alt @node type
Fetch->>DB: SELECT by NodeId-decoded keys (batched per type)
else @key type
Fetch->>DB: SELECT by key-column tuples (batched per type)
else custom fetcher (fed.fetchEntities)
Fetch->>Fetch: consumer-supplied resolver
end
DB-->>Fetch: rows
Fetch-->>Engine: typed results
Native runtime dependency
There isn’t one. mvn graphitron:dev runs an LSP server backed by a
tree-sitter-based GraphQL parser whose two native pieces, the
tree_sitter_graphql grammar and the libtree-sitter runtime, both ship in
the no.sikt:graphitron-tree-sitter-natives jar and are extracted at startup.
You do not install anything: no brew install, no vcpkg install, no
from-source build on Debian/Ubuntu, and no LD_LIBRARY_PATH /
JAVA_TOOL_OPTIONS wiring (NixOS included).
Supported host architectures are linux-x86_64, linux-aarch64,
macos-aarch64, and windows-x86_64. Intel-Mac (macos-x86_64) is not
shipped; on an unsupported host the LSP fails fast at startup naming the
os.name / os.arch it saw and the supported set.
If the bundled runtime fails to load after extraction (a noexec
java.io.tmpdir, a corrupt extract, or a missing system C runtime), the LSP
surfaces a single startup error naming the extracted path rather than an opaque
UnsatisfiedLinkError. See
LSP requirements for the
startup-diagnostics detail.
Tracing the LSP request path
When an editor session stops responding, LspTrace attributes the wall clock to
a named phase. It is off by default and allocates no span while off, so it sits
directly on per-keystroke paths.
Turn it on with a system property or an environment variable:
mvn graphitron:dev -Dgraphitron.lsp.trace=true
# or, when the editor spawns the server and you cannot add a flag
GRAPHITRON_LSP_TRACE=true
An editor can also flip it mid-session by sending lsp4j’s $/setTrace with
messages or verbose, which is the route when the session is already
misbehaving and you would rather not restart it and lose the state that provoked
it. Note the asymmetry: $/setTrace off does stop tracing, but the trace value
on the initialize handshake can only turn tracing on. Most clients send
trace: off there as boilerplate whether or not anyone asked, and honouring that
would silence a deliberately-set graphitron.lsp.trace before a single phase had
been traced.
Prefer the file sink when chasing a hang
Send output to a file:
mvn graphitron:dev -Dgraphitron.lsp.trace=true \
-Dgraphitron.lsp.trace.file=/tmp/lsp-trace.log
Stderr is the default and is fine for ordinary "why is this slow" work, where it
has the advantage that mainstream clients surface a language server’s stderr in
an editor output panel. For a hang, use the file. Writes are synchronous, so a
sink whose reader has stopped draining blocks the thread that is emitting, and
two of the instrumented sites (file.reparse and file.typeIndex) emit while
holding the workspace mutator lock. A client that spawns the server and never
reads its stderr can therefore fill the pipe buffer and stall the server inside
its critical section, which is the instrument manufacturing the symptom you are
trying to attribute. A file never blocks that way.
The synchronous write is deliberate rather than an oversight: what reaches the
sink is what happened right up to a kill, and an asynchronous drain would lose
the tail exactly when the tail is the evidence.
Output never goes to stdout under any configuration, and never through slf4j: the
stdio launcher speaks JSON-RPC on stdout, so a stray byte would desynchronise the
framing, and in that deployment the classpath usually carries slf4j-api with no
backend bound so a logger-based seam would emit nothing at all. It also does not
go back over the connection via window/logMessage, which would be the
editor-visible channel: that would route the diagnosis through the very channel
under suspicion, serialised behind every other response and emitted from inside
the workspace lock.
Phases slower than graphitron.lsp.trace.slowMs (default 100) are tagged SLOW.
Reading a trace
Each phase emits an open line and a close line, so a phase that never returns
shows up as an unmatched >. That is the point: it separates "stuck here" from
"slow everywhere", which a duration-only format cannot. Every line is stamped
with the time of day, which is what lets an unmatched > be lined up against the
moment the editor froze, against the editor’s own log, or against a build swap in
another window. A one-off header line carries the date, the resolved threshold and
the pid, so a file read days later describes its own provenance.
14:32:07.301 lsp-trace header date=2026-08-03 slowMs=100 pid=48213
14:32:07.412 lsp-trace > 6 workspace.mutate thread=graphitron-dev-conn-1
14:32:07.412 lsp-trace > 7 file.reparse thread=graphitron-dev-conn-1
14:32:07.413 lsp-trace < 7 file.reparse 1.1ms thread=graphitron-dev-conn-1 bytes=23781
14:32:07.414 lsp-trace > 8 file.typeIndex thread=graphitron-dev-conn-1
14:32:07.428 lsp-trace < 8 file.typeIndex 13.8ms thread=graphitron-dev-conn-1 bytes=23781 declared=400
14:32:07.434 lsp-trace < 6 workspace.mutate 21.9ms thread=graphitron-dev-conn-1 open=1 queued=1
Three things to read off a trace:
| Signal | What it means |
|---|---|
|
Both ran on the same lsp4j thread. While a notification’s phases run, the
server is not reading the connection, so client requests queue and can time
out rather than merely being slow. The diagnostics drain is exempt by
construction: it runs on its own |
|
The recalculate listener has stopped being a flag-and-submit. It hands the
drain to the connection’s drain executor and returns, so |
|
A read request waited on the mutator lock, so something else is holding it. |
The diagnostics walk is deliberately one span for the whole directive loop rather
than one per directive, which would bury the log under hundreds of lines per file.
To get at the per-site cost, subtract diagnostics.validatorReport from its
enclosing diagnostics.compute and divide by the directives= count on the same
line.