Reads a cell's own rows, keyed the way the DAG keys them.
A node's rows live in its resource, but the DAG addresses them by cell key
— a "|"-joined identity for a composite-PK node, a single column's value
otherwise. Anything that wants to ask a question about a cell rather than
about a table (ReactiveDag.Insights, ReactiveDag.Verdict, a union
reading its inputs) needs the rows under those keys, not under the resource's
own primary key.
This is the read side of what ReactiveDag.Node.Payload writes, and it
deliberately mirrors that module's key derivation: identity_fields when the
node is identity-keyed, payload_key (defaulting to :key) otherwise.
Why it is not the coordination tuple
These reads used to go to reactive_dag_tuple, which carried a status per
(cell_id, key). That made the tuple a second home for derived results — one
with a fixed two-column schema, updated by a writer the host had to configure,
and queryable only through this library. Reading the resource instead means a
status is an ordinary column with ordinary Ash semantics: policies apply,
loads work, and a host can add a second column without asking us.
Missing columns are not errors
A node need not have a :status column — most don't; a rollup has sums. Rows
from such a node come back with status: nil, and callers that count statuses
simply find nothing to count. A node with no attributes at all keeps its rows
somewhere else entirely and reads as empty. Asking is always safe.
Summary
Types
one of a cell's rows, addressed by cell key
Where a node's rows live and how they are keyed — a cell's meta, or the
same three fields lifted out of it (which is what a union carries for each
of its inputs, since it reads rows it does not own).
Functions
Every row the cell currently holds, as %{key:, status:, record:}.
The keys whose status is in statuses, at most limit of them (sorted, so a
sample is stable between calls rather than reshuffling on every render).
Reconcile a leaf's rows against the key set a scan found — the algorithm every leaf driver otherwise hand-rolls.
%{status => count} over the cell's rows — the histogram Insights shows and
Verdict folds into one answer.
Types
one of a cell's rows, addressed by cell key
@type source() :: %{ optional(:resource) => module() | nil, optional(:payload_key) => atom() | nil, optional(:identity_fields) => [atom()] | nil }
Where a node's rows live and how they are keyed — a cell's meta, or the
same three fields lifted out of it (which is what a union carries for each
of its inputs, since it reads rows it does not own).
Functions
@spec all(ReactiveDag.Cell.t() | source()) :: [row()]
Every row the cell currently holds, as %{key:, status:, record:}.
Returns [] for a node that keeps no rows here — no resource at all, or a
resource with no attributes (the shape a compute/custom-upsert: node has,
where the real writes land somewhere this library never sees). That is
different from "holds nothing", so a caller that must tell the two apart
should check meta[:resource] itself.
Raises whatever the underlying Ash.read!/1 raises. Callers on a display path
(Insights) already run these behind their own rescue; a caller on a compute
path wants the failure.
@spec keys_by_status(ReactiveDag.Cell.t() | source(), [String.t() | nil], keyword()) :: [String.t()]
The keys whose status is in statuses, at most limit of them (sorted, so a
sample is stable between calls rather than reshuffling on every render).
@spec reconcile(ReactiveDag.Cell.t(), [String.t()] | MapSet.t(), keyword()) :: {:ok, [String.t()]}
Reconcile a leaf's rows against the key set a scan found — the algorithm every leaf driver otherwise hand-rolls.
current = the cell's current keys (read from its resource)
want = `want_keys`, what the scan found
upsert each want key → the host writes the row, returns true iff CHANGED
vanished = current − want → retired
⇒ changed_upserts ++ vanished (the keys to propagate):upsert is called once per want-key, in either of two forms:
(key -> row | nil)— the common case. Return the row you observed and the library writes it, decidingchanged?against the node's declaredfingerprint(or every attribute, if it declares none). Returnnilfor a key you could not observe: nothing is written and the key is not reported, which is how a partial outage stays honest.(key -> boolean)— full control. Write the row yourself and say whether it moved. Use this when the write is not an upsert into the node's own resource.:retire— how vanished keys leave. Defaults to destroying the row via the node'spayload_destroyaction; a host with a retain-if-vanish policy passes a(keys -> any)fun and marks its own rows instead.:current— the baselinevanishedis computed against. Defaults to the cell's current keys. A host whose live set is narrower than all its rows passes it explicitly — a retain-if-vanish leaf passes its non-tombstoned keys, so already-retired keys are neither re-retired nor reported as newly vanished.
Vanished keys always propagate: something disappearing is a change.
A leaf declaring fingerprint needs no :upsert at all for the row-returning
form to be worth using — that is the point: poll/1 becomes fetch, build
rows, call reconcile.
The honest gap
Call this only with a want-set you actually observed. An upstream you could
not reach must write NOTHING — handing an empty want_keys to a scan that
failed retires every key the cell has, and a downstream rollup over an empty
set typically reads as vacuously fine. A scan that couldn't look must never
render as a scan that found nothing.
@spec status_histogram(ReactiveDag.Cell.t() | source()) :: %{ required(String.t() | nil) => non_neg_integer() }
%{status => count} over the cell's rows — the histogram Insights shows and
Verdict folds into one answer.
Rows with no status are counted under nil, so the counts always sum to the
cell's key count and a node without a :status column reports
%{nil => n} rather than lying with %{}.