Skip to content
PACKAGES

One Primitive, Two Tiers, Zero Schedulers

Legacy TypeScript website content. Shared public website, blog, protocol, guide, and language-neutral docs ownership now lives in ~/src/graphrefly under D563. This page is retained here only as migration/reference material while the TS API generator still lives in website/.

Arc 4, Post 13 — Architecture v3: The Type 3 Breakthrough


The core API is intentionally small. One primitivenode — covers how data enters the graph, transforms, and exits. Sugar constructors give you ergonomic entry points without adding conceptual surface area:

ConstructorRoleUnder the hood
state()Ergonomic source — set / update, TC39-friendly equalsnode with source defaults
derived()Computed store sugar — sync multi-dep transformnode with operator shape
producer()Async boundary — emit, signal, lifecyclenode with source + lifecycle
effect()Terminal sink — runs when deps resolve, no downstream storenode with sink shape

In the predecessor (callbag-recharge), these were five separate primitives: producer, state, operator, derived, and effect. GraphReFly evolved this into a single node primitive with sugar constructors that configure it for each role. The mental model shifts from “pick the right primitive” to “configure one primitive for your use case.”

dynamicNode() fits beside derived(): same operator lineage, but dependencies are discovered at runtime via tracking reads — still a transform, not a separate conceptual axis (source / transform / sink).

  • Tier 1 — synchronous transforms, static dependency lists, full DIRTY / RESOLVED / DATA protocol. Use derived() (or configure node directly). Diamond resolution and bitmask logic live here.
  • Tier 2 — timers, promises, inner subscriptions, dynamic upstream. Use producer() with autoDirty: true and imperative subscribe() inside the producer body. Tier-2 nodes start fresh DIRTY+DATA cycles per emission; they do not inherit upstream two-phase STATE the same way tier-1 nodes do.

The split is how we keep RxJS-shaped async operators without pretending they are the same animal as a pure map. Async boundaries are producer-shaped; sync graph logic stays derived-shaped.

There is no enqueueEffect, no global tick, no queueMicrotask layer deciding order. When all dirty deps of an effect have resolved (DATA or RESOLVED), the effect function runs inline, synchronously, in the same call stack as the resolution — deterministic ordering, glitch-friendly batching, and no hidden microtask priority inversions.

For single-dep reactions that do not need DIRTY/RESOLVED bookkeeping, subscribe stays the lightweight DATA sink.

Everything is node. state() is not a parallel implementation — it rides node with defaults users expect (Object.is, set(same) no-op semantics). producer() unifies “event stream,” “async boundary,” and “bare metal source” so we are not maintaining three competing source classes. derived() and effect() configure the same node for transform and sink roles respectively.

This single-primitive design means the protocol implementation exists in one place. Sugar constructors are thin — they set flags and defaults, not alternate code paths.


Chronicle continues with Output Slot: How null->fn->Set Saves 90% Memory — our first Arc 5 deep dive.