Async Operations and Sync
The Causl engine is deliberately synchronous: time only advances through
graph.commit(), and outside a commit the graph is read-only. That
property is what makes Phase A–H deterministic and what lets the static
checker prove things about recompute order. But real applications fetch data,
and a fetch is the prototypical thing the engine cannot directly express —
it spans wall-clock time, can interleave with other commits, and can fail.
@causl/sync is the adapter package that bridges that gap. It
models an async fetch as an external Event source whose lifecycle
is observed by an InputNode, and surfaces the result as a
tagged discriminated union the type system can narrow over. The engine stays
synchronous; the package translates loader callbacks into commits.
This page covers what you need to use it correctly: the resource lifecycle
state machine, the staleness guard, Promise identity for Suspense, the
conflict registry, the two adapter race rows the property suite guards, and
the whyUpdated / whyNotUpdated decoders.
Why async needs its own primitive
You could write graph.set(node, await fetch(...)) from inside a
handler and call it a day. The reason not to is that the world can move
between the moment the fetch starts and the moment it resolves — a
second fetch can fire, an input that the loader depended on can be edited,
the resource can be disposed. Without a structured lifecycle, each of those
cases turns into an ad-hoc bug at the call site.
@causl/sync names the relevant states explicitly, attaches the
originating GraphTime to each fetch, and routes the “late
resolve” case to a distinct stale tag rather than
silently overwriting newer data. That moves the entire family of fetch races
from runtime surprises to compile-time tag checks.
The resource lifecycle
A resource is registered with resource(graph, key, options). It
allocates an InputNode<ResourceState<T>> initialised
to { state: 'idle' } and returns a handle that drives transitions
through one-shot commits.
import { createCausl } from '@causl/core';
import { resource } from '@causl/sync';
const graph = createCausl();
const items = resource<Item[]>(graph, 'items', {
loader: async () => fetch('/api/items').then((r) => r.json()),
});
const data = await items.fetch();
const snapshot = graph.read(items.node);
// snapshot.state is one of: 'idle' | 'loading' | 'loaded' | 'stale' | 'errored'
The five state tags correspond to the ResourceFleet sub-statechart inside
Causl's composite lifecycle (SPEC.async §6). Every transition lands
as exactly one engine commit, so each fetch advances GraphTime
by a known number of ticks: one for loading, one for the
resolution (loaded or stale) or rejection
(errored).
1. Reading state — narrow before you touch .value
ResourceState<T> is a discriminated union, not an object
with optional fields. Reading .value requires narrowing on
.state first — the type system rejects code that tries
to read the value before the loader resolved.
const s = graph.read(items.node);
switch (s.state) {
case 'idle':
return ui.placeholder();
case 'loading':
return ui.spinner(s.promise); // s.promise typed only on this arm
case 'loaded':
return ui.list(s.value); // s.value typed only on this arm
case 'stale':
return ui.list(s.value, { dim: true });
case 'errored':
return ui.error(s.error);
}
Detailed explanation: why tags instead of optional fields. Every “X may or may not have Y” field is a state machine in disguise.ResourceStatesurfaces the machine as the type sotsccatches the “read value before loaded” bug before it ships. The same principle drives theConflict<T>shape further down this page.
2. The transitions and their commits
Each handle method produces exactly one transition per outcome. The intent
label on the commit is set per chart edge so the whyUpdated
decoder can name the reason later without consulting prev
and next:
| Edge | Trigger | Intent label |
|---|---|---|
| Idle → Loading | fetch() | fetch:<key>:start |
| Loading → Loaded | loader resolves, no drift | fetch:<key>:loaded |
| Loading → Stale | loader resolves, drift detected | fetch:<key>:stale |
| Loading → Errored | loader rejects | fetch:<key>:error |
| Loaded → Stale | invalidate() | invalidate:<key> |
| Loading | Loaded → Errored | fail(error) | fail:<key> |
Transitions not in this table are not in the chart. Calling
fail() from idle, stale, or
errored throws ForbiddenResourceTransitionError
rather than silently writing the errored tag. Calling
invalidate() outside of loaded is a chart-named
no-op — the reducer returns the input state unchanged and the
adapter skips the commit, so GraphTime does not advance.
3. The staleness guard
The classic async-resource bug is the stale-async race: fetchA
starts, fetchB starts later, fetchA resolves last
— and overwrites the fresher data. The engine cannot prevent the race
(the loaders run outside its control), but it can make the response
defined. When the loader resolves, the adapter compares the
GraphTime captured at fetch() start
(origin) against graph.now. If anything has
committed in the meantime, the transition is Loading → Stale, not
Loading → Loaded:
const a = items.fetch(); // origin = T0, state: 'loading'
graph.commit('unrelated', tx => tx.set(otherInput, 7));
await a;
graph.read(items.node);
// { state: 'stale', value: ..., origin: T0, loadedAt: T2 }
The same shape covers a fetch that resolved after a later fetch already finished — the older origin is no longer authoritative. Opt out per-resource for last-writer-wins semantics:
resource(graph, 'items', { loader, stalenessGuard: false });
This is race row S-1 from SPEC.async
§9.1.1: stale-async resolution. The witness is at
packages/sync/test/properties/race-row-S-1.property.test.ts and
runs at least 1000 random
(commit | fetch-start | fetch-resolve | fetch-reject)
sequences per CI build; failing seeds are committed as regressions.
4. Promise identity stability
The loading arm carries the in-flight promise on the state
itself:
{ state: 'loading', origin, promise }. This is what React's
Suspense renderer throws when a component reads a not-yet-loaded resource,
and identity matters: a fresh Promise per render breaks
SuspenseList and startTransition's ability
to deduplicate awaits.
SPEC.async §3.1 Theorem 3 commits to Promise identity
stability: every read of the same loading episode returns the same
promise reference under Object.is. The witness is
theorem-3-promise-identity-stability.test.ts.
Detailed explanation: settle-only-resolve. The promise the adapter carries on theloadingarm is constructed asloaderPromise.then(() => undefined, () => undefined). It always resolves, never rejects, so React throwing it never produces an unhandled-rejection warning. The original loader rejection still drives the resource toerroredand re-throws to the caller offetch(); the next render reads theerroredtag and an error boundary catches it. The Suspense-safe promise is a wrapper, not a replacement.
The conflict registry
Not every async operation succeeds. When a resource lands in
errored, when two writers disagree about the same key, or
when an offline sync surfaces a server-side rejection, you need a UI
surface to display the conflict and a typed protocol for resolving it.
The conflict registry is a derived view over engine primitives.
The runtime universe contains only InputNode and
DerivedNode; “conflict” is a role, not a permanent
node kind. Two pieces compose:
- An application-supplied derived that emits the currently-open conflict set.
- An
Input<ReadonlyMap<...>>mapping conflict id to a resolution record.
The exported registry is itself a derived node that overlays the resolution map onto the open set:
import { createConflictRegistry, singleConflictWhen } from '@causl/sync';
const conflicts = createConflictRegistry<Item[]>(graph, {
id: 'conflicts',
compute: singleConflictWhen(
items.node,
(s) => s.state === 'errored',
() => ({ id: 'items-errored', target: items.key }),
),
});
for (const c of conflicts.read(graph)) {
switch (c.kind) {
case 'open': ui.show(c.target, c.value); break;
case 'resolved': ui.audit(c.id, c.resolution, c.resolvedAt); break;
case 'ignored': ui.dim(c.id, c.ignoredAt); break;
case 'superseded': ui.link(c.id, c.supersededBy, c.supersededAt); break;
}
}
The discriminator is kind — the open arm carries
value and raisedAt, the resolved arm carries
resolution and resolvedAt, and so on. Each
variant carries exactly the fields the ConflictRegistry sub-statechart
guarantees in that state. The same “optional-fields-are-state-
machines” principle as ResourceState: there is no
c.resolution !== undefined runtime check left for callers.
Three legal transitions out of Open
The states are Open | Resolved | Ignored | Superseded; the
latter three are terminal. The mutators each commit one tick:
conflicts.resolve(graph, 'items-errored', { choice: 'use-cache' });
conflicts.ignore(graph, 'items-errored');
conflicts.supersede(graph, oldId, newId);
Targeting a non-Open conflict throws
ForbiddenConflictTransitionError. Shipping a transition that
is not in the chart is the SPEC.md §17.7 anti-pattern
this package was written against — a silent no-op there would let
adopters reason inconsistently about the state space.
Race row S-2 — open-set drift mid-resolution
The second adapter race the property suite guards is open-set drift: while
a resolve() mutator is running, the application's open-set
compute can change (an upstream input edits, a derived recomputes), and
the seam between the requireOpen guard and the resolution-Input
commit must remain atomic. The witness at
packages/sync/test/properties/conflict-registry-drift.property.test.ts
fuzzes randomized open-set-source mutations across that seam and asserts
the §5 atomicity contract holds: the resolution record stamps the same
GraphTime the guard read, and the post-resolve now
is exactly one tick past it. The drift seam is closed structurally.
Disposal and post-dispose loader resolution
A resource can be disposed (released by the host) while a fetch is in
flight. The adapter must not write the resolved value back to a disposed
node — that would resurrect state the host believed gone. The witness
at packages/sync/test/spec-async-10-4-disposed-mid-load.test.ts
asserts the §10.4 invariant: a loader that resolves after dispose is a
no-op against the disposed node, and subsequent reads surface a dispose
tombstone rather than the resolved value. The adjacent property test at
disposed-mid-load.property.test.ts extends the resource state
with a terminal disposed tag in the model and verifies that
no event sequence after dispose mutates the state. Note that
disposed is engine-level node disposal — the
ResourceState<T> union itself has five arms; disposal
lives one level below it.
Decoding updates: whyUpdated and whyNotUpdated
Subscribers see commits flow past. To classify why a particular
commit updated a resource — for devtools, telemetry, or higher-level
sync orchestration — SPEC.async §11.1 ships two
pure decoders.
whyUpdated(commit, prev, next): ResourceUpdateReason
Returns one of seven closed reasons. The decoder reads the commit's
intent label (which the resource adapter set per chart edge)
and maps it to the matching tag; prev and next
are accepted in the signature so a future schema bump that needs cross-arm
disambiguation does not break adopter call sites.
import { whyUpdated } from '@causl/sync';
graph.subscribe(items.node, (next, prev, commit) => {
const reason = whyUpdated(commit, prev, next);
// 'fetch-begin' | 'fetch-resolved' | 'fetch-stale' | 'fetch-rejected'
// | 'invalidated' | 'failed' | 'dep-changed'
telemetry.record({ key: items.key, reason });
});
The runtime tuple RESOURCE_UPDATE_REASONS is the value-level
dual: TypeScript erases types at runtime, so a devtools panel that wants
to render every possible reason reaches for the frozen tuple rather than
reconstructing the enum from string literals.
whyNotUpdated(prev, next): WhyNotUpdatedReason | null
The dual: when a subscriber didn't see an update, this decoder names
why. There are two named reasons; null is returned only when
the resource actually did transition (so the “why
not” question has no answer):
'object-is-deduped'—prevandnextareObject.is-equal. The engine's Phase B equality cutoff suppressed the dispatch.'no-dep-overlap'— structurally identical states with different references. The commit'schangedNodesset did not include this resource's node.
import { whyNotUpdated } from '@causl/sync';
const before = graph.read(items.node);
graph.commit('unrelated', tx => tx.set(otherInput, 7));
const after = graph.read(items.node);
whyNotUpdated(before, after);
// 'no-dep-overlap' — the commit didn't touch items.node
Both decoders are pure: same inputs produce same outputs, no graph reads,
no side effects. They compose with subscribeCommits or
commitMetadataDerived at the application layer. A future
iteration lifts them into derived nodes via the Phase F.5 seam; this
module ships the synchronous helper API first.
Bundle budget
@causl/sync ships with per-primitive sub-imports so adopters
pay only for what they use. Three CI-gated size-limit
ceilings:
| Import | Ceiling |
|---|---|
@causl/sync (full barrel) | 12 KB |
@causl/sync/resource | 8 KB |
@causl/sync/conflict | 8 KB |
import { resource } from '@causl/sync/resource';
import { createConflictRegistry } from '@causl/sync/conflict';
A PR that crosses a ceiling fails the bundle-size gate. The §14.2 narrative pins the trade-off: a smaller bundle buys a smaller install footprint; the ceiling rules out unilateral growth.
When async/sync is the right tool — and when it isn't
@causl/sync is the right fit when you have multiple async
resources whose results can interleave, when staleness matters (an old
result must not overwrite a new one), and when you want compile-time
narrowing instead of optional-field discipline. Pair it with
@causl/react's useCauslNode for Suspense
integration and the Promise-identity guarantee buys you stable
SuspenseList ordering.
It is not a replacement for a fetch library: there is no caching,
no deduplication of identical requests across keys, no retry policy. Those
live in your loader. @causl/sync's contribution is the
lifecycle and the race-row coverage; the network stays your problem.
Next: see the API reference for the full
ResourceHandle and ConflictRegistry surfaces,
or move on to the persistence guide for snapshotting graphs that contain
live resources.