Actions
This content is for the 0.1 version. Switch to the latest version for up-to-date documentation.
Everything that changes state is a StateAction. There is one sealed type with
85 variants, and every one of them arrives wrapped in an envelope that says
which channel it belongs to and where it sits in the sequence.
The envelope
Section titled “The envelope”final class ActionEnvelope { final String channel; // which channel this mutates final StateAction action; // what changed final int? serverSeq; // position in the connection-global sequence final ActionOrigin? origin; // who caused it final String? rejectionReason;}serverSeq is global to the connection, not per-channel. That is what makes a
gap detectable at all, and it is why gap detection lives on the mirror rather
than on an individual store.
Matching
Section titled “Matching”StateAction is sealed, so a switch over it is exhaustive and the compiler
tells you when upstream adds a variant you have not handled:
switch (action) { case StateActionChatDelta(:final value): print('${value.turnId}: ${value.delta}'); case StateActionSessionReady(:final value): print('session ready: ${value.type}'); case StateActionUnknown(:final raw): // A newer host sent something this client predates. break; default: break;}Each variant wraps a payload class carrying that action’s fields. The variant
is the discriminant; value is the data.
Unknown variants
Section titled “Unknown variants”StateActionUnknown is not an error path. The specification requires clients to
silently ignore actions they do not understand, so an unrecognized type
decodes into it, keeps the raw JSON verbatim, and re-encodes unchanged.
case StateActionUnknown(:final raw): // raw is the original map. toJson() returns it byte-for-byte.Two consequences worth relying on:
- Reducers treat it as a no-op, returning the state they were given identically — which suppresses rebuilds for free. See Reducers.
- A newer host round-trips through an older client without losing fields. Nothing is dropped on the way through.
Every generated union behaves this way, not just StateAction.
ActionType
Section titled “ActionType”The discriminants are also available as an enum, recovered from the schema’s
oneOf:
if (envelope.action case StateActionChatDelta(:final value)) { assert(value.type == ActionType.chatDelta);}ActionType.fromWire('chat/delta') decodes one, and throws on anything it does
not recognize — deliberately, because at that point you are not decoding an
action, you are decoding a string that was supposed to be one.
Where actions come from
Section titled “Where actions come from”Two directions, and the difference matters for reconciliation:
| Origin | Path |
|---|---|
| The host | Arrives on client.actions, applied to confirmed and optimistic |
| This client | dispatch applies to optimistic immediately, then reconciles |
A dispatched action comes back from the host as an echo carrying the same
clientSeq, which is how the client recognizes its own work and settles it.
Dispatching actions covers the round trip.
Decoding by name
Section titled “Decoding by name”The round-trip fixtures name a type as a string and expect it decoded. Dart has no runtime reflection in AOT, so the generator emits a lookup table:
final decoder = protocolDecoders['ChatState']!;final state = decoder(json) as ChatState;No other client needs this — Go, Swift, and Kotlin get the same effect from generics or reflection. It exists because Dart does not.