ID |
|
|---|---|
Status |
Backlog |
Bucket |
architecture |
Priority |
5 |
Theme |
classification-model |
Created |
2026-08-18 |
Updated |
2026-08-18 |
The graphitron decodes read captured rows instead of the AST
GraphitronFactCapture is 1050 lines that read the SDL AST (graphql.language.Directive,
Argument, Value, AstPrinter) and write the 63-relation graphitron_ family. Beside it,
SdlFactCapture reads the same AST and writes the generic directive model into graphql_:
definitions in graphql_directive / _argument / _location, and applications with their argument
values at all five locations (graphql_type_directive + _arg, and the schema, field, argument and
enum-value pairs).
So one corpus is transcribed twice, and the second pass’s rows are a function of the first pass’s
rows. graphitron_field_reference_step decomposes a @reference path argument;
graphitron_service_arg_mapping_sigil extracts a sigil from an argument value; graphitron_table
reads a name:. Every one of them is computable from what graphql_ already holds, which makes the
family a derivation and makes this pass a derivation running in the transcription stratum.
The tree already has the right word for it. `MacroCapture’s javadoc distinguishes what "transcribes into `graphql_type_directive`" from what "decodes into `graphitron_federation_key`", in one sentence.
What this is not
Not a demand that the decoding become SQL. A stratum is decided by what a relation’s rows are computed
from, not by what computes them, so a Java-computed derivation over captured rows is stratum two.
GraphQLSelectionParser parses selection-set syntax out of a string argument and will stay a parser
in Java under any arrangement.
Not a reason to separate graphitron_ from graphql_. The 48 foreign keys between them are a
derivation’s edges to its inputs, correct and permanent.
The obstacle, and the two moves
graphql_type_directive_arg.value_sdl is one VARCHAR, "the value as written, rendered from the AST".
The transcription therefore stores argument values as printed text, which is why the decoder needs
AstPrinter and why a decode over rows would have to parse SDL value syntax.
Move one, required: the decode’s input becomes the rows. GraphitronFactCapture stops taking the
registry and takes the transcribed rows, parsing value_sdl where it needs structure. This is
mechanical and contained to one class’s input, and it brings the pass under the independence gate the
sibling nodehood item introduces.
What it does not buy, contrary to an earlier draft of this item: decoupling the graphitron_
population from the graphql_ one. Both decode the same document at the same cadence, so the
population never separates and there is no cadence to be won. The stratum label is a description of what
the family is, not a schedule. The payoff this item was reaching for lives in the join-readiness
item instead, and move one is a supporting change rather than the prize.
Move two, optional and later: structure the transcribed value. One row per value node carrying a
kind (string, int, enum, boolean, null, list, object, variable), a parent link and an ordinal, so a
list or object value is a tree of rows rather than a printed string. An AST is a tree and a faithful
transcription of a tree is rows. value_sdl stays beside it as the written spelling, which the
"two spellings of one value are two base columns" rule in fact-model.adoc already licenses, and
which the round-trip emitter reads. This is what turns the shallow decodes into views; the real
parsers stay programs either way.
Move one is worth landing without move two. Whether move two pays is a question about how many
decodes are shallow enough to become views, and that count is worth measuring before committing:
graphitron_table, graphitron_enum, graphitron_scalar_type and the binding relations look
shallow, while the reference, condition, service and order families do not.
Move three, measured: decode in a view through an H2 function
Preferring views over Java derivations is the standing preference (a view is always current), and H2
can be extended with functions, so a third option exists beside the two above: leave value_sdl as
text and decode it inside a view. Measured against H2 2.4.240 and graphql-java 25 rather than read
off documentation.
What works. CREATE ALIAS <name> DETERMINISTIC FOR '<Class>.<method>' works, and a scalar Java
function reads correctly from inside a CREATE VIEW. Alias and view both persist in a file database
and survive reopening.
The limit. H2 cannot correlate a FROM-clause table function, or UNNEST, with an outer column.
FROM arg t, steps(t.value_sdl) s, the same as a JOIN ... ON 1=1, and
FROM arg t, UNNEST(arr(t.value_sdl)) WITH ORDINALITY u all fail with Column "T.VALUE_SDL" not
found, and LATERAL is not available (Function "LATERAL" not found). A ResultSet-returning
function works standalone but cannot be driven by a table’s rows, so the natural shape for a
multi-row decode is unavailable.
The idiom that does work, verified reading, joining and aggregating: a scalar function returns an
ARRAY, cross joined with a literal-bounded SYSTEM_RANGE, filtered on CARDINALITY, elements
picked with ARRAY_GET. Two properties of it are load-bearing rather than incidental. The range
bound must be a literal, so the view needs a ceiling plus a guard query proving no row reaches it.
And ARRAY_GET past the end raises an error rather than returning null, so the CARDINALITY filter
is what keeps the view readable at all.
Cost. Over 20 000 rows: 1690 ms parsing per call (about 85 microseconds each, essentially all of
it graphql-java constructing an ANTLR parser per invocation), 81 ms with a ConcurrentHashMap memo
keyed on the input text (202 actual parses, because directive argument values repeat heavily), against
19 ms for a pure-SQL equivalent. Two further findings shape how such a view is written: H2 caches
whole query results, so an identical second read cost 0 ms and made no calls, but the full cost
returns after any write to the driving table; and DETERMINISTIC does not memoize per value, so the
same expression twice in one row costs two calls, which the idiom above incurs by naming the function
in both ARRAY_GET and CARDINALITY.
Why the function must not be graphql-java-backed. CREATE ALIAS fails loudly at DDL time when the
class is missing, so every host booting the store would need the class and its dependencies on its
classloader: the LSP, the Maven plugin, MCP, the tests, and the codegen driver, which executes this
same DDL to generate the Tables classes. graphitron-model depends on jOOQ, H2 and jooq-codegen,
not graphql-java, so this inverts a module dependency to put a large library below the schema module.
GraphitronModelStore.connect() already records that these hosts "all hand it a loader the
service-loaded driver was not registered under", so this adds a second class to a known hazard. And
the degradation is asymmetric: a fresh boot without the class fails loudly, but reopening an
existing file without it does not. H2 drops the aliases silently and the view becomes unreadable,
and store_stamp covers the DDL hash and generator version, not the classpath.
So the shape, if this move is taken: an H2 function backed by a value-literal parser this project
owns in graphitron-model. The grammar is small (string, int, float, boolean, null, enum, list,
object, variable), it needs no dependency, it memoizes trivially, and it removes both the dependency
inversion and the classloader hazard. graphql-java stays where only it can serve, the document parse
and the assembled schema. The selection-set grammar GraphQLSelectionParser handles is a different
and much larger language and stays a Java derivation regardless.
This move and move two are alternatives, not a sequence: move two structures the value at capture so no decode has to parse, move three leaves it as text and parses in a function. Move three is cheaper to try and does not change the transcription; move two is the better model if the measurement above turns out to be optimistic on a real schema’s directive population. Move one is required either way.
The census the two moves were waiting on
Move two and move three were left as alternatives pending a count of how many decodes are shallow. Counted, against the family and against the fixture corpus rather than against an invented example.
63 graphitron_ tables, no views. Written by GraphitronFactCapture through newRecord (55),
through its generic marker helper (5), and by MacroCapture (3).
| Class | Relations | What the decode needs |
|---|---|---|
presence-only marker |
5 |
nothing; a filtered projection of |
scalar or object literal, one row per application |
36 |
value-literal reading |
list literal, one row per element |
11 |
value-literal reading plus a row-expansion idiom |
sub-grammar inside a string |
11 |
one of three parsers |
The eleven that need a real parser cluster on three of them:
GraphQLSelectionParser.parseEntries serves the eight *_arg_mapping_pair / *_column_mapping_pair
relations plus graphitron_argument_path_segment; ArgMappingSigil.scan serves
graphitron_service_arg_mapping_sigil; FieldSetGrammar.paths serves
graphitron_federation_key_field.
All three parsers are dependency-free: 274, 132 and 92 lines plus a 17-line record, java.*
imports only. They can move to graphitron-model without adding a dependency, so the classloader and
module-inversion objection recorded above applies only to a graphql-java-backed function and not to
this family’s actual parsing needs. Nothing here needs graphql-java behind an alias.
The five markers need no function at all. graphitron_facet carries the coordinate and the source
position and no value columns, so it is a WHERE directive_name = 'asFacet' projection.
Frequency, which is a different question from relation count. Across the corpus’s nine
.graphqls files and roughly 4236 directive applications: 64 argMapping:, 16 columnMapping:, 42
@key(fields:). About 122 applications, near 3 per cent, trip a sub-grammar; the rest are value
literals. The distribution’s head is @field, @service, @table, @nodeId, @reference, all of
which are scalar or object literals.
What this settles. The only new code either move needs is a value-literal parser, so that parser is
the shared investment and stops being a reason to prefer one move over the other. What does separate
them is the nine object-literal call sites (codeReference for @service, @condition, @record,
@enum and @externalField; referenceElement for the three *_reference_step relations): reading a
named field out of an object literal wants the value structured at capture rather than re-parsed per
read, so those relations argue for move two while the scalar and list cases are comfortable under
move three.
Coverage already has a measurement surface. graphitron_undecoded_argument records site,
directive, argument and verbatim value_sdl for every argument the decoder declined, so the
value-literal grammar’s coverage can be measured against a real corpus instead of asserted. It holds
no rows for the sakila example.
Sequencing and blast radius
Depends on the stratum-naming item for the vocabulary it uses. Wants the nodehood item ahead of it rather than the reverse: nodehood is the one decode whose inputs span two corpora, so it is the smallest instance of the same move and proves the shape on one relation before this item does it on sixty-three.
Blast radius is the reason this is not one commit. FactCaptureAgreementTest pins every relation
against the live pipeline, so the agreement suite is the safety net and also the thing most likely to
need re-anchoring. The pass runs inside `FactCapture.capture’s transaction today, so moving it also
moves work out of that transaction, which interacts with the per-crawler transaction item.
Out of scope
-
The value-structure model, unless the measurement above says move two pays; it is called out here so the decision is recorded rather than rediscovered.
-
Retiring the three
graphitron_*_synthesisprovenance relations, which the stratum reading predicts become unnecessary once synthesis is derived. -
Which producer owns the composed
graphql_payload. The coordinate keys are the assembled schema’s own grain, and moving the payload’s source from theTypeDefinitionRegistrytoGraphQLSchemais its own item. This item benefits from it (a decode over composed rows gets directive-argument defaults already applied) but does not require it.