Modules
SIGNING OFF ON A VERSION — two columns and one comparison.
A content-addressed digest of a row set — what those rows looked like at a moment, so a later comparison can tell whether they have moved.
A CHANGE, propagated to completion — in one transaction, in memory, stopping only where it must.
The cascade exceeded a budget instead of settling. Three budgets raise it, from bluntest to sharpest
The Oban job that propagates ONE change — the entry point for everything a host writes.
One node in the DAG — the domain-neutral IR both host apps compile down to.
Boot-time validation of config :reactive_dag, ….
Raised by ReactiveDag.Config.validate!/0 when configuration is wrong.
The DSL compile pipeline over the flat Cell IR — the resolve→lower→validate
machinery both host DSLs share, parameterized by app hooks so each keeps its
own domain vocabulary.
Pure DAG construction: a list of ReactiveDag.Cell → a ReactiveDag.Plan.
The engine, viewed from outside: what the graph LOOKS like, what state each cell is in, and what the last drains actually did.
What every library-provided Oban job needs from its arguments.
The vocabulary of propagation: when changed keys of a child feed a parent,
which of the parent's keys become dirty?
A cluster-wide advisory lock, for the one thing that still needs one.
Shared machinery for lowering a NESTED op-expression into a flat cell list —
the recursion both host DSLs independently grew (cascade's Lower.resolve_legs,
the portal's Graph.build_node). Same algorithm: walk an op's legs, recurse
into each, a ref resolves to an existing cell-id (no new cell), a nested op
becomes an intermediate cell whose inputs are the recursed leg ids.
The library-owned DDL, callable from a host migration — the suspension table
ReactiveDag.Suspension reads and writes
An Ash resource extension that makes a resource a node in a reactive DAG.
The resource IS the node and its own payload table: its reactive block
defines the computation, its attributes are the rows the node materializes.
This is the intended shape — one resource, both roles.
A PURE-ASH-QUERY reduce: the datastore does the grouping via a RELATIONSHIP
aggregate. The node's own resource is the group's resource — ONE row per group
— and over names its has_many to the rows being aggregated. The library loads
the aggregates in ONE Ash query — Postgres computes the GROUP BY — and each
parent row's aggregate values are its payload. No rows cross into the BEAM; no
into/read/upsert (contrast the in-BEAM reduce, which loads every row).
A grain entry that is a CALCULATION, evaluated on a change rather than read from the datastore.
ORIGINATES A CASCADE from a written record, so what depends on it recomputes.
Wired automatically by dirties_on and augmented_by — hosts do not add it.
An anonymous nested op-expression leg: composes inline as an intermediate
cell (its as id, or a positional id derived from the parent). Its own legs
are ref/compose, so the algebra reads as an expression tree.
The ESCAPE HATCH: declare an arbitrary recompute MODULE (a ReactiveDag.Op)
for a node whose computation the reduce/join combinators can't express —
an LLM call, a PDF/Tigris fetch, a bespoke multi-input recompute. compute MyApp.EventsExtract sits in the block alongside the combinators, mirroring
Ash's calculate :x, :type, MyModule (the arbitrary case is an entity too,
not a schema key beside the declarative ones).
A by-name CONTEXT input edge (context :people): the node READS the target
as settled context but is NOT recomputed when the target changes. Still a
real input (validated, ordered by depth so the target settles first, read
at recompute) — it just doesn't propagate. For a node whose recompute is
expensive/non-deterministic and consults mutable context it shouldn't be
re-triggered by (an LLM step that looks up a human-curated
people/positions table). Contrast ref, which dirties this node on change.
Which units a CHANGE affects, from the diff of that change.
A by-name FEEDBACK input edge (feedback :scheduled_meetings): a declared
back-edge, closing a loop that is real in the graph but never real in TIME.
The one value that decides whether an observation MOVED.
A declarative JOIN: read ONE input's payload, index it into a LEFT and a
RIGHT side (each a %{join_key => item} built from a per-side key fn), then
emit one row per left key joined to its right item (right may be absent). The
common declared-vs-observed reconcile/variance shape — the author writes the
two side keys + the join row, not the read/write/changed plumbing.
THE propagation rule: how a change reaches a parent, decided by what the parent
DECLARED. ReactiveDag.Cascade calls this — there is nothing to configure.
What a machine recompute does to a HUMAN's mark: lapse :approved_at, when_changed: :any.
Closes the payload loop for a resource-backed node: writes a combinator's output
row into the node's OWN resource (cell.meta.resource), keyed by the cell key.
The PER-ENTRY MAP: for each claimed row of the input, call a generic action with that row and write its structured output into this node's attributes.
This node's rows come from OUTSIDE the graph: poll MuniWatch.Crawler.
THE engine: how a cell recomputes, decided by what its node DECLARED.
Runs a pure-Ash-query aggregate node: the datastore groups + aggregates the
node's over relationship in ONE query (a relationship aggregate — Postgres does
the GROUP BY), and each parent row's aggregate values become its payload.
The pure builders behind the DECLARATIVE combinator slots. Each turns a declarative spec — attribute atoms, fold keywords, side picks — into the same fn shape the per-slot escape hatches supply, so the recompute pipeline runs one code path however declarative the author went.
Runs a per_key node: for each claimed input row, call a generic action with
that row and write its structured output into this node's attributes.
Executes a combinator's READ — always an Ash read of the over node's
resource (its primary read action, or the :read action read: names),
shaped by the optional query: transformer, and ALWAYS scoped by the
library: when the recompute claimed specific dirty keys, the over's payload
key is filtered to them (the transformer cannot un-scope; scoping is the
substrate's correctness concern, not policy).
Runs a union node: one row per (input cell, key) across several inputs,
written into this node's own table.
THE declaration the engine cares about: what unit does a change
invalidate? Everything else a combinator declares — group_by, into,
key derivation — is mapping data into shape once you already know what to
recompute.
A declarative REDUCE (fold): read an input node's payload, group it, and
reduce each group to one output row — the common map/fold shape, so the
author writes the grouping + reduction, not the read/write/changed plumbing.
Anything the combinator can't express (an LLM call, an external fetch, a
bespoke join) uses the compute: module escape hatch instead.
A by-name input edge to another named node (ref :id). The general form —
nestable inside compose. The flat depends_on: [:a, :b] schema key is sugar
that lowers to one %Ref{} per id.
Reads a cell's own rows, keyed the way the DAG keys them.
The ASH-NATIVE escape hatch: run :recompute_keys declares that this
node's recompute is a GENERIC action on its own resource — one step less
escape than a compute module, because the computation stays a first-class
Ash action (arguments, policies, Ash.run_action testability).
A dimension a human may select this node by: slice :fiscal_year.
Wires dirties_on and augmented_by onto ReactiveDag.Node.Changes.MarkDirty,
so ordinary Ash writes trigger the cascade with no host boilerplate.
The N-INPUT shape: one row per (input cell, key) across several inputs,
materialised into this node's own table.
Compile-time checks for the reactive block — everything verifiable against
the node's OWN resource fails at defmodule, not at drain time. (Checks that
need the OVER node's resource — named read actions, attribute existence —
run at graph assembly instead: that is the earliest point cross-node facts
are known.)
The behaviour a node's compute module implements — the recompute for ONE op,
the per-cell unit of work.
The compiled DAG plan — pure data the drain executes. Decoupled from any DSL:
a host app lowers its declarations into a Cell list and Graph.build/1
produces this. The drain only ever sees the Plan.
What a drain ACTUALLY did — the processing trace, returned by
ReactiveDag.Cascade.run/3.
Re-derive a cell's rows without any input having changed — the code moved, not the data.
The Oban job that resumes a suspended cascade — and the one place expensive work runs.
Summing one key across many meta maps — the arithmetic behind every "what did this cost" line, wherever the numbers came from.
What one scan DID — the poll and the drain it triggered, as a single value.
The Oban job that polls one scanner and drains what it changed.
A scanner — how the world gets in.
WHERE A CASCADE STOPPED — the library's only table, and the successor to the dirty queue.
ONE in-memory stand-in for the suspension table, and the transaction primitives around it.
A cell's live verdict: the one-word answer, rolled up from its rows' statuses.