ReactiveDag.Node.Diff (reactive_dag v0.17.0-rc.65)

Copy Markdown View Source

Which units a CHANGE affects, from the diff of that change.

A :group claim answers "which unit of mine does this changed row belong to". Answering it by READING the row works for an update in place and fails for the two cases that matter:

  • the row was deleted — nothing to read, so the propagation degrades to "*": reprice the whole cell, because a vanished row might have left any group;
  • the row moved between units — the live row names where it landed, never where it came from, so the group it left is stranded and the safe answer is again "*".

A version carries both sides. So the same question becomes arithmetic on a map, with no query and nothing to fail:

units(version) = grain(from)  grain(to)

See docs/adr-004-changes-as-the-propagation-source.md.

The diff shape

One entry per attribute, in exactly four shapes:

%{"to" => value}                    # a create: there was no prior value
%{"from" => old, "to" => new}       # the attribute moved
%{"unchanged" => value}             # present, untouched
# …and an attribute absent from the map entirely was not accepted

Borrowed from ash_paper_trail's change_tracking_mode :full_diff (AshPaperTrail.ChangeBuilders.FullDiff.Helpers) rather than invented. A host already keeping a paper trail speaks this, and a claim should not depend on which producer wrote the change — today that is Payload's own write and the dirties_on hook.

before/1 and after/1 project those into two plain maps, which is all a grain function needs — Declarative.group_fn/1 reads with Map.get/2, so a projected map substitutes for a row.

Why both sides, always

A :destroy has no to; a :create has no from. An update has both, and they are the SAME unit unless the change touched a grain attribute — in which case they are two, and both need repricing. Taking the union covers all four without a branch per action type, and version_action_type is then a fact for a human reading the log rather than a switch this module dispatches on.

Summary

Types

A :full_diff changes map: attribute name (as a string) to entry.

One attribute's entry in a :full_diff changes map.

Functions

The row as it is, as a plain map — nil for a destroy.

The row as it was, as a plain map — nil for a create.

The units a version affects, as the GROUP'S OWN VALUES — a tuple for a composite grain, a bare value for a single one. units/3 is this, serialized.

The units a version affects, given the consumer's grain.

Types

changes()

@type changes() :: %{optional(String.t()) => entry()}

A :full_diff changes map: attribute name (as a string) to entry.

entry()

@type entry() :: %{optional(String.t()) => term()}

One attribute's entry in a :full_diff changes map.

Functions

after_(changes)

@spec after_(changes()) :: map() | nil

The row as it is, as a plain map — nil for a destroy.

before(changes)

@spec before(changes()) :: map() | nil

The row as it was, as a plain map — nil for a create.

Keys are ATOMS, because a grain declaration names attributes as atoms and a version's changes names them as strings. Converting here keeps that translation in one place instead of at every grain function.

groups(changes, grain, resource \\ nil)

@spec groups(changes(), term(), module() | nil) :: [term()]

The units a version affects, as the GROUP'S OWN VALUES — a tuple for a composite grain, a bare value for a single one. units/3 is this, serialized.

The values are what a consumer actually wants. A fold scoping its read needs fund == "gf" and fiscal_year == "2025", and from a joined "gf|2025" it can only split on "|" and scope each column independently — which admits pairs that never changed ("gf|2025" and "water|2026" together also admit "gf|2026"). The values name the exact pairs, so the read is exact.

iex> groups(%{"fund" => %{"from" => "A", "to" => "ES"}}, :fund)
["A", "ES"]

iex> groups(%{"f" => %{"unchanged" => "A"}, "y" => %{"to" => "25"}}, [:f, :y])
[{"A", "25"}]

units(changes, grain, key_fn \\ nil)

@spec units(changes(), term(), (term() -> String.t()) | nil) :: [String.t()]

The units a version affects, given the consumer's grain.

grain is whatever the node declared — an attribute, a list of them, or a function — in the same vocabulary Declarative.group_fn/1 accepts, because it is the same declaration.

Returns a list with one element for a create, a destroy, or an update inside a unit; two for a move. Never empty, and never :all.

iex> units(%{"fund" => %{"from" => "A", "to" => "ES"}}, :fund)
["A", "ES"]

iex> units(%{"fund" => %{"unchanged" => "A"}}, :fund)
["A"]