Part 2: Building Your First Graph

In Part 1 we sketched what causl is and why a reactive engine deserves a dedicated transaction model. This page is the first one where we open an editor and write code. By the end you will have a working graph with two writable cells, one computed cell, a committed mutation, and a clear mental model for what a commit actually does when you call it.

We will build the example in four numbered steps, then circle back to the question every adopter eventually asks: what does causl guarantee if my callback throws?

What you will build

A miniature ledger. Two input cells — balance and interestRate — drive a single derived cell, nextStatement, that projects the balance one period forward. Trivial domain, but it exercises every shape we will need in Parts 3 through 9: input writes, derived recomputation, the commit boundary, and the read-after-commit pattern.

1. Create the graph

Every causl application starts with a graph. The graph is the value of record — there is no separate "store" sitting next to it. You construct one by calling createCausl() from @causl/core:


The optional name is for devtools and telemetry; it has to match /^[A-Za-z0-9_.:-]{1,256}$/ if you supply it. Multiple graphs in the same process are first-class — you can have one graph per workbook, one per tab, one per test. They do not share state and they do not share clocks.

Detailed explanation: why a constructor and not a singleton? Causl deliberately rejects the global-store pattern Redux popularised. A graph is a handle, not an ambient. Tests construct fresh graphs between cases without any teardown ceremony; long-running tools (the playground, the spreadsheet demo) keep many graphs alive in parallel without aliasing. The cost is one explicit reference — we think it pays for itself the first time you write a parametric test.

2. Register input nodes

An input is a writable cell whose value can change across commits. You declare one with graph.input(id, initial):

const balance = graph.input('account:balance', 1000) const interestRate = graph.input('account:rate', 0.05)

The first argument is a stable, graph-unique string identifier from your information-model namespace; the second is the value at GraphTime zero. The id is the name causl prints in explain output, persists in snapshots, and matches across hot reloads — so resist the urge to embed a counter in it. Use names that survive a refactor: cell:wb1:Sheet1:A1, user:profile:displayName, workflow:onboarding:state.

Registering the same id twice throws a DuplicateNodeError. The error is structured — it carries the offending id on the instance — so you can branch on instanceof rather than parse a string. Every causl error follows that pattern; we will lean on it once we get to async work in Part 7.

3. Register a derived node

A derived is a read-only cell whose value is a pure function of other cells. You declare one with graph.derived(id, compute), where compute receives a tracked accessor (we bind it to g below — any name works, but the single-letter form is idiomatic for inline arrows):

const nextStatement = graph.derived( 'account:nextStatement', g => { const b = g(balance) const r = g(interestRate) return b * (1 + r) }, )

Two things happen on this line. First, causl runs your compute once, synchronously, against the current values of balance and interestRate — so the node has a real value the moment it is registered. Second, the calls to the tracked accessor are tracked: causl records that nextStatement depends on balance and interestRate. You do not declare those edges by hand and you do not pass an array of deps.

Tracking is dynamic. If compute contains an if branch that reads one input on the true arm and another on the false arm, the dependency set rewires itself on every evaluation. A derivation that once read balance and now reads only interestRate stops listening to balance the moment its next recompute settles — there are no orphan edges. (The bounded model checker in @causl/checker verifies this property with random derivations and random commits, which is why we are comfortable promising it here.)

4. Read, then commit, then read again

Outside a commit, the graph is read-only. The way you observe a value is graph.read(node):

console.log(graph.read(balance)) // 1000 console.log(graph.read(nextStatement)) // 1050

To change anything, you open a transaction with graph.commit(intent, run):

const c = graph.commit('apply-rate-bump', tx => { tx.set(interestRate, 0.07) }) console.log(c.time) // 1 console.log(c.intent) // 'apply-rate-bump' console.log(c.changedNodes) // ['account:rate', 'account:nextStatement'] console.log(graph.read(interestRate)) // 0.07 console.log(graph.read(nextStatement)) // 1070

That is the entire mutation surface of the engine. There is no graph.set, no graph.update, no node.value = x. The only way bits change is by handing a run callback to commit and letting the pipeline drive the write. The synchronous return value is a frozen Commit record: { time, intent, originatedAt, changedNodes }. Devtools, async pumps, and conflict trackers all read it from the return value rather than reaching into the commit-history ring buffer.

