Packages

Block document model, one-way SCXML compiler, and LiveView editor components for composing Statifier statecharts

Current section

Files

Jump to
Raw

README.md

# StatifierBlocks
[![CI](https://github.com/riddler/statifier_blocks/actions/workflows/ci.yml/badge.svg)](https://github.com/riddler/statifier_blocks/actions/workflows/ci.yml)
[![Hex.pm Version](https://img.shields.io/hexpm/v/statifier_blocks.svg)](https://hex.pm/packages/statifier_blocks)
[![Hex Downloads](https://img.shields.io/hexpm/dt/statifier_blocks.svg)](https://hex.pm/packages/statifier_blocks)
[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/statifier_blocks/)
[![License](https://img.shields.io/hexpm/l/statifier_blocks.svg)](https://github.com/riddler/statifier_blocks/blob/main/LICENSE)
Block document model, one-way SCXML compiler, and LiveView editor components
for composing [Statifier](https://github.com/riddler/statifier-ex) statecharts
from typed blocks.
Statecharts are the right execution model for long-running workflows, and
SCXML is the right interchange format for them - but neither is something a
non-engineer will author by hand. This package is the authoring layer:
- **A block document model.** The authoring artifact is a document: a tree of
typed blocks that a person composes, each block a unit with a declared
shape rather than free-form XML. The document, not the chart, is the source
of truth that gets stored, versioned, and edited.
- **A one-way SCXML compiler.** The compiler turns a block document into an
SCXML chart that Statifier can run, and carries a provenance map so a
runtime position in the chart can be pointed back at the block that
produced it. The direction is deliberate: documents compile to charts, and
nothing decompiles a chart back into blocks.
- **LiveView editor components.** The components a host embeds to let people
compose, rearrange, and validate a block document in a browser - the
editing surface over the model above, sharing the family's rendering and
fixtures conventions with
[statifier_ui](https://github.com/riddler/statifier-ui).
Blocks are typed and host-pluggable: a host registers the block types its own
domain needs, and the compiler and editor work off that registry rather than
off a closed built-in vocabulary.
## Installation
```elixir
def deps do
[
{:statifier_blocks, "~> 0.2"}
]
end
```
## A worked example
A card-processing flow: place a hold, and settle it when the account has the
budget for it. Everything below runs - it is the example the suite executes on
every build.
**1. Write the block types your domain needs.** A block type is a behaviour
module: a handful of declarations plus one `emit/2`. These two are invoking
leaves, so they share their emission.
```elixir
defmodule MyApp.Blocks do
@moduledoc "Emission helpers shared by this host's invoking leaves."
alias StatifierBlocks.{Block, Emission}
alias StatifierBlocks.Compiler.Context
alias StatifierBlocks.Core.Emit
@doc "One compound state that starts an `<invoke>` and finishes either way."
def invoke_leaf(%Block{config: config}, %Context{} = context) do
done = Context.done_id(context)
{:ok, running} = Context.role_id(context, "running")
{:ok, invocation} = Context.role_id(context, "invocation")
waiting =
Emit.state(running, nil, [
Emission.element("invoke", [
{"id", invocation},
{"type", Map.get(config, "invoke_type", "")}
]),
Emit.transition(event: "done.invoke." <> invocation, target: done),
Emit.transition(event: "error.execution", target: done)
])
{:ok, Emit.state(context.state_id, running, [waiting, Emit.final(done)])}
end
end
defmodule MyApp.Blocks.Authorize do
@moduledoc "`myapp.authorize`: places a hold on the card."
@behaviour StatifierBlocks.BlockType
@impl true
def current_version, do: 1
@impl true
def slots(_config), do: []
@impl true
def config_schema(_config),
do: [%{key: "invoke_type", type: :string, label: "Invoke", required?: true, default: ""}]
@impl true
def validate_config(_config), do: :ok
@impl true
def io(_config), do: %{kinds: [:step], produces: "myapp.credit_card_txn"}
@impl true
def emit(block, context), do: MyApp.Blocks.invoke_leaf(block, context)
end
defmodule MyApp.Blocks.Capture do
@moduledoc "`myapp.capture`: settles a hold this flow already placed."
@behaviour StatifierBlocks.BlockType
@impl true
def current_version, do: 1
@impl true
def slots(_config), do: []
@impl true
def config_schema(_config),
do: [%{key: "invoke_type", type: :string, label: "Invoke", required?: true, default: ""}]
@impl true
def validate_config(_config), do: :ok
@impl true
def io(_config), do: %{kinds: [:step], consumes: "myapp.credit_card_txn"}
@impl true
def emit(block, context), do: MyApp.Blocks.invoke_leaf(block, context)
end
```
`io/1` is where a type declares how data flows through it. `produces` and
`consumes` are opaque strings compared for identity, widened only by a
relation the host supplies - there is no built-in type lattice.
That relation rides on the palette (`Palette.new(types, assignability:
MyApp.Blocks.Types)`) and it reaches every consumer through that one value.
`Assignability.validate/3` - what the compiler runs over the whole document -
and `Edit.Targets.slot_verdicts/3` - what the editor runs once at drag start
to mark every droppable slot before the pointer moves - are the same
implementation reading the same relation, so widening the host module opens a
drop target and clears the matching finding in the same edit. The relation can
only widen: identity is checked first, so a host callback can never refuse
something the default rule accepts.
Where a seam refuses, `Assignability.finding_reason/2` says why in a small
vocabulary (`:not_assignable`, `{:fixable_by, block_id}`), and
`Assignability.seam_reasons/3` names the seams that passed only because a
block declared nothing (`:source_untyped`, `:target_untyped`,
`:both_untyped`) - the way to find the parts of a palette you have not typed
yet. The editor stamps a refused slot's reason beside its validity as
`data-drop-reason`.
**2. Compose the document.** Your two types, arranged by the `core.*`
vocabulary this package ships. Eleven types: the containers that arrange
other blocks (`core.sequence`, `core.group`, `core.branch`, `core.parallel`,
`core.resumable_group`), and the leaves that do a structural thing on their
own (`core.wait`, `core.on_event`, `core.invoke`, `core.send`, `core.raise`,
`core.assign`). None of them knows a domain - `core.invoke` *names* an invoke
type for the host to run and never runs one. `StatifierBlocks.Core` carries
the table of all eleven with their slots. In a running system an editor
writes this tree; it is ordinary data either way.
```elixir
alias StatifierBlocks.{Block, Compiler, Document, Palette, Provenance}
document =
Document.new(
Block.new("core.sequence",
id: "blk_root",
slots: %{
"body" => [
Block.new("myapp.authorize",
id: "blk_authorize",
config: %{"invoke_type" => "myapp:authorize"}
),
Block.new("core.branch",
id: "blk_approved",
config: %{
"arms" => [%{"slot" => "arm_approved", "cond" => "budget_remaining > amount"}]
},
slots: %{
"arm_approved" => [
Block.new("myapp.capture",
id: "blk_capture",
config: %{"invoke_type" => "myapp:capture"}
)
]
}
)
]
}
),
id: "bdoc_card_capture"
)
```
**3. Build a palette and compile.** A palette is a plain value - a
`type_name => module` map you build for one operation and pass explicitly.
It is deliberately not application config and not a named process, so two
tenants in one runtime never step on each other's block types.
```elixir
palette =
Palette.new(
Map.merge(Palette.core_types(), %{
"myapp.authorize" => MyApp.Blocks.Authorize,
"myapp.capture" => MyApp.Blocks.Capture
})
)
{:ok, compiled} = Compiler.compile(document, palette)
```
`Compiler.compile/3` is a total function of `{document, palette}`: no process
state, no clock, no IO. It returns `{:ok, %StatifierBlocks.Compiled{}}` or
`{:error, findings}` - never a raise, never a partial success. The artifact
carries the generated bytes, the provenance map, a compilation record joining
document identity to chart identity, and the invoke types the chart names:
```elixir
compiled.invoke_types
#=> ["myapp:authorize", "myapp:capture"]
```
The SCXML it produced is a chart Statifier runs as-is - one compound state per
block, completion signalled by `done.state`:
```xml
<scxml initial="s_blk_root" name="bdoc_card_capture" version="1.0" xmlns="...">
<state id="s_blk_root" initial="s_blk_authorize">
<transition event="done.state.s_blk_authorize" target="s_blk_approved" type="internal"/>
<transition event="done.state.s_blk_approved" target="s_blk_root__done" type="internal"/>
<state id="s_blk_authorize" initial="s_blk_authorize__running">
<state id="s_blk_authorize__running">
<invoke id="s_blk_authorize__invocation" type="myapp:authorize"/>
...
```
**4. Point a running position back at a block.** That is what the provenance
map is for. Hand it the active state ids of a live session and it answers with
the blocks the session is inside - which is how an editor highlights the step
a run is on, and how a chart-level finding routes back to the config field
somebody typed it into.
```elixir
active_state_ids = Map.keys(compiled.provenance.by_state_id)
blocks_in_play =
compiled.provenance
|> Provenance.owners_of_states(active_state_ids)
|> Enum.map(& &1.block_id)
|> Enum.uniq()
|> Enum.sort()
#=> ["blk_approved", "blk_authorize", "blk_capture", "blk_root"]
```
For a fixed `{document canonical bytes, palette, compiler version}` the
generated SCXML is byte-identical on every machine and every run, and
`compiled.record` carries all three - so a host can skip a recompile on an
unchanged triple. The guarantee is not reversible: identical SCXML does not
mean an unchanged document, because `metadata` is not compiled.
The package's two full worked examples - this card-processing flow and a
signup wizard with A/B testing (`myapp:signup`, variants, conversion events) -
live in `test/support/document_fixtures.ex` and are stored as canonical bytes
under `test/fixtures/documents/`. Between them they reach the whole `core.*`
vocabulary.
## Config fields and where their values live
A block type's `config_schema/1` declares the fields the editor renders for
it. A field's `key` is its **identity**: the DOM id, the form param name, and
what a `{:config, block_id, key}` finding anchors to. Where the value is
*stored* is a second, separate question, and a field answers it with an
optional `value_path` - a list of keys and list indexes from the config root
down to the value it edits.
Most fields need no path: `key` alone addresses `config[key]`. Some cannot use
one. `core.branch` keys a condition field by the arm's slot name, because that
is what a finding has to name, while the condition itself is stored inside the
ordered `"arms"` list:
```elixir
alias StatifierBlocks.{BlockType, Core}
config = %{"arms" => [%{"slot" => "arm_approved", "cond" => "budget_remaining > amount"}]}
[field] = Core.Branch.config_schema(config)
field.key
#=> "arm_approved"
BlockType.value_path(field)
#=> ["arms", 0, "cond"]
BlockType.fetch_value(config, BlockType.value_path(field))
#=> {:ok, "budget_remaining > amount"}
BlockType.put_value(config, BlockType.value_path(field), "amount <= 5000")
#=> %{"arms" => [%{"cond" => "amount <= 5000", "slot" => "arm_approved"}]}
```
`value_path/1` answers `[key]` for a declaration that declares no path, so a
caller never branches on which case it has. `fetch_value/2` is total and
answers `:error` for a path that does not resolve; `put_value/3` writes the
last segment whether or not a value was already there - an arm with no
condition yet is exactly the one an author is about to type into - but never
invents an intermediate map or list a block type did not write. A host block
type that stores a value somewhere other than a top-level key declares the
path the same way.
## Registering your own block types
The `core.*` vocabulary is structural on purpose: it knows sequencing,
branching, waiting and parallelism, and nothing about anyone's domain. A
card-processing host adds the steps its own product has by writing a module
per step and handing the editor an **explicit list** of them where the editor
is mounted. There is no global registry, no application-configuration lookup,
and no discovery pass that finds every module implementing the behaviour -
each of those would make two tenants in one runtime share a vocabulary that
is supposed to be per palette.
```elixir
defmodule MyApp.Blocks.RiskHold do
@moduledoc "myapp.risk_hold: parks an authorization until a reviewer clears it."
@behaviour StatifierBlocks.BlockType
alias StatifierBlocks.Compiler.Context
alias StatifierBlocks.Core.Emit
@impl true
def current_version, do: 1
@impl true
def slots(_config), do: []
@impl true
def config_schema(_config),
do: [
%{
key: "queue",
type: :string,
label: "Review queue",
required?: true,
default: "fraud"
}
]
@impl true
def validate_config(config) do
case Map.get(config, "queue") do
queue when is_binary(queue) and queue != "" -> :ok
_missing -> {:error, [{"queue", "name the queue a reviewer picks this up from"}]}
end
end
@impl true
def palette_entry,
do: %{
label: "Risk hold",
group: "Payments",
description: "Parks the authorization until a reviewer clears it.",
badge: "manual review",
accent_token: "--sb-accent-risk"
}
@impl true
def emit(_block, context) do
done = Context.done_id(context)
with {:ok, holding} <- Context.role_id(context, "holding") do
waiting =
Emit.state(holding, nil, [
Emit.transition(event: "myapp.risk.cleared", target: done)
])
{:ok, Emit.state(context.state_id, holding, [waiting, Emit.final(done)])}
end
end
end
palette =
StatifierBlocks.Palette.from_modules(
[{"myapp.risk_hold", MyApp.Blocks.RiskHold}],
core: true
)
{:ok, risk_hold} = StatifierBlocks.Palette.fetch(palette, "myapp.risk_hold")
Map.has_key?(palette.types, "core.sequence")
#=> true
StatifierBlocks.BlockType.badge(risk_hold.palette_entry())
#=> "manual review"
StatifierBlocks.ViewModel.accent_token(risk_hold.palette_entry())
#=> "--sb-accent-risk"
```
`from_modules/2` is a value constructor and nothing more - the palette it
returns is passed into the editor, the compiler and validation explicitly,
the same way `Palette.new/2` and `Palette.core/0` are. The list is ordered
and later entries win, so `core: true` puts the core vocabulary underneath
and a host that deliberately swaps in its own `core.wait` writes it after.
The registration carries the type **name** as well as the module because the
document names a type by string and the palette resolves the string: the
mapping is the host's fact, which is what lets one module serve two names in
two tenants' palettes.
### The three presentation declarations
A palette entry may also say how the editor should draw the type, and three
of those keys are worth calling out because a host reaches for them
immediately:
| Key | What it declares | Absent means |
|---|---|---|
| `accent_token` | the NAME of a `--sb-*` custom property, never a colour | the editor's own accent |
| `badge` | a short chip for the card header | no chip |
| `join_label` | a one-argument function of config, phrasing the join marker under a side-by-side arrangement | the editor's own word |
All three are read through a total normalizer that **refuses rather than
repairs**: a badge longer than 24 characters is dropped, not clipped, and one
carrying a newline is dropped rather than collapsed to a space, because a
truncated chip reads as a bug in the editor where a missing one reads as the
declaration it is. An accent that is not an anchored `--sb-*` name never
reaches a style attribute. A `join_label` is host code on the layout path, so
it is a pure function of its argument and it is called inside a rescue - a
type with a bug in it gets an ordinary join marker rather than taking the
canvas down.
## Embedding the editor
The editor ships in this package, and a host that never renders anything must
not pay for it. `phoenix_live_view` is therefore an **optional** dependency,
and every module under `StatifierBlocks.Editor.*` is compiled behind a
presence guard: an authoring API that compiles documents in a background job,
a test suite that exercises validation, a migration script - none of them drag
in Phoenix, and none of them compile a line of editor code.
A host that wants the editor already has LiveView, since there is nowhere else
to put the editor, so it adds nothing to `mix.exs`. It does three things:
**1. Import the hook.** The package's entire client-side surface is one hook.
Add the package to `assets/package.json`:
```json
{ "dependencies": { "statifier_blocks": "file:../deps/statifier_blocks" } }
```
and register it in `app.js`:
```javascript
import { StatifierBlocksDrag } from "statifier_blocks";
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { StatifierBlocksDrag },
});
```
**2. Import the stylesheet.** It is structural CSS only - the column layout,
the drag affordances, the finding treatments - with no visual opinion and no
framework:
```css
@import "../../deps/statifier_blocks/assets/css/statifier_blocks.css";
```
**3. Render the component.**
```heex
<.live_component
module={StatifierBlocks.Editor}
id="editor"
document={@document}
palette={@palette}
on_change={&save_draft/1}
/>
```
Optional assigns: `findings` (yours, merged with the ones the view model
derives), `icon` (a function component that turns an icon *name* into markup -
this package never emits an icon set of its own), `expression_component` (an
override for `:expression` fields), `theme`, and `class`.
Underneath the component is a pure command algebra - `StatifierBlocks.Edit`
(insert, remove, move, update config, each with its inverse) over
`StatifierBlocks.ViewModel` - with no UI framework dependency at all. A host
that wants to drive document edits from something other than this editor uses
those directly.
### What the mounted component holds
The `document` you pass in, an undo history over it, the current selection,
and a `drafts` map of config edits the validation gate has not accepted yet.
A draft is never in the document and never on the undo stack: a form whose
config has not been accepted names the fields that are outstanding and offers
"Discard edits", because a draft was never a command and so cannot be undone.
There is deliberately **no `datamodel` assign**. A field that names a
datamodel path - `core.assign`'s `path`, a `core.invoke` param - is checked
for *shape* and nothing more, because this package does not own the datamodel
path grammar and holds no declaration to check a path against. A host that
knows its own datamodel checks paths itself and hands the result in through
`findings`.
### The host seams that exist today
Everything a host can say about how its own types behave and look is a
declaration on a value it already passes in - the palette, the palette entry,
the theme - rather than a callback the editor calls back into:
| Seam | Declared on | What it does |
|---|---|---|
| `:assignability` | `Palette.new/2` (also `from_modules/2`) | the host's widening relation for "may this block land in this slot" - both gates, kind admission and data flow, run against the palette the caller passed (ADR-0003 decision 6) |
| `accent_token` | palette entry | the NAME of a `--sb-*` property, stamped on that type's cards and palette rows |
| `badge` | palette entry | a short chip for the card header |
| `join_label` | palette entry | a one-argument function of config, phrasing the join marker under a side-by-side arrangement |
| `slot_outcome_key` | palette entry | names the config key the blocks in one slot carry their outcome under, so a renderer routes an interrupt rule's escape without branching on a type name; it reaches the view model as `Slot.outcome_key` and the resolved value as `Node.outcome` |
| `--sb-*` tokens | the `theme` assign, or your own CSS | every colour, space, radius and drag treatment - see [`docs/theming.md`](https://github.com/riddler/statifier_blocks/blob/main/docs/theming.md) |
| compile findings | `findings` assign | `StatifierBlocks.Finding.from_compiler/2` adapts a compiler finding into the shape the editor renders, so a compile result routes back to the field somebody typed it into |
The metadata readers are total and refuse rather than repair: a badge that is
blank, carries a newline, or runs past 24 characters is dropped rather than
clipped, an accent that is not an anchored `--sb-*` name never reaches a style
attribute, and a `join_label` that raises degrades to the editor's own word.
Assignability answers with reason-carrying refusals (sb-ue7, in flight).
Routing a compile pass into the findings pane is two calls:
```elixir
{lint_findings, _refused} =
StatifierBlocks.Finding.from_compiler_all(compiled.warnings)
```
`from_compiler_all/2` returns the findings it could anchor and, separately,
the ones it refused with the reason - a finding that names no block has
nowhere in the editor to land, and dropping it silently would be the wrong
answer. Pass the anchored ones as the `findings` assign, or straight into
`StatifierBlocks.ViewModel.build/3` if you are driving the view model
yourself.
### Not yet
Honest about the edges, so you do not go looking for these:
- **Connectors.** Blocks are arranged by containment, and there is no
free-floating edge between two cards. Whether the editor grows one is an
open ADR-0005 decision-7 question (`sb-y14`).
- **A fixtures pane.** No panel drives a document against fixture rows, and
nothing marks a block as currently invoking. ADR-0005 decision 15 defers
the live half to the family's trace conventions (`sui-13q`).
- **Datamodel path advisories.** As above: shape only. Whether an undeclared
path becomes an advisory finding is decision 11d, pending a ruling.
- **A duration control over predicator strings.** The shipped `:duration`
control is decision 9's: a whole number and a unit, storing ISO 8601, with
a raw text fallback for a value it cannot parse that way. `core.send` does
read a stored predicator duration (`1h30m`) through
`StatifierBlocks.Core.Duration`, but the control that makes that spelling
primary lives in the spike and waits on the decision 10/13 ruling.
### Theming
Every class the package emits is prefixed `sb-`, and every color, space,
radius and drag treatment is a `--sb-*` custom property with a default. Set
them through the `theme` assign, or in your own CSS against the prefix:
```heex
<.live_component
module={StatifierBlocks.Editor}
id="editor"
theme={%{"--sb-accent" => "var(--brand-500)", "--sb-radius" => "10px"}}
...
/>
```
Enough that a host can make the editor look like its own product without
forking it, and not so much that the package acquires a theming DSL.
[`docs/theming.md`](https://github.com/riddler/statifier_blocks/blob/main/docs/theming.md)
is the full guide: the three tiers the surface is organised into, why
`--sb-color-scheme` is not optional, how a block type gets an identity of its
own by naming a token, and a complete host theme you can copy. The rule it
holds itself to is that a theme sets `--sb-*` properties and writes no other
declaration - and that example is read out of the document and audited in the
gate, so it is checked rather than promised.
### What stays yours
Which palette entries a tenant may use, who may edit or publish a document,
where it is stored, and what publishing means. The editor is also a
single-session component: it surfaces the `revision` it loaded so you can do
optimistic concurrency on save, and it does not merge or resolve anything.
## Design records
The contracts this package is built out of are written down as ADRs in
[`docs/adr/`](https://github.com/riddler/statifier_blocks/tree/main/docs/adr):
the document schema (0001), the block-type behaviour (0002), host-pluggable
assignability (0003), the compiler and its provenance map (0004), and the
editor architecture (0005). A module's docs cite the decision it implements;
when the two disagree, the record is the contract and the code is the bug.
## License
MIT - see
[LICENSE](https://github.com/riddler/statifier_blocks/blob/main/LICENSE).