State Machine

NO WARRANTY. Trax auth is plumbing, not a security product. You are solely responsible for securing systems that use it. See API Security.

samples/StateMachine in Trax.Samples is a GraphQL host over two snapshot state machines, authored with the fluent API and driven through the four generic stateMachine mutations, plus a React app (web/) that drives them from a browser.

MachineShapeWhat it shows
turnstileLocked ⇄ UnlockedStructure only: guards, reducers, per-state invariants. No effect.
checkoutCart → Review → Paid, version 2A committed state, one irreversible charge run exactly once, a forward migration from v1, and a total the server checks

What it proves

Each row is proved over GraphQL against the running host by tests/Trax.Samples.StateMachine.E2E (see Tests).

FeatureCodeProved by
One-line discovery: AddStateMachines(assembly) before AddMediator; both machines driven through the four mutationsApi/Program.csTurnstileTests, CheckoutTests
Illegal transitions refused as data (guard-failed, no-transition, invalid-context), the stored draft unchangedMachines.csTurnstileTests, CheckoutTests
An effect that runs once per intent, however often sendSnapshot is repeated, and only a send reaches PaidMachines.cs, RunsOnce<ICharge>, Payments.csCheckoutTests, PaymentTests
A v1 draft upgraded to v2 by MigrateFrom(1, ...), on load and on saveMachines.csForwardMigrationTests
Server authority over what a client autosaves: a draft whose total disagrees with its items is refused, and the charge takes the stored draft's totalMachines.cs, LoggingChargeServerOwnedTotalTests
The two bindings a host supplies: ISnapshotPrincipal (whose draft) and the effect (ICharge); each caller sees only their own draftsApi/Program.cs, SnapshotPrincipal.csTurnstileTests
Anonymous callers refused; the demo keys exist only in DevelopmentApi/Program.csAuthenticationTests

Run

From the Trax.Samples root:

docker compose up -d       # Postgres on 5432, database trax_statemachine
dotnet run --project samples/StateMachine/Trax.Samples.StateMachine.Api

The host listens on http://localhost:5280 in Development (Properties/launchSettings.json), the only environment that registers the demo keys alice-key-do-not-use-in-production and bob-key-do-not-use-in-production. The snapshot_draft and effect_claim tables come from the Trax Postgres provider's own migrations; the sample writes no schema.

For the browser app, start the host, then:

cd samples/StateMachine/web
npm install
npm run dev                # http://localhost:5173

The page teaches the machine before the example. Each machine has a tab: the turnstile first, as the simplest machine, then the checkout, a machine with an effect. A state diagram follows every move and marks the moves out of the current state, and every trigger can be sent from any state, so pressing one with no arrow shows the server's no-transition. What the server just did lists the checks the server made for the last request, in the order it makes them, and where it stopped (guard-failed for a penny, invalid-context for a $0.01 total). On the checkout, Side effect: the charge shows the RunsOnce<ICharge> binding, the whole request the page sends to pay (only the draft's id), and the payment provider's own list of charges, which grows by one on Pay and not at all on a second Pay. Show the raw requests opens every GraphQL request the page sends.

Try it

