Move off the generic state-machine mutations
Applies from: Trax 1.63.1.
The generic stateMachine mutations are no longer served by default. Each machine a host names is served as its own
typed API instead, under the machines root. The decision is
ADR 0048.
What stops working
Before, AddStateMachines put these under dispatch { stateMachine { ... } } for every registered machine:
saveSnapshot, advanceSnapshot, loadSnapshot and sendSnapshot. After the upgrade none of them, nor the new
startSnapshot and discardSnapshot, is in the schema unless the host opts in. A client that still sends
them gets a GraphQL validation error, so nothing runs. That includes a TypeScript client from before Trax 1.64.0
built with createSnapshotClient(createHttpExecutor(...)), which sent these documents; the package no longer ships
either.
The trains behind the mutations are still registered as mediator routes, so host code that dispatches them keeps working. Only the GraphQL fields are gone.
You have two ways forward: move each machine to its typed API (recommended), or opt back in to the generic mutations, which now come with conditions.
Moving to the typed API
1. Expose the machine
Mark each machine a client may reach with [TraxMachine]. Nothing is served for a machine that is not marked: the
attribute is the only switch, as [TraxQuery] and [TraxMutation] are for a train, and it is read from every
machine the host registers, whether AddStateMachines found it in a scanned assembly or the host registered it as
IMachine itself. To serve a machine you do not own, derive a class from it, mark that, and register only the
subclass. A subclass has its base's id, so a host that also registers the base (by scanning the library's assembly,
or through a registration of the library's) refuses to start, naming both. A sealed machine cannot be served this way.
[TraxMachine]
public sealed class TopicMapMachine : Machine<TopicMapState, TopicMapTrigger> { ... }
builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(connectionString))
.AddStateMachines(typeof(TopicMapMachine).Assembly)
.AddMediator(typeof(TopicMapMachine).Assembly));An exposed machine must be declarative (built with the fluent builder, so it has an IR) and user-owned. A machine
built from delegates, or one that declares SystemOwned(), refuses startup when exposed; host code still reaches it.
2. Turn on persisted operations
Exposing a machine requires persisted operations. The host generates and registers each exposed machine's documents itself, so there is nothing to upload:
builder.Services.AddTraxGraphQL(graphql =>
graphql.UsePersistedOperations(persisted => persisted.SingleNode()));A client reads the documents' ids from the exported client manifest at build time (see Persisted documents).
3. Declare a rule on everything a client can do
An exposed machine declares who may do each thing, and the host refuses to start, naming every gap in one exception, until it does:
| What | Declared with | Allowed rules |
|---|---|---|
| each client or effect-bound event | .On(trigger) followed by the rule | Requires(roles), RequiresPolicy(name), AnyAuthenticated() |
start | Start<TStartInput>(s => s.Reduce(...).<rule>()) | Requires, RequiresPolicy, AnyAuthenticated |
get | Get(r => ...) | owner-only or stricter |
the push (<machine>Changed) | Watch(r => ...) | owner-only or stricter |
discard | Discard(r => ...) | owner-only or stricter |
Anonymous() on an event or the start of a user-owned machine refuses startup, and so does a policy the host does not
register. There is no machine-level default: an event added later without a rule stops the host from starting rather
than inheriting one.
m.Id("topic-map")
.Version(1)
.StartsAt(TopicMapState.ChoosingFields, () => new JsonObject())
.Start<TopicMapStart>(s => s.Reduce(Keep()).AnyAuthenticated())
.Get(r => r.OwnerOnly())
.Watch(r => r.OwnerOnly())
.Discard(r => r.OwnerOnly());
m.In(TopicMapState.ChoosingRange)
.On(TopicMapTrigger.Back).AnyAuthenticated().To(TopicMapState.ChoosingFields)
.On(TopicMapTrigger.Build).Requires(RecoveryRoles.Operator).WithInput<RangeChoice>().To(TopicMapState.Building);The rules are checked below the resolver, where every surface meets, so the same rule holds for host code acting for a client. The authoring page lists every startup check.
Whatever the discard rule, an instance in a state only the server puts it in (a committed state, an effect's
target, a state that invokes a train, an invoked train's outcome target, or the target of an event gated by
Requires or RequiresPolicy), or one an effect is running on, is not discarded: the answer is
discard-not-allowed. A flow that discarded such a draft to start over declares an event back to its first step
instead.
4. Map the operations
| Before (generic) | After (typed, under machines { <machine> { ... } }) |
|---|---|
creating a draft with the first saveSnapshot under an id the client chose | start(input, requestId); the server mints the id |
advanceSnapshot(machine, id, trigger, input, requestId) | one mutation per event: <event>(id, input, requestId) |
sendSnapshot(machine, id, requestId) | the effect-bound event's own mutation: <event>(id, requestId) |
loadSnapshot(machine, id) | query { machines { <machine> { get(id) } } } |
discardSnapshot(machine, id, requestId) | discard(id, requestId) |
saveSnapshot(machine, id, snapshot) | none: every change is a declared event |
polling loadSnapshot for server-made changes | subscription { <machine>Changed(id: ...) }, pushed on every write |
requestId is required on every event, start and discard; a retry with the same id answers what the first
request recorded. Each event takes the record its WithInput<T>() declares, typed in the schema, and an event without
one takes no input. A typed answer carries the instance's revision, so a client can order what it receives.
There is no save on the typed API. A flow that autosaved a client-computed snapshot moves to events: give each step that changes the context a declared event with an input record, and let the server apply it.
5. Move the client
In TypeScript, createMachineExecutor(endpoint, { machines }) drives the typed API by its persisted documents, behind
createDraftSession and startDraft, and createMachineServer with
useServerMachine drives one instance from React, with the server applying every transition and pushing each
change. Both are described under The TypeScript client
and Driving an instance from React.
Opting back in
A host that cannot move yet serves the generic mutations again with ExposeGenericStateMachineMutations, which takes
the name of an authorization policy every caller of the six mutations must satisfy:
builder.Services.AddAuthorization(o =>
o.AddPolicy("StateMachineClients", p => p.RequireAuthenticatedUser()));
builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(connectionString))
.AddStateMachines(
m => m.ExposeGenericStateMachineMutations(g => g.Policy = "StateMachineClients"),
typeof(CheckoutMachine).Assembly)
.AddMediator(typeof(CheckoutMachine).Assembly));The opt-in brings three conditions:
- A policy is required. Calling it without
Policyrefuses startup. - Every user-owned machine needs its rules. While the opt-in is on, the generic mutations reach every user-owned
machine, so each one is held to the same startup check as an exposed machine: a rule on every event, the start,
get, the push anddiscard, as in step 3 above. System-owned machines are not reachable and need none. - The generic save serves only machines with no server-side behaviour.
saveSnapshotanswerssave-not-allowed, and writes nothing, for a machine with an event or start whose rule restricts callers (RequiresorRequiresPolicy), a timer, an invoked train or an effect. Such a machine moves only through its declared events:advanceSnapshotandsendSnapshotstill serve it, under each event's rule. A client that autosaved one must send events instead.
The opt-in and exposed machines can be combined, for instance while a client moves one machine at a time.
Defaults an upgraded host gets
These apply to every host that calls AddStateMachines, whichever surface serves its machines. Set each on
StateMachineOptions to change it (see AddStateMachines):
| Option | Default | Effect |
|---|---|---|
DraftTtl | 30 days | A background sweep deletes a user's draft idle that long. It keeps a draft waiting on an invoked run, one with a timer pending, one an effect is running on, and an instance in a state only the server puts it in. Set DraftTtl = null to keep idle drafts. |
MaxDraftLifetime | 90 days | The sweep deletes a user's draft created longer ago, whatever its timers or activity, cancelling a run it waits on first; it keeps an instance in a state only the server puts it in. Set it to null for no bound; with DraftTtl = null as well, drafts live until they are discarded. A DraftTtl you set longer than it makes AddStateMachines throw. |
MaxDraftsPerUser | 50 | A start, or a save that creates a draft, past it is refused as too-many-drafts. Drafts idle past DraftTtl and system-owned instances do not count. Set it to null for no cap. |
ChangeFeed | Notify | On Postgres, each host listens for the change notices migration 078 sends. Poll reads the watched rows instead. |
The upgrade also applies migrations 076 to 080 (SQLite 038 to 040), which add the revision, the start
request id and request ring, the change notices, the timers' due time and the switch that turns the notices off.
Each is described, with what a rolling deploy sees, in Schema migrations.
Related
- Machines root: the typed API, its answers, the TypeScript client and the persisted documents.
- AddStateMachines: the options and the startup checks.
- Result codes:
forbidden,save-not-allowed,request-id-reusedand the rest.