Ramal

Ramal

Git-style branches for your Ash application's data, as BEAM processes.
Fork it, change it, diff it, merge it back, or throw it away. Nothing touches your database until you say so.

Hex.pm HexDocs Run in Livebook

Status: 0.4, on Hex. Branches, diff, merge, proposals, exact aggregates, the LiveView inspector and the tools for AI agents work and are tested on Postgres. The API may still change before 1.0.

What it does

Ramal lets an Ash application try something on its real data without doing it.

You open a branch. Inside the branch your own code runs unchanged: the same Ash actions, the same validations and policies, the same reads. But every write lands in a small overlay held by a process instead of in Postgres, and every read sees your database plus what the branch changed. When you are done you can look at exactly what changed (diff), copy the branch to explore a variation (fork), apply it for real (merge), or drop it (discard).

              ┌─ branch A: move herd R7 to paddock L18 ─┐
              │                                          │
 Postgres ────┼─ branch B: move herd R7 to paddock L03 ──┼──▶  diff ──▶ merge one (through your real actions)
 (untouched)  │                                          │              the others are now stale and refuse
              └─ branch C: split herd R7 in two ─────────┘

A branch is one Erlang process and one map. There is no second database, no copied tables, no container and no branch quota, so a program can open as many alternatives as it needs.

{:ok, branch} = Ramal.branch(MyApp.Farm, name: "move herd R7 to paddock L18")

Ramal.run!(branch, fn ->
  # Your real action, validations and policies included. The row lands in the branch, never in Postgres.
  Ash.update!(herd, %{paddock_id: paddock.id})
  # A read inside the branch sees the base rows plus what the branch changed.
  Ash.read!(MyApp.Farm.Herd)
end)

{:ok, copy}    = Ramal.fork(branch)                  # a second branch that starts where this one is
{:ok, changes} = Ramal.diff(branch)                  # a list of %Ramal.Change{}, computed without querying the database
{:ok, merged}  = Ramal.merge(branch, actor: admin)   # replays the branch through the real actions, in one transaction
:ok            = Ramal.discard(copy)

Why it exists

Agents, planners and people who ask "what if?" need many cheap, alternate states of the same data. The usual tools each give you only part of that:

On the BEAM a branch can be a process with a map, and Ash already gives you actions, validations and policies to replay. That combination is the whole library.

What you can build with it

How it works, in four lines

  1. A branch is an overlay. It holds the rows it created, updated or deleted, keyed by resource and primary key, plus a log of the writes.
  2. Reads are patched. A Spark extension adds a preparation to your read actions: Postgres answers as usual, and the branch's overlay is applied on top (deleted rows dropped, changed rows replaced, new rows added if they match the query's filter, then sorted and paged).
  3. Writes are captured. The extension adds a change to your create, update and destroy actions that, inside a branch, puts the result in the overlay instead of calling the data layer. Ash does all its normal work first.
  4. Merge replays through your actions. The log is replayed through the real actions, in one transaction, after checking that no row the branch saw has changed in the database since. A branch that is no longer valid fails as a whole and leaves the database untouched.

Both hooks do nothing when no branch is active, and a test per hook proves it. The full design is in docs/DESIGN.md.

Quickstart

Ramal is on Hex, with docs on HexDocs. Add it to mix.exs:

def deps do
  [{:ramal, "~> 0.5.0"}]
end

Requires Elixir 1.18+ and Ash 3.x. AshPostgres is optional and only needed for the Postgres storage backend.

1. Add the extension to each resource a branch should see.

defmodule MyApp.Farm.Herd do
  use Ash.Resource,
    domain: MyApp.Farm,
    data_layer: AshPostgres.DataLayer,
    extensions: [Ramal.Resource]

  # ...
end

2. Add the supervisor to your tree.

children = [
  MyApp.Repo,
  {Ramal.Supervisor, storage: Ramal.Storage.Postgres, repo: MyApp.Repo}
]

Run mix ramal.gen.migration for the ramal_branches table (run it again after upgrading from 0.3.0 for the owner column, then migrate). Without storage: branches live in ETS and are lost when the supervisor stops, which is fine for development and tests.

3. Branch.

{:ok, branch} = Ramal.branch(MyApp.Farm, name: "what if")

Ramal.run!(branch, fn ->
  Ash.create!(MyApp.Farm.Herd, %{name: "R10", head: 5})
end)

{:ok, [change]} = Ramal.diff(branch)
change.kind      #=> :created
change.changes   #=> %{name: {nil, "R10"}, head: {nil, 5}, ...}

Ash.read!(MyApp.Farm.Herd)       # outside the branch: no R10
Ramal.merge(branch)              # now R10 is in Postgres, created by your real create action

Every function that takes a branch also takes its id, and starts the branch process from storage if it is not running, so a request can keep only the id.