Send X-Api-Key: alice-key-do-not-use-in-production with each request (Nitro at http://localhost:5280/trax/graphql, or curl).

# What machines are there? (anonymous)
{ discover { stateMachine { listMachines { machines { name hasEffect } } } } }
 
# Save a checkout draft at Review. The total must be 999 cents per item.
mutation {
  dispatch { stateMachine { saveSnapshot(input: {
    machine: "checkout",
    id: "11111111-1111-1111-1111-111111111111",
    snapshot: "{\"machine\":\"checkout\",\"version\":2,\"state\":\"Review\",\"context\":{\"items\":[\"book\"],\"receipt\":null,\"total\":999}}"
  }) { output { snapshot problem { code } } } } }
}
 
# Pay: runs the charge once and moves to Paid
mutation {
  dispatch { stateMachine { sendSnapshot(input: {
    machine: "checkout", id: "11111111-1111-1111-1111-111111111111", requestId: "pay-1"
  }) { output { snapshot problem { code } } } } }
}

The sendSnapshot answer is the Paid snapshot with a receipt, and the host logs Charged 999 cents for checkout Review -> receipt rcpt_.... Send it again and the same snapshot comes back with no second charge. Save a draft with "items":["book","pen"] and "total":1 under another id and the answer is problem { code: "invalid-context" }.

The turnstile shows a refused transition. Save it Locked, then try a penny and a quarter:

mutation {
  dispatch { stateMachine { saveSnapshot(input: {
    machine: "turnstile", id: "33333333-3333-3333-3333-333333333333",
    snapshot: "{\"machine\":\"turnstile\",\"version\":1,\"state\":\"Locked\",\"context\":{}}"
  }) { output { snapshot problem { code } } } } }
}
 
mutation {
  dispatch { stateMachine { advanceSnapshot(input: {
    machine: "turnstile", id: "33333333-3333-3333-3333-333333333333",
    trigger: "Coin", input: "{\"coin\":\"penny\"}"
  }) { output { snapshot problem { code message } } } } }
}

The penny comes back as problem { code: "guard-failed", message: "Only a quarter or a dollar is accepted." } and the draft stays Locked. The same mutation with "quarter" answers the Unlocked snapshot with "paidWith":"quarter".

To see the migration, save a version 1 checkout, which has no total:

mutation {
  dispatch { stateMachine { saveSnapshot(input: {
    machine: "checkout", id: "44444444-4444-4444-4444-444444444444",
    snapshot: "{\"machine\":\"checkout\",\"version\":1,\"state\":\"Review\",\"context\":{\"items\":[\"book\",\"pen\"],\"receipt\":null}}"
  }) { output { snapshot problem { code } } } } }
}

The answer is the draft at version 2 with "total":1998, which is what is stored.

Without the X-Api-Key header, each of these mutations answers HTTP 200 with errors: [{ message: "Not authorized.", extensions: { code: "TRAX_AUTHORIZATION" } }]; only listMachines is anonymous.

How it works

Registration

using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Effect.Provider.Json.Extensions;
using Trax.Effect.StateMachine.Persistence;
using Trax.Mediator.Extensions;
 
builder.Services.AddTrax(trax =>
    trax.AddEffects(effects => effects.UsePostgres(connectionString).AddJson())
        .AddStateMachines(typeof(TurnstileMachine).Assembly)   // before AddMediator
        .AddMediator(typeof(TurnstileMachine).Assembly)
);
 
builder.Services.AddScoped<ISnapshotPrincipal, TraxCallerSnapshotPrincipal>();
builder.Services.AddSingleton<SimulatedPaymentProvider>();
builder.Services.AddScoped<ICharge, LoggingCharge>();
builder.Services.AddTraxGraphQL(graphql => graphql);

AddStateMachines discovers every Machine<TState, TTrigger> in the assembly and contributes the four mutations to the mediator scan, so it must come before AddMediator. TraxCallerSnapshotPrincipal maps the authenticated Trax caller to the draft's owner, so Alice and Bob each see their own draft for the same id. See AddStateMachines.

LoggingCharge takes the payment from SimulatedPaymentProvider, an in-memory stand-in for a payment gateway, for the draft's user and the stored draft's total. Nothing else calls the provider: the page has no charge endpoint, only payments.listCharges, a [TraxAuthorize] query that returns the caller's own charges, which is how the page shows the effect ran once.

The server owns the total

saveSnapshot is the soft path: the client writes the whole snapshot, context included, and the server validates and stores it. Anything an effect will act on must therefore be checked by the state's Holds, or the client decides it. The checkout's total is the amount a real ICharge would take, so every state holds it to the item price:

private static bool TotalMatchesItems(JsonObject ctx) =>
    ctx["total"] is JsonValue total
    && total.GetValueKind() == JsonValueKind.Number
    && total.ToJsonString()
        == ((long)ItemsCount(ctx) * UnitPriceCents).ToString(CultureInfo.InvariantCulture);
 
m.In(CheckoutState.Review)
    .Holds(ctx =>
        ItemsCount(ctx) > 0 && ReceiptEmpty(ctx) && TotalMatchesItems(ctx)
            ? null
            : "Review: non-empty items, no receipt, total = 999 cents per item."
    )
    .On(CheckoutTrigger.Pay)
    .When((ctx, input) => ItemsCount(ctx) > 0 && Receipt(input) is not null)
    .RunsOnce<ICharge>("checkout:charge")
    .To(CheckoutState.Paid);

The charge then reads the amount from the snapshot it is handed, which is the server's stored copy, already validated. See State Machines: Persistence.

The forward migration

Version 2 added total. A draft an older host stored at version 1 has none, so it would fail the v2 invariants; MigrateFrom(1, ...) backfills it from the item count whenever the server reads a version 1 snapshot: a stored draft on load, advance or send, and a snapshot an older client sends to saveSnapshot:

m.Id("checkout").Version(2).StartsAt(CheckoutState.Cart, Fresh)
    .MigrateFrom(1, (state, ctx) =>
    {
        var next = (JsonObject)ctx.DeepClone();
        next["total"] = ItemsCount(ctx) * UnitPriceCents;
        return new MigrationResult(state, next);
    });

A snapshot newer than the server's version is refused as version-mismatch. See Migrations.

Tests

dotnet test tests/Trax.Samples.StateMachine.Tests    # the machines over an in-memory store, no database
dotnet test tests/Trax.Samples.StateMachine.E2E      # the real host over GraphQL, needs Postgres

Trax.Samples.StateMachine.Tests drives both machines through the draft service over an in-memory store: the v1 to v2 migration, autosave then advance, the canonical wire, and the total check.

Trax.Samples.StateMachine.E2E starts the real host with WebApplicationFactory<Program> against a Postgres database named statemachine_e2e_tests (Host=localhost;Port=5432;Username=trax;Password=trax123; set TRAX_TEST_PG_PORT when your Postgres listens elsewhere) and sends every request to /trax/graphql, as the web client does. Without the database the suite fails; it never skips. Three techniques carry over to testing any state machine host:

  • Observe the effect by replacing it. The factory binds a recording ICharge in place of the sample's logging one, which is the binding a real host swaps for a payment gateway anyway. The recording charge reads the amount the same way, CheckoutMachine.AmountCents(snapshot), so a test can count charges and check the amount:

    using Microsoft.AspNetCore.Hosting;
    using Microsoft.AspNetCore.TestHost;
    using Microsoft.Extensions.DependencyInjection;
     
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseSetting("ConnectionStrings:TraxDatabase", connectionString);
        builder.UseEnvironment("Development");   // the demo keys exist only here
        builder.ConfigureTestServices(services => services.AddSingleton<ICharge>(charges));
    }
  • Seed what an older host stored through the store. ISnapshotStore does not validate, so writing a version 1 draft through it reproduces a row the old host left behind, which loadSnapshot then upgrades. Drafts are keyed by ISnapshotPrincipal.CurrentUserKey, which for the sample's TraxCallerSnapshotPrincipal is the scheme-qualified principal id, TraxApiKey:alice for the demo key:

    using var scope = factory.Services.CreateScope();
    var store = scope.ServiceProvider.GetRequiredService<ISnapshotStore>();
    await store.Insert("TraxApiKey:alice", id, new Snapshot
    {
        Machine = "checkout",
        Version = 1,
        State = "Review",
        Context = new JsonObject { ["items"] = new JsonArray("book"), ["receipt"] = null },
    });
  • Isolate tests by id and data, not by database. Every test uses a fresh draft id and puts an item no other test uses in its cart, so one host and one database serve the whole suite and each test sees only its own drafts and charges.

AuthenticationTests also starts the host in Production: no API key scheme is registered there, the demo key is refused, and registering a do-not-use-in-production key makes the host refuse to start.

SDK Reference

AddStateMachines | Fluent authoring | Effects | Migrations | Persistence ports