ShapeFingerprintComputer
Computes a canonicalized structural hash (sha-256, hex) of the response shape produced by a GraphQL operation. Two documents that produce byte-compatible JSON shapes hash to the same fingerprint; documents that would change a shipped client's deserialization hash differently.
The storage layer calls this on every UpsertAsync and stores the result in shape_fingerprint. The shape-diff guardrail in the store compares old vs new on every edit of an existing id and refuses a change unless UpsertOptions.BypassShapeDiff is set.
Signature
public static string Compute(string document, string? operationName = null);| Parameter | Notes |
|---|---|
document | Full GraphQL document text. Required. |
operationName | Disambiguates when the document contains multiple operations. When null and the document has exactly one operation, that one is used. |
Throws InvalidOperationException when the document has no executable operation, when multiple operations are present without a name, or when the named operation is missing.
Algorithm
- Parse the document via
Utf8GraphQLParser. - Locate the executable operation.
- Inline fragment spreads with their type conditions. Cycles are broken by a recursion guard.
- For every field emit
(parentType, responseKey, fieldName, hasInclude, hasSkip)whereresponseKeyis the alias when present, else the field name. - Sort sibling tuples by
(parentType, responseKey, fieldName). - Recurse into nested selections, indented by depth.
- Hash the canonical string with sha-256.
What is "same shape"
| Same fingerprint | Why |
|---|---|
| Whitespace, indentation, comments | Lexed away. |
| Field order within a selection set | Canonical sort. |
| Argument value changes | Arguments do not affect response shape. |
| Argument additions / removals | Same. |
Variable type widening (Int! -> ID!) | Variables are inputs. |
| Adding a new optional variable | Old clients ignore it. |
| Default variable value changes | Same. |
| Pure-resolver-path swaps with identical projection | Same response keys. |
What is "different shape"
| Different fingerprint | Why |
|---|---|
| Adding / removing / renaming a selected field | Response keys differ. |
| Adding or changing an alias | Response key differs even if underlying field is the same. |
| Fragment-spread vs inlined fields | Conservative: fragment carries a type condition that may matter on unions/interfaces. Operators rewriting can set BypassShapeDiff if they have verified the type is concrete. |
| Inline-fragment branch added or removed (e.g. on a union) | Real shape change. |
Adding @include or @skip to a field | Field becomes conditional. |
| Mutation vs query, subscription vs query | Operation type is part of the canonical form. |
Conservatism
The fingerprint errs on the side of false positives. BypassShapeDiff (the upload mutation's bypassShapeDiff, the dashboard editor's bypass checkbox) is the escape hatch for cases where the operator has verified a flagged change is shape-safe. False negatives (silently approving a real shape change) would be the dangerous direction; the algorithm is biased away from them.
A binding test fixture lives in Trax.Api.Tests.PersistedOperations.UnitTests.ShapeFingerprintComputerTests with ~50 input pairs labeled "same shape" or "different shape", covering fragments, unions, interfaces, directives, aliases, variables, deep nesting, whitespace, and field-order canonicalization. New canonicalization rules MUST add new pairs there.