lazily 0.4.0
lazily: ^0.4.0 copied to clipboard
Lazy reactive primitives for Dart — Slots, Cells, and Signals with automatic dependency tracking and cache invalidation, plus the lazily-spec wire protocol for mirroring graph state across processes a [...]
lazily (Dart) #
Lazy reactive primitives for Dart — Slots, Cells, and Signals with automatic dependency tracking and cache invalidation. Pure Dart: Flutter, web, and native.
A port of the lazily reactive family (lazily-rs, lazily-py,
lazily-js, lazily-zig).
The reactive family #
- Slot — a lazily-computed cached value that automatically tracks its dependencies and recomputes only when read after an upstream change.
- Cell — a mutable source value that invalidates dependent Slots/Signals when it changes.
- Signal — an eager derived value that recomputes the instant a dependency changes, with no intermediate unset value.
Values are lazy by default. When you need eager push-style semantics, reach
for Signal.
Usage #
import 'package:lazily/lazily.dart';
final ctx = Context();
final a = Cell<int>(ctx, 2);
final b = Cell<int>(ctx, 3);
// Lazy: computes on first read, caches, recomputes only when a or b changes.
final sum = Slot<int>(ctx, (_) => a.value + b.value);
print(sum()); // 5
a.value = 10;
print(sum()); // 13
// Eager: recomputes immediately when a dependency changes.
final parity = Signal<String>(ctx, (_) => a.value.isEven ? 'even' : 'odd');
print(parity.value); // even
a.value = 11;
print(parity.value); // odd (already updated before the read)
A [Cell] also supports persistent observers (the hook for Flutter
ValueNotifier bridges and setState wrappers):
final count = Cell<int>(ctx, 0);
final dispose = count.subscribe((v) => print('now $v'));
count.value = 1; // prints "now 1"
dispose();
Context #
All reactives that react to each other must share a Context. The context
holds an identity-keyed cache and the computation stack used for automatic
dependency tracking. One context per reactive graph is the contract.
State machine #
StateMachine is a finite state machine backed by a Cell, so any slot or
signal that reads state is invalidated on transition:
final m = StateMachine<String, String>(
ctx, 'Red',
(s, e) => e == 'advance'
? const {'Red': 'Green', 'Green': 'Yellow', 'Yellow': 'Red'}[s]
: null,
);
m.send('advance'); // true -> 'Green'
State chart #
StateChart is a full Harel/SCXML hierarchical state machine — the native
counterpart of lazily-formal's LazilyFormal.StateChart and
lazily-rs's / lazily-kt's state charts. It is compute, not protocol: it is
never serialized as a distinct wire kind. The active configuration lives in a
Cell, so any slot or signal reading configuration, activeLeaves, or
matches is invalidated on a real transition; a no-op (configuration
unchanged) is suppressed by the cell's structural-equality guard.
send is deterministic by construction — a total function of
(chart, configuration, history, guards, event), mirroring the Lean
StateChart.send. A chart is built from the declarative JSON form
(lazily-spec/docs/state-charts.md) via ChartDef.fromJson.
Implemented subset (per the spec's implementation-status note): compound
(hierarchical) states with default initial descent, orthogonal (parallel)
regions, shallow and deep history (record-on-exit / restore-on-enter),
entry/exit/transition actions (exit innermost-first → transition → entry
outermost-first), named guards resolved at send time (fail-closed), and
external + internal transitions. run actions and {"expr": …} context
guards are rejected explicitly. final states are accepted as leaves without
raising completion (done) events, matching lazily-py and lazily-kt.
import 'package:lazily/lazily.dart';
final def = ChartDef.fromJson({
'initial': 'on',
'states': {
'root': {'initial': 'on'},
'on': {
'parent': 'root', 'initial': 'playing',
'on': {'toggle': 'off'}, // handled by 'on', bubbles from a child
},
'playing': {'parent': 'on', 'on': {'pause': 'paused'}},
'paused': {'parent': 'on', 'on': {'play': 'playing'}},
'off': {'parent': 'root', 'on': {'toggle': 'on'}}, // re-enters -> 'playing'
},
});
final chart = StateChart(ctx, def);
chart.activeLeaves(); // ['playing']
chart.send('toggle'); // true; off -> on -> playing (initial)
chart.matches('on'); // true
chart.lastActions(); // exit → transition → entry actions
Conformance #
lazily-dart replays the shared lazily-spec conformance fixtures:
- State-chart fixtures mirrored into
test/conformance/statechart/are replayed bytest/statechart_conformance_test.dart, assertingaccepted,active,matches, andactionsidentically to every other binding (lazily-rs / lazily-kt / lazily-py / lazily-zig / lazily-js). When the siblinglazily-speccheckout is present on disk, the canonical fixtures are preferred, so this harness also guards against cross-family drift.
lazily-spec IPC #
The package:lazily/ipc.dart library implements the language-agnostic
lazily-spec wire protocol (Snapshot, Delta, NodeState, ...) so a Dart
graph's state can be mirrored to remote observers across processes and
languages. It round-trips the canonical fixtures from
lazily-spec/conformance/.
Status #
Conforms to the full lazily-spec Binding Conformance Matrix — every MUST layer is implemented:
| Layer | Where |
|---|---|
Reactive core (Cell / Slot / Signal) |
package:lazily/lazily.dart |
Keyed cell collections (CellMap / CellTree / reconciliation) |
package:lazily/lazily.dart |
| Flat state machine | package:lazily/lazily.dart |
| Harel state charts | package:lazily/lazily.dart |
| Async reactive context | package:lazily/async_context.dart |
IPC (Snapshot + Delta + CrdtSync) |
package:lazily/ipc.dart |
Distributed CRDT plane (CrdtSync / WireStamp / Hlc / StampFrontier) |
package:lazily/ipc.dart |
C-ABI FFI boundary (LazilyFfi*, CrdtSync = 3) |
package:lazily/ffi.dart |
Permission boundary (RemoteOp / PeerPermissions) |
package:lazily/ipc.dart |
| Capability negotiation | package:lazily/capability.dart |
Distributed sync wiring to live merge: crdt root cells, WebRTC transports,
and the deeper CRDT collection layers (SemTree, SeqCrdt, TextCrdt, StableId)
are not ported yet — see lazily-rs for the full feature set.
Keyed cell collections #
CellMap<K, V> is a composition of cells, not a new cell kind. Each entry
is an ordinary Cell; a dedicated membership cell tracks the key set, and a
dedicated order cell tracks the ordered key list, so the three reactivity
planes are independent:
- writing one entry's value invalidates only that entry's value readers;
- adding/removing a key invalidates membership readers (
len/containsKey) and order readers (keys), but not unrelated entry value readers; - a pure reorder (atomic move) invalidates order readers only.
final ctx = Context();
final scores = CellMap<String, int>(ctx)
..set('alice', 10)
..set('bob', 20);
final leaderboard = Slot<List<String>>(ctx, (_) => scores.keys());
leaderboard(); // ['alice', 'bob']
scores.moveTo('bob', 0);
leaderboard(); // ['bob', 'alice'] — recomputed (order changed)
reconcileDiff is the move-minimized keyed reconciliation (#lzkeyrecon):
diffs two keyed sequences by stable key and emits the minimal
{insert, remove, move, update} op set, holding the longest-increasing
subsequence fixed so keys already in relative order do not move.
CellTree<K, V> is the ordered keyed tree — each node is
(stable id, value cell, ordered keyed child collection), inheriting per-level
reactivity and the atomic-move guarantee.
Distributed CRDT plane #
The CRDT plane rides the same lazily-ipc transport as Snapshot/Delta:
CrdtSync is a third IpcMessage variant. State-based and idempotent —
out-of-order, duplicated, or batched delivery all converge.
import 'package:lazily/ipc.dart';
final a = CrdtPlane(1);
final stamp = a.tick(12345); // local event
final op = CrdtOp.newOp(nodeId, stamp, [1, 2, 3]); // state to merge
final frame = CrdtSync(
frontier: a.frontier.toWire(), // per-peer stamp frontier
ops: [op],
);
// → IpcMessage.ofCrdtSync(frame).encodeJson() is the wire form.
StampFrontier.merge is commutative, associative, idempotent; the
causal-stability watermark (stabilityWatermark) is the min over
membership — only once every replica has observed a tombstone may it be
collected (isCollectable).
FFI boundary #
package:lazily/ffi.dart exposes LazilyFfiBytes, LazilyFfiStatus, and
LazilyFfiMessageKind (with CrdtSync = 3). A frame is just serialized
IpcMessage bytes; the channel decodes each accepted frame as IpcMessage
and re-encodes canonical JSON.
import 'package:lazily/ffi.dart';
import 'package:lazily/ipc.dart';
final frame = LazilyFfiBytes(IpcMessage.ofCrdtSync(sync).encodeJson());
final c = lazilyFfiKindJson(frame);
expect(c.kind, LazilyFfiMessageKind.crdtSync);
lazily-dart declares the ffi = host capability (Dart has dart:ffi).
Capability negotiation #
package:lazily/capability.dart ships the compatibility handshake. Peers fail
closed on protocol_id, protocol_major_version, codec,
ordered_reliable, or a required feature the other does not offer.
Async reactive context #
package:lazily/async_context.dart is a separate reactive surface for
async/future-returning computations, with the full
Empty → Computing → Resolved | Error state machine, revision tracking (stale
completions discarded), in-flight deduplication, the re-resolve contract, an
equality memoAsync guard, serialized async effects (cleanup-before-body),
batching, and disposal.
Development #
dart pub get
dart analyze --fatal-infos
dart test
See also #
lazily-spec— language-agnostic wire protocol + the conformance fixtures (IPC and state-chart) every binding replays.lazily-formal— Lean 4 formal model (shared primitives, the flatStateMachinekernel, and the full HarelStateChart); the executable reference behind the state-chart fixtures and the deterministicsendlazily-dart inherits.lazily-rs/lazily-py/lazily-zig— sibling reactive cores.