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 didnextStatementget 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 singleCommitrecord covering both the input change and the derived change. The observable order is: yourrunfinishes; clocks advance; deriveds settle; theCommitappears. 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:
-
Phase A — Validate. Causl runs your
run(tx)callback. Everytx.setcall stages a write into a transient map; no live cell has changed yet. A throw escapingrunshort-circuits the pipeline entirely — nothing in Phases B through H has executed. -
Phase B — Prepare. Staged writes are merged into
the live input table, gated by
Object.isequality. A write of the previous value is a no-op for the rest of the pipeline, which meanschangedNodesreports a structurally honest answer.
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:
-
Single mutation entry-point.
Outside
committhe graph is read-only. There is no side-channel that bypasses the pipeline. -
Scoped transaction handle.
The
txobject lives only for the duration of yourruncallback. If you smuggle it out — store it in a module variable, attach it to a Promise — callingtx.setafterwards throwsStaleTxError. The escape is structurally impossible, not just discouraged. - 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 yourruncallback also wrote tolocalStorage, calledfetch, or mutated an external object, those effects are not rolled back. Side-effects belong outside the commit (in asubscribeCommitsobserver 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.
-
Subscribers.
graph.subscribe(node, observer)wakes a callback in Phase G whenever a node's value changes;graph.subscribeCommits(observer)wakes one in Phase H once per commit. Both are covered in Part 3. -
Dry-run.
graph.simulate(intent, run)answers "what would commit do here?" without advancing the clock or firing observers. It is invaluable for previews and conflict detection. We pick it up in Part 6.
Recap
In this part you wrote a real causl program. You learned that:
-
createCausl()returns a graph; multiple graphs can coexist without aliasing. -
graph.input(id, initial)registers a writable cell;graph.derived(id, compute)registers a pure function of other cells with automatically-tracked dependencies. -
graph.read(node)observes;graph.commit(intent, run)mutates. Outside a commit, the graph is read-only. -
The commit pipeline runs in eight named phases. Inputs settle,
deriveds settle, the
Commitrecord is frozen, then observers fire — never in any other order. - Atomicity is structural: a throw anywhere in the envelope restores byte-identical pre-commit state. No subscriber sees a failed commit, ever.
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.