Try it

A guided tour opens branches of a small farm in Postgres, diffs and forks them, merges one, gets a stale one refused, and shows a merge that fails halfway and leaves the database untouched. It needs a Postgres (docker compose -f demo/docker-compose.yml up -d starts one) and comes in two forms:

demo/ has what each chapter shows, and the browser view.

A closer look

Merging safely

merge/2 records a fingerprint of every database row the branch reads or writes. At merge time it checks them again inside the transaction. By default (on_stale: :error) it refuses when one has changed or disappeared, and it fails as a whole, leaving the database untouched, if any replayed action is no longer valid:

{:ok, a} = Ramal.branch(MyApp.Farm)
{:ok, b} = Ramal.fork(a)           # both started from the same farm

# ... each moves herd R7 to a different paddock inside its own branch ...

{:ok, _} = Ramal.merge(a)
{:error, %Ramal.Error{reason: :stale}} = Ramal.merge(b)   # R7 is no longer where b read it

Pass on_stale: :ignore, or a function that decides per stale row, when you know better.

Proposals: an agent proposes, a person decides

{:ok, branch} = Ramal.branch(MyApp.Farm)
Ramal.run!(branch, fn -> Ash.update!(herd, %{paddock_id: paddock.id}, authorize?: false) end)
{:ok, _proposed} = Ramal.propose(branch, proposed_by: agent, reason: "R7 is crowding L07")

{:ok, pending} = Ramal.list(MyApp.Farm, status: :proposed)   # what a reviewer sees
{:ok, changes} = Ramal.diff(branch)                          # what the reviewer reads
{:ok, result}  = Ramal.approve(branch, decided_by: manager, actor: manager)   # merges; or Ramal.reject(branch, ...)

A proposed branch is frozen: it refuses further writes, so the diff the reviewer reads is the diff that gets merged. Ramal records who proposed and who decided, and leaves the question of who may decide to your own policies.

Exact numbers for a branch

Ramal.aggregate/4 takes the database's answer for the rows the branch did not touch and adds the rows it did, so it costs what the overlay holds, not what the table holds.

Ramal.run!(branch, fn ->
  Ash.create!(MyApp.Farm.Herd, %{name: "R10", head: 5})
  Ramal.aggregate!(MyApp.Farm.Herd, :sum, :head)   # the base total plus 5
end)

It supports :count, :exists, :sum, :min, :max and :avg.

Per-user branches

Pass owner: to Ramal.branch/2 and Ramal.list(domain, owner: user_id) shows each user their own. It is a filter, not access control; the security notes say where the line is.

For AI agents

Ramal is written with an agent in the loop in mind: the agent works in a branch, a person approves. Three pieces:

opts = [domain: MyApp.Farm, owner: "session-42", proposed_by: "planner agent"]

tools = Ramal.Tools.definitions(opts)                      # name, description, input_schema: hand these to your model
{:ok, %{"id" => id}} = Ramal.Tools.call("ramal_open_branch", %{"name" => "move R7"}, opts)
{:ok, %{"changes" => _}} = Ramal.Tools.call("ramal_branch_diff", %{"branch_id" => id}, opts)

The agent guide has the tool loop, the AshAi MCP plug and what is and is not tested.

Key features

Honest limits

Ramal is deliberately small, and it says what it does not do:

The complete list, with the reasons, is in the design document and the ADRs.

Inspector

ramal_live is a separate package with a LiveView page that shows the open and proposed branches as columns, each with its diff as before and after rows, and buttons to fork, merge, discard, approve and reject. It is mounted by your router inside your own live_session, so it uses your LiveView socket and JavaScript, and has no asset pipeline. Ramal itself never depends on it.

ramal_inspector "/ramal", domain: MyApp.Farm, pubsub: MyApp.PubSub, actor: {MyAppWeb.Inspector, :actor}

The Ramal inspector with four open branches, each moving herd R7 to a different paddock, shown as columns of before and after rows

To see it move before wiring it into an app, run the demo from a clone of the repository: docker compose up -d db, then elixir ramal_live/examples/farm_demo.exs. It starts a seeded farm where one button opens four branches that each move a herd to a different paddock, and merging one makes the others stale. The ramal_live README has the steps and the setup, and ADR 0015 explains why it is built this way.

Benchmark

bench/ is a separate project that runs the same work against Ramal and two plain-Postgres baselines (one table copied per fork, one shared delta table) on 20,000 rows. In the run published in bench/README.md, on one shared sandbox with the database on localhost:

The figures are indicative. Run mix bench on your own hardware before drawing a conclusion; the file also says how it differs from the DAPLab study quoted above.

Learn more

Name

Ramal is Spanish for a railway branch line: it leaves the main line, serves its own stations, and often rejoins it. Pronounced rah-MAHL.

License

MIT. See LICENSE.