Turn on persisted operations
Source Trax.Api.GraphQL.PersistedOperations/
These steps take a Trax GraphQL host from accepting any query to running only documents stored under an id. What the contract is and why the host asks how changes reach each node is in Persisted operations. The Persisted Operations sample is a working host.
1. Check the prerequisites
- The documents live in the
trax.persisted_operationtable, read and written through the Trax data context, so the host needs a data provider such asAddEffects(effects => effects.UsePostgres(...)). The tables come with the Trax schema migrations. - Install the package:
dotnet add package Trax.Api.GraphQL.PersistedOperations2. Register persisted operations
Start in shadow mode: inline queries still run, and each one is logged, so you can see which clients would be refused before anyone is.
using Trax.Api.GraphQL.Extensions;
using Trax.Api.GraphQL.PersistedOperations.Extensions;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Mediator.Extensions;
builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(connectionString))
.AddMediator(typeof(Program).Assembly)
);
builder.Services.AddTraxGraphQL(graphql =>
graphql
.UsePersistedOperations(opts =>
opts.RequirePersisted(false)
.LogNonPersistedRequests(true)
.SingleNode()
.GateManagement(roles: "Deployer")
)
);SingleNode()says one process serves the endpoint and writes the store. With more than one node, use step 6 instead. The host refuses to start with neither, or with both.GateManagementserves the management fields,persistedOperations { ... }at the root, to a role or policy. Without it they are not in the schema. It is a gate of its own: the uploader needs this role and nothing underoperations, whichUsePersistedOperationsdoes not expose. The other choices are in Authorization rules.RequirePersisted(false)withoutLogNonPersistedRequests(true)does nothing, and the host refuses to start.
3. Upload the documents your clients send
Each client's build produces a manifest of ids and documents. Upload it before the clients that use it ship, from CI or a deploy step. Over GraphQL, as a caller the management gate admits:
mutation Upload($input: UploadPersistedOperationInput!) {
persistedOperations {
uploadPersistedOperation(input: $input) {
success
operation { id shapeFingerprint isActive }
errors { code message }
}
}
}{ "input": { "id": "userProfile_v1", "document": "query UserProfile($id: Int!) { user(id: $id) { id name email } }" } }Or in process, from a tool that registers only the store:
using Trax.Api.GraphQL.PersistedOperations.Extensions;
using Trax.Api.GraphQL.PersistedOperations.GraphQL.Models;
using Trax.Api.GraphQL.PersistedOperations.Services;
builder.Services.AddPersistedOperationStore(store =>
store.UseRabbitMqInvalidation(rabbitMqConnectionString)
);
var app = builder.Build();
var service = app.Services.GetRequiredService<IPersistedOperationsService>();
var result = await service.UploadAsync(
new UploadPersistedOperationInput(
"userProfile_v1",
"query UserProfile($id: Int!) { user(id: $id) { id name email } }"
),
CancellationToken.None
);
foreach (var error in result.Errors)
Console.WriteLine($"{error.Code}: {error.Message}");UseRabbitMqInvalidation here is the broker the GraphQL nodes use, so they hear of the upload;
store.SingleNode() instead when no other process caches the operations. A refused upload comes
back as an error with a code, never as an exception; the codes are on
Management mutations.
An upload that would change the response shape of an existing id is refused with
SHAPE_DIFF_VIOLATION; pass bypassShapeDiff: true only once you have checked that shipped
clients still read the new shape.
4. Watch the logs, then enforce
Run in shadow mode for a full release cycle. Each inline query is logged at Information as
Trax persisted-operations observed inline query, with its operation name. When nothing
unexpected is left, turn enforcement on, first on a canary environment, then in production:
using Trax.Api.GraphQL.Extensions;
using Trax.Api.GraphQL.PersistedOperations.Extensions;
builder.Services.AddTraxGraphQL(graphql =>
graphql
.UsePersistedOperations(opts =>
opts.RequirePersisted(true).SingleNode().GateManagement(roles: "Deployer")
)
);An inline query is now refused with PERSISTED_OPERATION_REQUIRED and HTTP 400. Introspection
still passes; add .DisableIntrospection() to refuse it as well.
5. Let developer tools run inline queries
In Development, name the operations a playground or smoke test may run inline:
using Trax.Api.GraphQL.Extensions;
using Trax.Api.GraphQL.PersistedOperations.Extensions;
builder.Services.AddTraxGraphQL(graphql =>
graphql
.UsePersistedOperations(opts =>
{
opts.RequirePersisted(true).SingleNode().GateManagement(roles: "Deployer");
if (builder.Environment.IsDevelopment())
opts.AllowOperations("playground_smoke_test")
.AllowOperationsMatching(name => name.StartsWith("dev_"));
})
);The allowlist matches the name the caller sends, not the document, so keep it to networks you trust; see Allowlist and dev carve-outs.
6. Run more than one node
When more than one node serves the endpoint, or anything else writes the store, broadcast each change over RabbitMQ so every node empties its caches:
using Trax.Api.GraphQL.Extensions;
using Trax.Api.GraphQL.PersistedOperations.Extensions;
builder.Services.AddTraxGraphQL(graphql =>
graphql
.UsePersistedOperations(opts =>
opts.RequirePersisted(true)
.UseRabbitMqInvalidation(rabbitMqConnectionString)
.WithCacheMaxAge(TimeSpan.FromMinutes(2))
.GateManagement(roles: "Deployer")
)
);WithCacheMaxAgebounds how long a node serves a change it missed. The default is five minutes.- When a mutation returns
CHANGE_NOT_BROADCAST, the change is saved and in force on the node that made it, and the broker did not confirm the broadcast. Repeat the change to send it again. WithInMemoryCache()adds a cache of the store's lookups (IPersistedOperationCache; a change on this node, or one heard from the broker, calls itsInvalidatefor that id). Add it only when measurements show the lookup is hot; its TTL may not exceed the maximum age.
Every builder method is on PersistedOperationsBuilder.
7. Manage operations from the operations console
With the operations console on the host, a Persisted operations entry appears
under API admin once the host turns on persisted operations with UsePersistedOperations(...)
and declares the management gate with GateManagement(...), and the caller passes that gate.
Without them the entry is hidden, and /trax/dashboard/api-admin/persisted-operations says
management is not served on this server.
The page reads and writes through the persistedOperations fields, so every request it sends is
held to the management gate, whoever loaded the page. An operator who manages documents needs a
credential the gate admits, besides one for the operations gate the rest of the console uses.
The page lists trax.persisted_operation a page at a time, filters by tenant, status and id
prefix, and uploads, edits, deactivates and restores against the row's own tenant. It goes through
the same service as any other caller of those fields, so it refuses what the API refuses, with the
same message; parse, schema and shape errors show inline in the editor, and its bypass checkbox is
the bypassShapeDiff flag. A change saved but not broadcast shows as a warning carrying the
CHANGE_NOT_BROADCAST message, not as a refusal.
The console keeps working on a host that requires persisted operations: the host registers the
console's own documents when it starts and the console sends them by id. The persisted-operation
pages still send text, which runs behind GateManagement. See
UseTraxApiDashboard.
The console's own setup, including the posture it refuses to start without, is in
Add the console to a host.
SDK Reference
UsePersistedOperations | PersistedOperationsBuilder | IPersistedOperationsService | IPersistedOperationStore | Management mutations | AddTraxGraphQL