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.
defmodule MyApp.FlowMonth do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer, # the node's OWN payload table
extensions: [ReactiveDag.Node]
attributes do # the payload columns
attribute :key, :string, primary_key?: true
attribute :plant, :string
attribute :avg_flow, :float
end
actions do
create :upsert do upsert?(true); upsert_identity(:key); accept([:key, :plant, :avg_flow]) end
end
reactive do
op :fold
key_rule :all
# `into` returns the row; the LIBRARY writes it into THIS resource
# (keyed by :key) and does the coordination Op.put. No `upsert:` needed.
reduce over: :dmr_rows,
read: &MyApp.FlowMonth.read/1,
group_by: &MyApp.FlowMonth.group/1,
key: &MyApp.FlowMonth.key/1,
into: &MyApp.FlowMonth.into/2
end
endThe library closes the payload loop: a reduce/join whose into returns a
row, with no upsert:, has that row written into the node's own resource
(ReactiveDag.Node.Payload) with change-detection. Writing into a different
resource is the explicit deviation — supply a custom upsert: for that.
The cell key maps to the resource's payload_key attribute (default :key) via
the payload_action upsert (default :upsert); set those in the reactive
block if they're named otherwise.
Which computation? (reduce / join / aggregate / compute)
| you want to… | use | rows into BEAM? | needs |
|---|---|---|---|
| fold one input's rows into per-group summaries | reduce | all of over | a read/group_by/into |
| same, but one group → many output rows | reduce (into returns a list) | all of over | list rows carry own :key |
| left-join two inputs by key | join | all of over | left/right/into |
group + avg/sum/count a relationship | aggregate | none (datastore GROUP BY) | a has_many on this resource |
| anything else (LLM, fetch, bespoke) | compute Mod | up to the module | a ReactiveDag.Op |
Rule of thumb: aggregate when the fold is a datastore aggregate over a
relationship (pushdown, no rows in memory); reduce for any other in-BEAM fold;
compute when no combinator fits.
Node shapes (what scaffolding a node needs)
| shape | data_layer | attributes | actions | reactive |
|---|---|---|---|---|
| payload (materializes typed rows) | AshPostgres/Ets | the payload columns | an :upsert action | a combinator, no upsert: |
verdict (verdict? true) | Ash.DataLayer.Simple | none | none | a combinator; rows carry :status |
| write-elsewhere | Simple | none | none | a combinator + a custom upsert: |
| escape hatch | Simple | none | none | compute Mod |
A payload node's into row is written into the resource itself (the payload
loop, ReactiveDag.Node.Payload); the cell key maps to the payload_key
attribute (default :key) via the payload_action upsert (default :upsert).
Cell ids (the vocabulary of every edge)
A node's cell id defaults to the resource module's short name, snake-cased
(MyApp.FlowMonth → :flow_month); set id: to override. This id is what
every edge names — depends_on [:flow_month], ref :flow_month, and the ids
passed to graph/2. If an edge doesn't resolve, it's almost always an id that
doesn't match a node's (defaulted or explicit) id.
Assembling + running
ReactiveDag.Node.graph/2 builds a ReactiveDag.Plan from a list of node
resources; the substrate reads only the reactive block. Then:
plan = ReactiveDag.Node.graph([FlowMonth, FiscalLines, …], for_each: &fetch/1)
ReactiveDag.Drain.run(plan,
recompute: ReactiveDag.Node.Recompute,
key_rule: ReactiveDag.Node.KeyRule)Config
config :reactive_dag,
repo: MyApp.Repo, # REQUIRED (raises if unset)
tuple_table: "my_tuple", # coordination spine table (must match your migration)
dirty_table: "my_dirty", # frontier table (must match your migration)
coordination_writer: MyApp.Writer # optional; a spine-only default shipstuple_table/dirty_table default silently, so a name that doesn't match your
migration yields empty results with no error — set them explicitly.
Summary
Functions
The cell id for a node resource (explicit id, else the module's snake short-name).
The ReactiveDag.Cells a node resource lowers to (no graph math): its root
cell + one per nested compose. Legs are lowered by-name via the shared
ReactiveDag.Lowering.walk — a ref/dep resolves to an existing cell id
(no new cell), a compose recurses into an intermediate cell.
The reserved id of the attested view a gate: interposes over over —
<over>@<gate> for the blocking mode, <over>@<gate>~annotate for the
non-blocking one (two projections → two cells).
Assemble a ReactiveDag.Plan from a list of node resources. Each resource
contributes its root cell PLUS an intermediate cell per nested compose leg
(lowered through ReactiveDag.Lowering.walk). The union is validated and
depth-ordered by ReactiveDag.Graph.build/1.
The root cell a NON-generator node resource lowers to.
Functions
The cell id for a node resource (explicit id, else the module's snake short-name).
@spec cells(module(), (atom() -> [map()]) | nil) :: [ReactiveDag.Cell.t()]
The ReactiveDag.Cells a node resource lowers to (no graph math): its root
cell + one per nested compose. Legs are lowered by-name via the shared
ReactiveDag.Lowering.walk — a ref/dep resolves to an existing cell id
(no new cell), a compose recurses into an intermediate cell.
The reserved id of the attested view a gate: interposes over over —
<over>@<gate> for the blocking mode, <over>@<gate>~annotate for the
non-blocking one (two projections → two cells).
@spec graph( [module()], keyword() ) :: ReactiveDag.Plan.t()
Assemble a ReactiveDag.Plan from a list of node resources. Each resource
contributes its root cell PLUS an intermediate cell per nested compose leg
(lowered through ReactiveDag.Lowering.walk). The union is validated and
depth-ordered by ReactiveDag.Graph.build/1.
Pass :for_each to expand GENERATOR nodes: a (population_atom -> [member])
fun. A node with for_each: :pop builds no template cell — instead, for each
member it builds an instance sub-tree rooted at <id>.<member.id>, with the
member's meta merged onto every instance cell (the host's per-member stamp,
e.g. a probe filter). A member is any map with an :id (+ optional :meta).
Without a fetcher, a generator node is skipped (and logged by the caller).
@spec to_cell(module()) :: ReactiveDag.Cell.t()
The root cell a NON-generator node resource lowers to.