- 1. Getting Started
- 2. Autonomous State Charts
- 3. Going Deeper
- 4. Shorthand Convenience
- 5. Custom Executable Content
- 6. Integration with Fulcro
- 7. Testing
- 8. Relationship to SCXML
A statechart is defined as a (nested) map. Some of this content’s behavior is configurable. The simplest setup is to use Clojure for the executable code and a simple flat data model that scopes all state charts to the session (running instance of state chart).
ℹ️
|
The SCXML standard is used for the semantics and processing, and MUCH of the overall structure. We keep the idea that each node in the chart has a unique ID, but take some license with executable content. Most of the "executable content" elements described in the SCXML standard have differences since we are not using XML. Sometimes we assume you can get by with an expression on the parent element, or just a single child script node. |
To make it easier to write the maps there are functions for each element type in the
com.fulcrologic.statecharts.elements
namespace, and it is recommended that you use these because some of
them validate their parameters and children.
Once you have a state chart definition you need to create a session, which is just a running instance of the chart.
Once you have a session, you can send events to it, and look at the content of the session (or store it, etc).
Here’s a traffic light example from the src/examples
directory in this repository that
leverages parallel and compound states to simulate traffic lights with pedestrian signals:
link:src/examples/traffic_light.cljc[role=include]
If you run the items in the comment block, you’ll see:
(:cross-ew/red :cross-ns/white :east-west/green :north-south/red)
(:cross-ew/red :cross-ns/flashing-white :east-west/green :north-south/red)
(:cross-ew/red :cross-ns/flashing-white :east-west/yellow :north-south/red)
(:cross-ew/white :cross-ns/red :east-west/red :north-south/green)
(:cross-ew/flashing-white :cross-ns/red :east-west/red :north-south/green)
(:cross-ew/flashing-white :cross-ns/red :east-west/red :north-south/yellow)
(:cross-ew/red :cross-ns/white :east-west/green :north-south/red)
History support includes both shallow and deep. Here’s a shallow example:
link:src/examples/history_sample.cljc[role=include]
See the SCXML spec for how to structure elements. The structure and naming are kept close to that spec for easy cross-referencing with its documentation.
The pure functional form for the charts is interesting and useful as a building-block, but if you want a fully-function SCXML-compatible system, you needs something that can run, deliver delayed events, send events from one statechart session to another, start/stop nested statecharts, etc.
In order for this to work you must have a number of things: an event queue, invocation processor(s), a data/execution model, a working memory storage facility, a statechart registry, and an event loop.
That’s a lot of different things to set up!
The com.fulcrologic.statecharts.simple
namespace can set up all of these things for you, as long
as you simple want to run statecharts all in the same JVM in RAM. That namespace also includes helper
functions for starting a new session on a chart, and sending events to arbitrary sessions.
ℹ️
|
The design of this library is meant to accomplish much more complex distributed and long-lived systems, but you have to implement the various protocols for each of the above elements to build such a thing. |
The basic steps for using simple
are:
-
Create a
(simple/simple-env)
-
Register your charts with the statechart registry in that env with
(simple/register! env k chart)
-
Run an event loop. The
com.fulcrologic.statecharts.event-queue.core-async-event-loop/run-event-loop!
usescore.async
to run such a loop for you. -
Start one or more state charts, via the
k
you registered them under. -
(optionally) Send events
(simple/send! env {:event :evt :target sessionid})
Below is an example that uses these steps, but also overrides one of the components in the env (the working memory store) so it can output messages as the statechart changes state:
link:src/examples/traffic_light_async.cljc[role=include]
ℹ️
|
The send element has a Send (capital S) alias so you can avoid shadowing the predefined Clojure function.
|
You can use the SCXML guide which has examples as a pretty good reference for documentation. The document structure in XML is nearly identical to the data structure of this library:
<scxml>
<state id="A"/>
...
</scxml>
(statechart {}
(state {:id :A})
...)
See the docstrings on the statechart
and various elements for details of differences.
- Atomic State
-
A state that does not have substates.
- Compound State
-
A state that has child states, where at most one child is active at any given time.
- Parallel State
-
A state that has more than one compound substate, all of which is active at the same time, and thus have a child state that is active.
- Configuration
-
When used in the context of a state chart, the configuration is the list of all active states. A state is active if it or ANY of its children are active. Thus, a parallel statechart with hierarchical states may have a rather large set of IDs in its current configuration.
- Working Memory
-
A map of data that contains the current configuration of the state chart, and other information necessary to know its current full state (which states have had their data model initialized, possible data model tracking, etc.).
- DataModel
-
An implementation of the data model protocol defines how the data model of your chart works. The simple implementation places the data in Working Memory, and treats lookups as paths into a single top-level map scoped to the session (running chart instance).
- ExecutionModel
-
An implementation of this protocol is handed the expressions that are included on the state chart, and chooses how to run them. A default implementation requires that they be Clojure
(fn [env data])
, and simply calls them with the contextual data for their location. - External Event
-
An event that came from the external API, either by you calling
process-event
or some other actor code doing so (invocations, sends from external charts, etc.). - Internal Event
-
An event that came from the session that will process it. These come from the chart implementation itself (e.g. done evens) and from you using the
raise
element in an executable content position. - EventQueue
-
A FIFO queue for external event delivery. The event queue is responsible for holding onto pending External Events to "self", possibly other statechart sessions (instances of other charts), remote services (e.g. send an email), and for delayed delivery. The default implementation is a "manually polled" queue that does not support most of these features, and the delayed event delivery is manual (e.g. you have to poll it, or ask it when the next one should happen and start a timer/thread). Creating a system that runs the loop and does the timed delivery is typically how you would use these in a more serious/production environment.
- Processor
-
An implementation of the statechart algorithm. This library comes with an implementation that follows (as closely as possible) the SCXML recommended standard from 2015. You may provide your own.
- InvocationProcessor
-
A protocol representing things that can be invoked while in a state. Implementations are provided for statechart invocations, and (in CLJ) futures. It is a simple matter to expand the list of supported types.
- WorkingMemoryStore
-
A protocol that represents a place to put the working memory of a statechart. This is used when you want an autonomous system that can "freeze" and "thaw" running sessions as events are received. Such a store could be in RAM (implementation provided) or in something more durable (Redis, SQL, Datomic, Filesystem).
- StatechartRegistry
-
A protocol that allows a statechart to be saved/retrieved by a well-known name. An implementation is provided for tracking them in RAM, but you could also choose an execution model that allows your charts to be serializable, and then store the charts in something more durable.
- Session
-
The combination of the content of the DataModel and Working Memory. I.e. all of the data you’d need in order to resume working from where you last left off. Sessions typically have a unique ID, which could be used to store sessions into durable storage when they are "idle", and are used for cross-session events.
- Conditional Element
-
In statecharts there is a node type (represented as a diamond) that represents a state in which the chart never "rests", but instead immediately transitions to another node based on predicate expressions. In this library (and SCXML) this is modelled with a state that has more than one transition, NONE of which have and
:event
qualifier (meaning they are exited as soon as they are entered, assuming there is a valid transition).
States may be atomic, compound (hierarchical), or parallel. The first two are generated with the state
element, and
the latter with parallel
.
Transitions are the consumers of events. Their "source" is their parent. Transitions can be eventless, and they can also have no target (indicating they do not change the active states, and are only for executing their content).
A state can have any number of transition elements. These are tested in order of appearance, and the first transition that matches the current circumstance is taken. Transitions are enabled when:
-
Their
cond
(if present) evaluates to true AND -
Their
event
(if present) matches the current event’s name (See event name matching) -
OR neither are present.
A transition that is marked external and targets its enclosing state will cause the exit/entry handlers to fire. An internal transition (that has no target at all) will not.
See the SCXML standard for other behaviors of transition
.
See Shorthand Convenience for some alternative ways to represent transitions that are easier to read.
Condition states from classic state charts (shown as a diamond in UML state chart notation) can be modelled using eventless transitions.
An "eventless" transition without a :cond
is always enabled.
Below is a conditional state that when entered will immediately transition to either state :X or :Y, depending on the result of the first transition’s condition expression:
(state {:id :Z}
(transition {:cond positive-balance? :target :X})
(transition {:target :Y}))
ℹ️
|
See Shorthand Convenience for a nicer-looking version of this. |
In a fully-fleshed system your event queue would have a corresponding runtime story, where process-event
was
automatically called on the correct chart/session/invocation/service when an event is available. As such,
and event queue might be distributed and durable (e.g. using Kafka or the like), or might simply be something
that runs in-core (a core.async go-loop
).
The library includes a fully-functional system for simple applications in the simple
namespace.
The SCXML standard describes a name matching algorithm for event names that allows for wildcard pattern matching.
Event names are dot-separated, and are prefix-matched. In this library keywords are used for event names, but
other than that the interpretation is as specified in the standard. For example,
the event name :a.b.c
is matched by :a.b.c.*
, :a.b.c
, :a.b.*
, :a.b
, etc. That is to say that
a transition with :event :a.b
would be enabled by event :a.b.c
.
The wildcard is always implied, but can be specified on a transition for clarity. Transitions are enabled and matched "in order", so you can model narrowed and catch-all behavior:
(state {:id A}
(transition {:event :error.communication} ...)
(transition {:event :error} ...))
where the first transition will be enabled only if the error is a communication one, and all errors will enable the second.
See the SCXML standard for the rules on transition preemption regarding conflicting transitions that match at the same time.
The SCXML standard (section 6.2.4) allows the format of the target to change based on the type, and implies that
these are often URLs. The default implementation of the event queue in this library only supports
targeting other statecharts (see Send Types below), and when using the default send type, the target is
expected to simply be a session-id
, where session-id
is a unique identifier, such as a GUUID.
Other implementations of the EventQueue protocol may choose to define further refinements.
The SCXML standard (section 6.2.5) defines types as a URI as well. The internal implementations in this library do not enforce this, but this library does recognize the string "http://www.w3.org/TR/scxml/#SCXMLEventProcessor" as a desire to talk to another statechart session (and is the only/default predefined value).
There is a manually polled queue implementation in this library, with no automatic processing
at all. If you want to support events that come from outside of the chart via the queue, then you have
to create a loop that polls the queue and calls process-event!
. If you do this, then event the
delayed event delivery will work, as long as your code watches for the delayed events to appear on the queue.
link:src/examples/traffic_light.cljc[role=include]
A data model is a component of the system that holds data for a given state chart’s session. The SCXML specification allows the implementation quite a bit of latitude in the interpretation of the chart’s data model. You could define scopes that nest, one global map of data that is visible everywhere, or hook your data model to an external database.
See the docstrings in the protocols
namespace.
There is a predefined FlatWorkingMemoryDataModel
(in data-model.working-memory-data-model
) that puts all data into
a single scope (a map which itself can be a nested data structure). This is the recommended model for ease of use.
There is also a predefined WorkingMemoryDataModel
in that scopes data to the state in which it is declared.
Both of these put the real data in the session working memory, allowing the data, for example, to be persisted with the state of a running session when pausing it for later revival.
Most people will probably just use the CLJCExecutionModel, which lets you write executable content in CLJC as lambdas:
;; Use `data` to compute when this transition is "enabled"
(transition {:event :a :cond (fn [env data] (your-predicate data))}
;; Run some arbitrary code on this transition
(script {:expr (fn [env data] ...)}))
There is a macro version of the script
element called script-fn
that can be used as a shorthand for
script elements:
;; Use `data` to compute when this transition is "enabled"
(transition {:event :a :cond (fn [env data] (your-predicate data))}
;; Run some arbitrary code on this transition
(script-fn [env data] ...)))
The working memory of the state chart is plain EDN and contains no code. It is serializable by nippy, transit, etc. Therefore, you can easily save an active state chart by value into any data store. The value is intended to be as small as possible so that storage can be efficient.
Every active state chart is assigned a ID on creation (which you
can override via initialize
). This is intended as part of the story to
allow you to coordinate external event sources with working with
instances of chart that are archived in durable storage while idle.
An invocation is an "external" service/chart/process that can be started when your state chart enters a state, exchanges events while that state is active (you can forward events and receive them). The invocation can self-terminate while the state is still active, but it will also be cancelled if your chart leaves the enclosing state.
Invocations can forward events to the target invocation, and can receive events back. An incoming event will be
pre-processed by finalize
elements and can update the data model before the event is further propagated through
the chart. See the SCXML standard for a full description of how invocations work.
The library has a built-in InvocationProcessor
that knows how to start other statecharts via an invoke
, and in CLJ
there is also support for futures (which are cancelled if the state containing the invoke is exited).
The src/examples
of the repository includes an example, shown below:
link:src/examples/invocations.cljc[role=include]
For example, suppose you want to install an invocation processor that can provide timer events on some interval. You could do something like:
link:src/examples/custom_invocation.cljc[role=include]
The transition
element responds to the events received by the state chart, and the invocation processor for
an invoke
can send such events. So, the timer service here is sending :interval-timer/timeout
events.
Making your statechart definition cleaner is a simple matter, since it is nothing more than a nested data structure.
One thing to note is that every element of the statechart can accept a nested sequence of children, and it will automatically flatten them. This means you can write helper functions that emit sequences of children, which in turn can use other helpers that might emit sequences.
Thus, macros and functions
can be used to generate common patterns. The convenience
and
convenience-macros
nses define some examples. These two namespaces
are currently ALPHA and are not API stable, but it is a simple matter
to copy their content if you like any of them and want to rely on them.
The macro versions expect you to be using the lambda execution model, and
require that you use a symbol for the expression (that resolves to a function).
They add some additional :diagram/???
attributes to the elements that are
string versions of the expression, for use in diagram tools or possibly even
export.
One common pattern is to schedule a delayed event on entry to a state, but
cancel (if non-delivered) on exit. This helper exists in convenience
, and
looks like this:
(defn send-after
[{:keys [id delay delayexpr] :as send-props}]
(when-not id (throw (IllegalArgumentException. "send-after's props MUST include an :id.")))
[(on-entry {}
(Send send-props))
(on-exit {}
(cancel {:sendid id}))])
There are also some simple ones (as functions) that clean up readability for common cases:
(transition {:event :E :target :target-state}
optional-executable-elements ...)
;; has a convenience version:
(on :E :target-state
optional-executable-elements...)
;; or if there is just a script and no target (just an event handler that stays in the same state):
(handle :E expr)
;; means:
(transition {:event :E}
(script {:expr expr}))
A good use-case for a macro comes up when you want to emit nodes that might be better suited for a diagram tool. The UML state chart system defines a choice node which is a node that makes a decision about where to transition to. In the SCXML standard they are coded as a state that includes nothing but event-less transitions with conditions:
(state {:id node-id :diagram/prototype :choice}
(transition {:cond pred :target target-state})
(transition {:cond pred2 :target target-state2})
(transition {:target else-target-state})
Which is not only a bit noisy, but it isn’t immediately obvious to the reader that this is a node that merely makes a choice. Additionally, with the lambda execution model the predicates are code, so there is no way for them to easily be emitted to a diagram.
So there is a macro version of this called choice
in the convenience-macros
namespace:
(choice {:id node-id ...}
pred target-state
pred2 target-state2
:else else-target-state)
that is not only more concise, but adds {:diagram/condition "pred"}
to the transitions properties (the stringified expression of the predicate).
There is also a function version of choice
in convenience
that does no add the diagram note, but looks identical
to the reader.
See the docstrings in those namespaces for more functions/macros that can make your charts more concise.
The SCXML standard defines a number of elements it terms "Executable Content", meaning child nodes that
do some action. For example, send
is executable content of this on-entry
node:
(state {}
(on-entry {}
(send ...)))
The standard allows for a compliant implementation to include extra executable content, but this library takes that one step further and allows you to define new executable content elements.
Each node in the data graph of the statecharts in this library (which are just nested maps)
has a :node-type
entry (in the case of send, its value is :send
). The implementation of v20150901
allows you to extend the set of node types that "do things" as executable content. All you need to do
is create a map that has a new (preferably namespaced) node type, and then create a defmethod that
handles that type:
(ns my-node-types
(:require
[com.fulcrologic.statecharts.algorithms.v20150901-impl :as impl]))
(defn my-exec [opts]
(merge opts {:node-type ::my-exec}))
(defmethod impl/execute-element-content! ::my-exec [env element] ...)
and you can use your (my-exec {…})
anywhere executable content is allowed in the state chart. The
helper function impl/execute!
should be used to run any child nodes (if your node allows other
executable children).
(ns my-node-types
(:require
[com.fulcrologic.statecharts.algorithms.v20150901-impl :as impl]))
(defn log-and-run [& children]
(merge opts {:node-type ::log-and-run
:children (vec children)}))
(defmethod impl/execute-element-content! ::log-and-run [env element]
(log/debug "Running: " element)
;; The `execute!` method will run executable children of an element (which will call execute-element-content! on
;; each of them)
(impl/execute! env element))
;; ...
(statechart {}
(state {}
(log-and-run
(send {})
(log {})
(raise {}))))
The Fulcro integraion for statecharts has the following general enhancements over the standard statecharts:
-
Use the Fulcro app’s state database as the DataModel
-
Use core.async to automatically support an EventQueue
-
Support a "local data" area for the statechart session that won’t accidentally collide with other database concerns.
-
Allow for the use of actors (a component abstraction) and aliases (to database locations)
-
Supply an extensible set of operations that executable elements can leverage:
-
The ability to use Fulcro’s load to populate the state database.
-
The ability to invoke remote mutations.
-
The ability to leverage mutation helpers for optimistic updates.
-
Namespace aliases used in this document:
[com.fulcrologic.fulcro.application :as app]
[com.fulcrologic.fulcro.components :as comp :refer [defsc]]
[com.fulcrologic.statecharts.elements :as ele]
[com.fulcrologic.statecharts :as sc]
[com.fulcrologic.statecharts.chart :as chart]
[com.fulcrologic.statecharts.data-model.working-memory-data-model :as wmdm]
[com.fulcrologic.statecharts.data-model.operations :as ops]
[com.fulcrologic.statecharts.integration.fulcro :as scf]
[com.fulcrologic.statecharts.integration.fulcro.operations :as fop]
[com.fulcrologic.statecharts.protocols :as sp]
There are three basic steps: install the support, register machines, and start them:
;; 1. Install statecharts on the app. Should be done once at startup, but this is an idempotent operation.
(scf/install-fulcro-statecharts! app)
;; 2. Define your charts
(def chart (chart/statechart {}
...))
;; 3. Register your chart under a well-known name. NOTE: The statecharts have to be installed *before* calling register!
(scf/register-statechart! app ::chart chart)
...
;; 4. Start some statechart. The session-id can be a unique uuid, or a well known thing like a keyword. Be careful, though,
;; because starting a machine using an existing ID will overwrite the existing one.
(scf/start! app {:machine ::chart ; registered chart name
:session-id :some-id}) ; globally unique session id
The Fulcro statecharts data model has a number of features to help you work with Fulcro applications.
Two special concepts were take from Fulcro’s own UI state machines: aliases and actors. These are implemented purely on the data model by simply adding entries to a statechart’s local data under the special keys:
-
:fulcro/aliases
- A map from a keyword to a path. The path itself can be any legal DataModel path (see later) except for another alias. -
:fulcro/actors
- A map from a keyword to anscf/actor
, which tracks the class and ident of a UI component. Can be used to find the UI component (for normalization/loads) and in data paths.
Notes:
-
Actors MUST be identified by keywords with an
actor
namespace: e.g.:actor/thing
. -
Actor keywords may only appear as the first element of a path.
-
Aliases are NOT used within paths. They define a path. Therefore arguments that would normally take a vector to describe a data path will usually accept an alias keyword instead.
You can use the initial data in sp/start!
or a top-level data-model
element to put data into the
local storage of your statechart. This is particularly useful for specifying things like aliases
on the chart itself, but defining actors at runtime:
(def c (chart/statechart {}
(data-model {:expr {:fulcro/aliases {:a [:actor/thing :thing/field]}}})
...))
(scf/register-statechart! app ::c c)
...
(scf/start! app {:machine ::c
:session-id :some-id
:data {:fulcro/actors {:actor/this (scf/actor Thing [:thing/id 1])}}})
The above code defines a chart that will have :fulcro/aliases
on the local data model
because of the statechart definition, and will have a runtime value for the :fulcro/actors
based
on data that was passed during start. Anything initialized this way will go into the local data
store (which is a path based on the session id in the app database).
Changing aliases and actors on the fly is therefore a simple matter of doing an op/assign
operation on
the data model.
There are four primary ways to address data in the data model, and
the standard statechart operations such as op/assign
already support these abstract paths:
- A keyword (not in a vector)
-
IF this keyword exists in the
:fulcro/aliases
map, then the value of that alias (which can be a path that contains any of the other things in this list) is used to find the location; otherwise the keyword is relative in the root of the LOCAL data for the statechart. - A vector starting with
:ROOT
(or a keyword that doesn’t match the other cases) -
A path in the local data of the statechart. Same as using a path without
:ROOT
. Included to be compatible with the standard location support. - A vector starting with
:fulco/state
or:fulcro/state-map
-
Indicates an absolute path in the Fulcro app database.
- A vector starting with an actor name
-
If the first element of the vector matches an entry in the local statecharts
:fulcro/actors
then:fulcro/state
and the ident of that actor are spliced together in place of that keyword and the resulting path is treated as above.
The executable content nodes (predicates and other expressions) in the statechart can be functions of
two arguments: env
(the processing environment) and data
.
You can get the current statechart session ID using the processing environment, and you can also
pull the various components of the statechart system from there (e.g. (::sc/event-queue env)
). The
data
argument includes ALL of the statechart local data (including the special :fulcro/aliases
and :fulcro/actors
, which have special meaning but are really just normal local data). The data
will also include the standard :_event
(which is a map that has things like :target
) and
an extended key for :fulcro/state-map
which has the current value of the Fulcro state database.
Non-predicate executable content (e.g. script
) functions can return a vector of operations to
run. The standard set (assign and delete) are supported, and use the extended path support
described for the DataModel.
There are some additional operations for doing I/O:
(fop/invoke-remote txn options)
-
Run a single-mutation txn (e.g.
[(f {:x 1})]
) on a remote. The options allow you to specify events to trigger on the results. Seescf/mutation-result
. You can, of course use data:target
and:returning
to auto-merge graph data return values. The:target
option can use an actor keyword as a convenience.:target
Can be a normal Fulcro state-map path, a defined alias, or a path that can include actors (which will splice the actor’s ident into the target path). (fop/load query-root component-or-actor options)
-
Issue a Fulcro load with an EQL query.
options
supposed the normal data fetch arguments, and additionally let’s you indicate what events to send when done/failed.
When using invoke-remote
you will often not have a local Fulcro CLJS mutation. This means that you’d normally
need to syntax-quote the transaction; however, remember that the Fulcro mutations namespace includes a
declare-mutation
helper that will make a "callable" function-like object that just returns itself as data.
(m/declare-mutation login app.authentication.session/login)
...
(def statechart
...
(script {:expr [(fop/invoke-remote [(login {...})] {:ok-event :event/success :error-event :event/failed)]}))
(scf/local-data-path session-id)
-
Get the path of the local data for a given session. This is useful for adding a lookup ref to a UI component whose rendering depends on changes to this local state.
(scf/statechart-session-ident session-id)
-
Get the ident of the statechart session itself. This is useful for adding a lookup ref to a UI component whose rendering depends on changes to the statechart’s configuration.
(resolve-aliases data)
-
Used in executable content to return a map for all aliases. It looks up every alias from
:fulcro/aliases
and returns a map with them as keys, and their extracted data as values. (resolve-actors data :actor/thing :actor/other …)
-
Resolves the UI props of multiple named actors. The return value is a map from actor name to the UI props (tree).
(resolve-actors data :actor/thing)
-
Resolves the UI props of a single actor, and returns them.
(resolve-actor-class data actor-key)
-
Returns the Fulcro component that is currently acting as the UI counterpart of the named actor, if known.
(scf/send! app-ish session-id event optional-data)
-
Send an event to a running statechart.
(scf/current-configuration app-ish session-id)
-
Returns the current configuration (active states) of the given statechart instance. Useful in the UI when you need to render content based on state, but remember to add
(scf/statechart-session-ident session-id)
to any component query where that is necessary (to ensure render updates). (scf/mutation-result data)
-
Extracts the raw return value of a remote mutation when the event being processed is the result of a mutation result that was originally triggered by a
fops/invoke-remote
. (m/declare-mutation)
-
Makes a function-like object that can be used to generate remote mutation calls in transactions for
invoke-remote
.
The data
parameter of runtime content (e.g. script nodes) contains:
-
The local data of the statechart (at the top level).
-
The special standard (from SCXML)
:_event
that is the event that is being processed, which in turn has:-
:data
that contains any data sent with the event. -
:target
The session ID of the statechart
-
-
A special
:fulcro/state-map
key that is the current value of the Fulcro application state.
The runtime env
in executable elements includes:
-
Any data passed via the
extra-env
argument toinstall-fulcro-statecharts!
-
:fulcro/app
- The Fucro app itself. -
::sc/statechart-registry
- The statechart Registry instance -
::sc/data-model
- The statechart DataModel instance -
::sc/event-queue
- The statechart EventQueue instance -
::sc/working-memory-store
- The statechart working memory store -
::sc/processor
- The statechart processing algorithm -
::sc/invocation-processors
- The supported invocation processors -
::sc/execution-model
- The CLJC statechart ExecutionModel
There is support for using a statechart as a co-located element of a hooks-based component.
The basic idea is that the statechart will be started when the component
uses it, and when the component leaves the screen the chart is sent
an :event/unmounted
. If the chart reaches a top-level final state, then
it will be GC’d from state.
The session ID of the chart is auto-assigned a random UUID, but you can specify a known session ID to allow for a statechart to survive the component mount/unmount cycle.
Here is an example of using this support to create a simple traffic light that can regulate (red/yellow/green) or blink red:
(defsc TrafficLight [this {:ui/keys [color]}]
{:query [:ui/color]
:initial-state {:ui/color "green"}
:ident (fn [] [:component/id ::TrafficLight])
:statechart (statechart {}
(state {:id :state/running}
(on :event/unmount :state/exit)
(transition {:event :event/toggle}
(script {:expr (fn [_ {:keys [blink-mode?]}]
[(ops/assign :blink-mode? (not blink-mode?))])}))
(state {:id :state/green}
(on-entry {} (script {:expr (fn [_ _] [(fops/assoc-alias :color "green")])}))
(send-after {:delay 2000
:id :gty
:event :timeout})
(transition {:event :timeout
:target :state/yellow}))
(state {:id :state/yellow}
(on-entry {} (script {:expr (fn [_ _] [(fops/assoc-alias :color "yellow")])}))
(send-after {:delay 500
:id :ytr
:event :timeout})
(transition {:event :timeout
:target :state/red}))
(state {:id :state/red}
(on-entry {} (script {:expr (fn [_ _] [(fops/assoc-alias :color "red")])}))
(send-after {:delay 2000
:id :rtg
:event :timeout})
(transition {:cond (fn [_ {:keys [blink-mode?]}]
(boolean blink-mode?))
:event :timeout
:target :state/black})
(transition {:event :timeout
:target :state/green}))
(state {:id :state/black}
(on-entry {} (script {:expr (fn [_ _] [(fops/assoc-alias :color "black")])}))
(send-after {:delay 500
:id :otr
:event :timeout})
(transition {:event :timeout
:target :state/red})))
(final {:id :state/exit}))
:use-hooks? true}
(let [{:keys [send! local-data]} (sch/use-statechart this {:data {:fulcro/aliases {:color [:actor/component :ui/color]}}})]
(dom/div {}
(dom/div {:style {:backgroundColor color
:width "20px"
:height "20px"}})
(dom/button {:onClick (fn [] (send! :event/toggle))}
(if (:blink-mode? local-data) "Blink" "Regulate")))))
The main interface to this library is pure and functional, which makes the general job of testing easier in some cases, but the fact is that you often need to walk a state chart through several steps in order to get it into the starting configuration to test.
The fact that nodes can execute (possibly side-effecting) code also means that when testing it is usually desirable to eliminate these side effects through some kind of stubbing mechanism.
Fortunately the design of the library makes it trivial to plug in a mock execution model, mock event queue, and a test data model, which allows you to easily exercise a state chart in tests in ways that do not side effect in an uncontrolled fashion.
The com.fulcrologic.statecharts.testing
namespace includes mocks for the necessary protocols, allows for
a pluggable data model (defaults to flat working memory), and has pre-written helpers and predicates.
The mock execution model allows you to easily set up specific results for expressions and conditions that would normally be run.
Here is an example test (written with fulcro-spec):
link:src/test/com/fulcrologic/statecharts/testing_spec.cljc[role=include]
The mocking (set when creating the testing env) is a simple map (e.g. {is-tuesday? true}
). The keys of the map
must exactly match the expression in question (e.g. use defn
to make them (as shown above), and then use those as the
cond/expr so you can match on it). The values in the mocking map can be literal values OR functions. If they are functions
then they will be passed the env
, which will include the special key :ncalls
that will have the count (inclusive) of
the number of times that expression has run since the test env was created.
The sends and cancels will also be auto-recorded. See the docstrings in the testing namespace for more information.
Most of your tests will need the chart to be in some particular state as part of your setup. You could get there
by triggering a sequence of events while having all of the mocks in perfect condition, but this creates a fragile
test where changes to the structure of the chart break a bunch of tests. The testing helpers include
(testing/goto-configuration! env data-ops config)
that allows you to set up the data model and configuration.
The data-ops
is a vector of Data Model operations (e.g. ops/assign) to run on the data model. and config
is
a valid configuration (set of active states) for the chart.
Unfortunately, the configuration of a statechart is non-trivial (it must include all active states in a complex
hierarchy) and will change when you refactor the chart. So, another helper testing/configuration-for-states
allows you
to get a list of all of the states that would be active given the deepest desired leaf state(s).
In a chart with no parallel states, there will only ever be one leaf, but when parallel states are active you must list a valid leaf from each region.
Thus, a common test setup will look like this:
(defn config [] {:statechart some-statechart})
(specification "Starting in Some State"
(let [env (testing/new-testing-env (config) {})]
;; assume a top-level parallel node, with two sub-regions. An internal call to `configuration-for-states`
;; will populate all of the necessary ancestor states from these leaves.
(testing/goto-configuration! env [] #{:state.region1/leaf :state.region2/leaf})
(testing/run-events! env :event/expired)
;; assertions
))
This library’s internal implementation follows (as closely as possible) the official State Chart XML Algorithm. In fact, much of the implementation uses internal volatiles in order to match the imperative style of that doc for easier comparison and avoidance of bugs.
The actual structure of the live CLJC data used to represent charts also closely mimics the structure described there, but with some differences for convenient use in CLJC.
Specifically, executable content is still treated as data, but the XML nodes that are described in the standard do not all exist in this library, because a conformant XML reader (which would need to be aware of the target execution model) can easily translate such nodes into the target data representation (even if that target representation is script strings).
Some of the data model elements are also abbreviated in a similar manner. See the docstrings for details.
Thus, if you are trying to read SCXML documents you will need to write (or find) an XML reader that can do this interpretation.
For example, an XML reader that targets sci (the
Clojure interpreter) might convert the XML (where a
and do-something
are implied
values in the data and excution model):
<if cond="(= 1 a)">
(let [b (inc a)]
(do-something b))
</if>
into (scope and args still determined by the execution model selected):
;; String-based interpretation
(script {:expr
"(if (= 1 a)
(let [b (inc a)]
(do-something b)))"})
;; OR eval-based
(script {:expr
'(if (= 1 a)
(let [b (inc a)]
(do-something b)))})
;; OR functional
(script {:expr (fn [env {:keys [a]}]
(if (= 1 a)
(let [b (inc a)]
(do-something b))))})
If you’re using XML tools to generate your charts, though, it’s probably easiest to use
script
tags to begin with.
The primary alternative to this library is clj-statecharts, which is a fine library modelled after xstate.
This library exists for the following reasons:
-
At the time this library was created, clj-statecharts was missing features. In particular history nodes, which we needed. I looked at clj-statecharts in order to try to add history, but some of the internal decisions made it more difficult to add (with correct semantics) and the Eclipse license made it less appealing for internal customization as a base in commercial software (see https://www.juxt.pro/blog/prefer-mit).
-
To create an SCXML-like implementation that uses the algorithm defined in the W3C Recommended document, and can (grow to) run (with minor transformations) SCXML docs that are targeted to Clojure with the semantics defined there (such as they are).
-
To define more refined abstract mechanisms such that the state charts can be associated to long-lived things (such as a monetary transaction that happens over time) and be customized to interface with things like durable queues for events (e.g. AWS SQS) and reliable timers.
-
MIT licensing instead of Eclipse.
Other related libraries and implementations:
-
XState : Javascript. Could be used from CLJS.
-
Apache SCXML : Stateful and imperative. Requires writing classes. Requires you use XML.
-
Fulcro UI State Machines : A finite state machine namespace (part of Fulcro) that is tightly coupled to Fulcro’s needs (full stack operation in the context of Fulcro UI and I/O).
This library was written using the reference implementation described in the SCXML standard, but without the requirement that the chart be written in XML.
Any deviation from the standard (as far as general operation of state transitions, order of execution of entry/exit, etc.) should be considered a bug. Note that it is possible for a bugfix in this library to change the behavior of your code (if you wrote it in a way that depends on the misbehavior); therefore, even though this library does not intend to make breaking changes, it is possible that a bugfix could affect your code’s operation.
If future versions of the standard are released that cause incompatible changes, then this library will add a new namespace for that new standard (not break versioning).