Modules
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.
Calendar bucketing as an ASH CALCULATION — the Ash-native answer to the classic "date-marked records → time-bucketed aggregate". The bucket is declared on the resource that OWNS the date (where derived values live in Ash), and a rollup node just groups by the calculation
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 reactive propagation loop — the heart of the substrate, shared by both hosts.
What a drain ACTUALLY did — the processing trace, returned by
ReactiveDag.Drain.run/2.
The drain exceeded its pass budget — likely a cycle, or a recompute that
keeps re-dirtying its own inputs. :report carries the PARTIAL trace up to
the abort: report.steps' tail shows exactly which cells keep triggering
each other, which is the diagnostic for the loop this error suspects.
The Oban job that drains the frontier and nothing else.
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.
The dirty frontier, owned by the library and backed by the reactive_dag_dirty
table (created by ReactiveDag.Migration). The host is an Ash/AshPostgres app,
so we go through its repo with raw SQL — values always parameterized; the
table name (the one identifier SQL cannot parameterize) comes from config and
is validated against an identifier grammar at read time, so a typo fails
loudly instead of as a syntax error deep in a query. Claim-as-delete is a raw
DELETE … RETURNING that Ash actions don't express cleanly.
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?
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 dirty-frontier
table ReactiveDag.Frontier reads and writes (coalesced by (cell_id, key);
claim is a DELETE … RETURNING)
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).
Marks a written record's key dirty on its own cell, so the next drain picks
the change up. Wired automatically by dirties_on — 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.
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.Drain 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.
Re-derive a cell's rows without any input having changed — the code moved, not the data.
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.
A cell's live verdict: the one-word answer, rolled up from its rows' statuses.