How-to guide

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_operation table, read and written through the Trax data context, so the host needs a data provider such as AddEffects(effects => effects.UsePostgres(...)). The tables come with the Trax schema migrations.
  • Install the package:
dotnet add package Trax.Api.GraphQL.PersistedOperations

2. 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.
  • GateManagement serves 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 under operations, which UsePersistedOperations does not expose. The other choices are in Authorization rules.
  • RequirePersisted(false) without LogNonPersistedRequests(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")
        )
);
  • WithCacheMaxAge bounds 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 its Invalidate for 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