Anticipated question: where did nextStatement get recomputed? Inside the commit. Causl walked the dependency graph downstream of every input you wrote, re-ran each affected derivation against the newly-published values, and assembled a single Commit record covering both the input change and the derived change. The observable order is: your run finishes; clocks advance; deriveds settle; the Commit appears. Subscribers see the after-state, never an intermediate one.

The commit transaction model

A causl commit is closer to a database transaction than to a Redux action. Concretely, the call graph.commit('apply-rate-bump', …) drives an eight-phase pipeline (named Phase A through Phase H, with dotted sub-phases for seams added later) before returning to your code. The two phases you need to know about right now are:

Phases C through F.6 advance the clock, recompute the affected deriveds, freeze the Commit record, and update the bounded history ring. Phase G then fires per-node subscribers; Phase H fires commit-level subscribers. We will cover both subscriber shapes in Part 3; for now, what matters is the order: writes settle, deriveds settle, the Commit is sealed, then observers fire.

That order is the engine's central contract. It is also why you cannot read a "halfway" graph — there is no point in time at which an input has been written but its downstream derivation has not yet caught up.

What makes a commit atomic-or-nothing

The atomicity guarantee causl makes is stronger than the one transactional databases make, because it is structural: there is no public API through which a partial-state could become observable, even by accident. Three properties combine to produce that:

  1. Single mutation entry-point. Outside commit the graph is read-only. There is no side-channel that bypasses the pipeline.
  2. Scoped transaction handle. The tx object lives only for the duration of your run callback. If you smuggle it out — store it in a module variable, attach it to a Promise — calling tx.set afterwards throws StaleTxError. The escape is structurally impossible, not just discouraged.
  3. Rollback on any throw. If Phase B, the Phase D recompute, or any subsequent envelope phase throws, causl restores byte-identical pre-commit state: input cells revert, every derived the pipeline touched reverts to its previous value via a rollback map, the clock un-increments, and the history ring un-appends. Subscribers never fire on a failed commit, because Phases G and H sit outside the rollback envelope and are unreachable on the failure path.

A worked example makes the contract concrete. Suppose the rate-bump derivation accidentally pushes a value through a buggy validator that throws:

const guarded = graph.derived('account:guarded', g => { const r = g(interestRate) if (r > 0.5) throw new Error('rate out of range') return r * 100 }) try { graph.commit('try-bad-rate', tx => { tx.set(interestRate, 0.9) }) } catch (e) { console.error('commit failed:', (e as Error).message) } console.log(graph.read(interestRate)) // still 0.07 — Phase B was rolled back console.log(graph.read(nextStatement)) // still 1070 — Phase D was rolled back

No subscriber fired. graph.now did not advance. graph.commitLog has no record of the attempted commit. The catch handler is the only place in your code that learned the mutation was attempted at all. Conal Elliott's framing — "atomicity is just the statement that the next state is a function of the previous state" — is what this guarantee buys you: your derivations remain pure functions of input at the same GraphTime, even across failed transactions.

Honest limits. Atomicity is for the engine, not for the world. If your run callback also wrote to localStorage, called fetch, or mutated an external object, those effects are not rolled back. Side-effects belong outside the commit (in a subscribeCommits observer that fires in Phase H) or behind the async lifecycle primitives in @causl/sync. Part 8 of this tutorial walks through both patterns.

Multiple writes in one commit

A single commit can stage as many writes as you want. They all land at the same GraphTime — one commit produces exactly one new t, never a fractional moment, and every downstream derivation sees a self-consistent slice of input values:

graph.commit('apply-month-end', tx => { tx.set(balance, graph.read(nextStatement)) tx.set(interestRate, 0.06) }) console.log(graph.read(balance)) // 1070 (post-rollover) console.log(graph.read(interestRate)) // 0.06 console.log(graph.read(nextStatement)) // 1134.2 (1070 * 1.06)

Notice the graph.read(nextStatement) inside the callback. Reads are still safe during a commit — they observe the committed values from before the call, plus any writes the same run has already staged via tx.set. This is the canonical way to express "commit a change relative to the current value" without splitting it across two transactions.

What we glossed over

Two real features are absent from this page on purpose; we will return to them when they earn their keep.

Recap

In this part you wrote a real causl program. You learned that:

In Part 3 we will hook a subscribe callback up to nextStatement and watch the dispatch run exactly once per actual value change — including the edge case where you write the same value back and nothing fires at all.

← Part 1: Why causl? Part 3: Subscribing to changes →