@lazily-hub/lazily-js 0.4.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,15 @@ jobs:
24
24
 
25
25
  steps:
26
26
  - uses: actions/checkout@v4
27
+ with:
28
+ path: lazily-js
29
+
30
+ # The conformance / schema tests replay fixtures from the lazily-spec sibling
31
+ # (../lazily-spec). Check it out alongside so `npm test` passes in CI.
32
+ - uses: actions/checkout@v4
33
+ with:
34
+ repository: lazily-hub/lazily-spec
35
+ path: lazily-spec
27
36
 
28
37
  - uses: actions/setup-node@v4
29
38
  with:
@@ -35,9 +44,11 @@ jobs:
35
44
  run: npm install -g npm@latest
36
45
 
37
46
  - name: Install dependencies
47
+ working-directory: lazily-js
38
48
  run: npm ci || npm install
39
49
 
40
50
  - name: Test
51
+ working-directory: lazily-js
41
52
  run: npm test --if-present
42
53
 
43
54
  # Published under the `prev` dist-tag so this v0.x line does not steal
@@ -45,4 +56,14 @@ jobs:
45
56
  # (`npm dist-tag add @lazily-hub/lazily-js@<v> latest`) once the accidental
46
57
  # v1.x releases are removed.
47
58
  - name: Publish
48
- run: npm publish --access public --tag prev
59
+ working-directory: lazily-js
60
+ run: |
61
+ version="$(node -p "require('./package.json').version")"
62
+ if npm publish --access public --tag prev; then
63
+ echo "published @lazily-hub/lazily-js@${version}"
64
+ elif npm view "@lazily-hub/lazily-js@${version}" version >/dev/null 2>&1; then
65
+ echo "@lazily-hub/lazily-js@${version} already published — idempotent no-op"
66
+ else
67
+ echo "publish failed for a reason other than already-published" >&2
68
+ exit 1
69
+ fi
package/BENCHMARKS.md ADDED
@@ -0,0 +1,91 @@
1
+ # lazily-js Benchmark Results
2
+
3
+ Generated benchmark data for the
4
+ [`@lazily-hub/lazily-js`](https://www.npmjs.com/package/@lazily-hub/lazily-js)
5
+ reactive primitives library.
6
+
7
+ ## Benchmark Results
8
+
9
+ The suite is a 1:1 port of the single-threaded `Context` benchmarks in
10
+ lazily-rs's `benches/context.rs` — the same group/case names and the same
11
+ widths/depths (`FAN_OUT_WIDTHS=[32,256]`, `MEMO_CHAIN_DEPTH=32`,
12
+ `BATCH_STORM_CELLS=64`, `SET_CELL_INVALIDATION_FAN_OUT=512`) so JS and Rust
13
+ numbers are directly comparable.
14
+
15
+ JS runs on a single event-loop thread, so the lazily-rs `ThreadSafeContext`
16
+ multi-worker contention benchmarks (`thread_safe_contention`,
17
+ `thread_safe_effect_contention`, `thread_safe_graph_propagation`) have no
18
+ like-for-like single-process counterpart here and are intentionally omitted.
19
+
20
+ <!-- benchmark-results:start -->
21
+
22
+ Generated for package `@lazily-hub/lazily-js` version `0.6.0`.
23
+
24
+ Environment: Node.js `26.4.0` on `linux x64`.
25
+
26
+ Refresh command:
27
+
28
+ ```bash
29
+ node scripts/run-benchmarks.mjs
30
+ ```
31
+
32
+ Mean wall-clock time per iteration; 95% CI half-width from the standard error.
33
+
34
+ | Group | Case | Mean | 95% CI | p75 | p99 | Samples |
35
+ |---|---|---:|---:|---:|---:|---:|
36
+ | cached_reads | context | 57.798 ns | ± 20.941 ns | 46.398 ns | 124.744 ns | 100 |
37
+ | cold_first_get | context | 669.220 ns | ± 8.630 ns | 670.000 ns | 802.610 ns | 100 |
38
+ | dependency_fan_out | context / 32 | 7.448 us | ± 824.968 ns | 6.783 us | 34.205 us | 100 |
39
+ | dependency_fan_out | context / 256 | 43.766 us | ± 1.064 us | 43.270 us | 49.513 us | 100 |
40
+ | set_cell_invalidation | high_fan_out / 512 | 10.737 us | ± 376.524 ns | 10.113 us | 17.141 us | 100 |
41
+ | memo_equality_suppression | context | 2.387 us | ± 193.801 ns | 2.310 us | 2.864 us | 100 |
42
+ | effect_flushing | context | 211.187 ns | ± 20.150 ns | 221.482 ns | 524.491 ns | 100 |
43
+ | batch_storms | context / 64 | 30.430 us | ± 1.295 us | 30.815 us | 49.775 us | 99 |
44
+ | typed_cache_reads | context_cell | 31.325 ns | ± 1.052 ns | 30.255 ns | 58.102 ns | 100 |
45
+ | typed_cache_reads | context_slot | 120.262 ns | ± 4.805 ns | 118.099 ns | 220.409 ns | 100 |
46
+
47
+ <!-- benchmark-results:end -->
48
+
49
+ ## Suite
50
+
51
+ | Group | Case | Parity with lazily-rs | What it measures |
52
+ |-------|------|-----------------------|------------------|
53
+ | `cached_reads` | `context` | `bench_cached_reads` / context | Steady-state cached `ctx.get(slot)` (no recompute). |
54
+ | `cold_first_get` | `context` | `bench_cold_first_get` / context | First (uncached) read of a freshly built slot. |
55
+ | `dependency_fan_out` | `context / {32,256}` | `bench_dependency_fan_out` / context | One root cell invalidates N dependents, then all are re-read. |
56
+ | `set_cell_invalidation` | `high_fan_out / 512` | `bench_set_cell_invalidation` / high_fan_out | Cost of `setCell` invalidating a 512-wide fan-out (no recompute). |
57
+ | `memo_equality_suppression` | `context` | `bench_memo_equality_suppression` / context | Memo chain that stays equal downstream (guard suppresses recompute). |
58
+ | `effect_flushing` | `context` | `bench_effect_flushing` / context | Effect re-runs on every cell change. |
59
+ | `batch_storms` | `context / 64` | `bench_batch_storms` / context | Coalesced batched writes to 64 cells (one effect flush). |
60
+ | `typed_cache_reads` | `context_slot`, `context_cell` | `bench_typed_cache_reads` / context_* | Direct `ctx.get(slot)` vs `ctx.getCell(cell)`. |
61
+
62
+ ### Running
63
+
64
+ ```bash
65
+ npm run bench # run the suite, print a markdown table
66
+ node scripts/run-benchmarks.mjs # run + refresh this file
67
+ node scripts/run-benchmarks.mjs --check # CI gate: exit 1 if stale
68
+ make bench # via the Makefile
69
+ ```
70
+
71
+ ### Harness
72
+
73
+ `bench/harness.mjs` is a zero-dependency criterion-style harness
74
+ (`node:perf_hooks` only). It offers two measurement modes:
75
+
76
+ - **`bench(group, case, fn)`** — auto-batched timing for *idempotent* routines
77
+ (each sample times N calls, N auto-scaled so one sample lasts ~5 ms). Use for
78
+ steady-state reads / repeated-equal-cost work.
79
+ - **`bench.batched(group, case, setup, routine)`** — `iter_batched` timing
80
+ (criterion `BatchSize::SmallInput`): fresh `setup()` before every single
81
+ measured call, setup excluded from timing. Use when the operation mutates graph
82
+ state a second call would skip — e.g. the `==` guard on `setCell`, or a cold
83
+ first read that caches on first access.
84
+
85
+ ### Regression workflow
86
+
87
+ ```bash
88
+ node scripts/run-benchmarks.mjs # record "before" baseline in BENCHMARKS.md
89
+ # apply the performance patch
90
+ node scripts/run-benchmarks.mjs # compare against the new numbers
91
+ ```
package/README.md CHANGED
@@ -6,6 +6,9 @@ is the JavaScript/TypeScript binding alongside [`lazily-rs`][rs],
6
6
  [`lazily-dart`][dart]. It ships:
7
7
 
8
8
  - a full reactive graph (`Context`, `Cell`, `Slot`, `Signal`, `Effect`);
9
+ - an async reactive graph (`AsyncContext`) for Promise-driven derivations, with
10
+ revision-guarded stale-completion discard, in-flight deduplication, and
11
+ cancellation;
9
12
  - the [`lazily-spec`][spec] IPC wire types (`Snapshot`, `Delta`, `CrdtSync`,
10
13
  capability negotiation, and default-deny peer permissions);
11
14
  - keyed cell collections, move-minimized reconciliation, and a memoized
@@ -13,6 +16,9 @@ is the JavaScript/TypeScript binding alongside [`lazily-rs`][rs],
13
16
  - move-aware sequence CRDT, Fugue/RGA text CRDT, and manufactured text identity;
14
17
  - a Cell-backed flat state machine plus a full Harel/SCXML state-chart
15
18
  interpreter with the typed `ChartBuilder` API;
19
+ - the distributed plane — the WebSocket signaling protocol + client, a
20
+ `DataChannel` transport seam with permission-filtering sink/source, a browser
21
+ `RTCPeerConnection` adapter, and the `CrdtPlaneRuntime` anti-entropy engine;
16
22
  - a koffi-backed state-projection consumer for agent-doc host projections.
17
23
 
18
24
  > **Package note.** Earlier `@lazily-hub/js` releases were only a state-projection
@@ -24,26 +30,42 @@ the FFI projection transport is used.
24
30
 
25
31
  ## Feature Set
26
32
 
27
- The full `lazily` capability set and its cross-language coverage (`lazily-rs`,
28
- `lazily-kt`, `lazily-js`). `✅` shipped, `~` partial, `—` not applicable/absent.
29
-
30
- | Feature | Rust | Kotlin | JS |
31
- |---------|:----:|:------:|:--:|
32
- | Reactive graph — `Context`, `Slot`, `Cell`, `memo`, `Signal` (eager), `Effect`, `batch` | ✅ | ✅ | ✅ |
33
- | Thread-safe `Context` (`Send + Sync`, lock-backed) | | | |
34
- | Async reactive `Context` | | | |
35
- | Statechart (Harel) + state machine | ✅ | ✅ | ✅ |
36
- | Keyed cell collections + `reconcile` + `SemTree` (keyed tree) | ✅ | ✅ | ✅ |
37
- | Stable-id alignment (manufactured identity) | ✅ | ✅ | ✅ |
38
- | Free-text character CRDT (`TextCrdt`) | ✅ | ✅ | ✅ |
39
- | **`TextCrdt` delta sync `version_vector` / `delta_since` / `apply_delta` (`#lztextsync`)** | ✅ | ✅ | ✅ |
40
- | Move-aware sequence CRDT (`SeqCrdt`) | ✅ | ✅ | ✅ |
41
- | Registers (LWW / MV), `PnCounter`, `CellCrdt` | ✅ | ✅ | ✅ |
42
- | IPC wire `Snapshot` + `Delta` + `CrdtSync` + shared-memory blobs | ✅ | ✅ | ~ |
43
- | State projection / mirror | | | |
44
- | FFI boundary | ✅ | ✅ | n/a |
45
- | Distributed plane (WebRTC transport + signaling) | ✅ | | |
46
- | Instrumentation / benchmarks | ✅ | | |
33
+ The full `lazily` capability set across every binding. Legend: ✅ shipped ·
34
+ `~` partial · `—` absent or not applicable. The canonical matrix with per-cell
35
+ notes and platform carve-outs lives in
36
+ [`lazily-spec` § Cross-Language Coverage](../lazily-spec/docs/coverage.md).
37
+
38
+ <!-- coverage-table:start -->
39
+ | Feature | Rust | Python | Kotlin | JS | Dart | Zig | Go | C++ |
40
+ | --------- | :----: | :------: | :------: | :--: | :----: | :---: | :--: | :---: |
41
+ | Reactive graph `Cell` / `Slot` / `Signal` / `Effect` / memo / batch | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
42
+ | Thread-safe context (lock-backed) | | | | — | — | ✅ | ✅ | ✅ |
43
+ | Async reactive context | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
44
+ | Flat state machine | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
45
+ | Harel state charts | | | | | ✅ | ✅ | ✅ | ✅ |
46
+ | Keyed cell collections (`CellMap` / `CellTree`) + reconcile | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
47
+ | Memoized semantic tree (`SemTree`) | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
48
+ | Stable-id alignment (manufactured identity) | | | | | ✅ | ✅ | | ✅ |
49
+ | Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) | | | | — | — | — | — | — |
50
+ | Free-text character CRDT (`TextCrdt`) | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ |
51
+ | `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | | | ✅ | ✅ | ✅ | ✅ | ✅ |
52
+ | Move-aware sequence CRDT (`SeqCrdt`) | ✅ | | | ✅ | ✅ | ✅ | ✅ | ✅ |
53
+ | Lossless tree CRDT core (`LosslessTreeCrdt`, M1) | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
54
+ | Lossless tree — dotted-frontier anti-entropy | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
55
+ | Lossless tree — concurrent merge convergence | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
56
+ | Registers (LWW / MV) + `PnCounter` + `CellCrdt` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
57
+ | IPC wire — `Snapshot` + `Delta` + `CrdtSync` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
58
+ | Shared-memory blob path (`ShmBlobArena`) | ✅ | ✅ | ✅ | ~ | ~ | ✅ | ✅ | ✅ |
59
+ | Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
60
+ | Distributed plane — WebRTC transport + signaling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
61
+ | State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
62
+ | Causal receipts (`CausalReceipts` outcome projection) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
63
+ | Message-passing + RPC command plane (`command-plane-v1`) | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
64
+ | C-ABI FFI boundary | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ |
65
+ | Permission boundary (`PeerPermissions` / `RemoteOp`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
66
+ | Capability negotiation (`SessionHandshake`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
67
+ | Instrumentation / benchmarks | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ |
68
+ <!-- coverage-table:end -->
47
69
 
48
70
  CRDT convergence and the wire protocol are pinned by the shared conformance fixtures
49
71
  and JSON Schemas in `lazily-spec` and the Lean models in `lazily-formal`.
@@ -53,6 +75,7 @@ and JSON Schemas in `lazily-spec` and the Lean models in `lazily-formal`.
53
75
  |--------|------------|
54
76
  | `@lazily-hub/lazily-js` | `lazily-spec` IPC wire types: `Snapshot`, `Delta`, `DeltaOp`, `IpcMessage` (`Snapshot` / `Delta` / `CrdtSync`), `NodeState`, `IpcValue`, `PeerPermissions`, `SessionHandshake`, `BINDING_CAPABILITIES` |
55
77
  | `@lazily-hub/lazily-js/reactive` | Reactive dependency graph: `Context`, `Cell`, `Slot`, `Signal`, `Effect` |
78
+ | `@lazily-hub/lazily-js/reactive-async` | Async reactive graph: `AsyncContext` — Promise-driven slots/effects with revision-guarded stale-completion discard, in-flight dedup, and cancellation |
56
79
  | `@lazily-hub/lazily-js/state-machine` | Flat finite-state-machine kernel backed by a reactive `Cell` |
57
80
  | `@lazily-hub/lazily-js/statechart` | Harel/SCXML chart interpreter plus `ChartBuilder`, `StateBuilder`, `TransitionBuilder` |
58
81
  | `@lazily-hub/lazily-js/collections` | `CellMap`, `CellTree`, keyed reconciliation, and LIS move minimization |
@@ -60,6 +83,8 @@ and JSON Schemas in `lazily-spec` and the Lean models in `lazily-formal`.
60
83
  | `@lazily-hub/lazily-js/seq-crdt` | Move-aware sequence CRDT using independent LWW value / position / deletion registers |
61
84
  | `@lazily-hub/lazily-js/text-crdt` | Fugue/RGA character CRDT |
62
85
  | `@lazily-hub/lazily-js/stable-id` | Manufactured text identity: anchors, content hashes, similarity alignment |
86
+ | `@lazily-hub/lazily-js/signaling` | WebSocket signaling protocol: `ClientMessage` / `ServerMessage`, `SignalingClient`, `SignalingRoom` routing (anti-spoof, roster), `SignalingPermissions` |
87
+ | `@lazily-hub/lazily-js/distributed` | Distributed plane: `DataChannel` seam + `InMemoryDataChannel`, `WebRtcSink` / `WebRtcSource`, `CrdtPlaneRuntime` anti-entropy, and the browser `RTCPeerConnection` adapter |
63
88
  | `@lazily-hub/lazily-js/state-projection` | koffi FFI consumer for agent-doc `DocumentStateProjection` |
64
89
 
65
90
  ## Reactive graph
@@ -96,6 +121,40 @@ ctx.setCell(a, 11);
96
121
  ctx.getSignal(parity); // "odd", already materialized
97
122
  ```
98
123
 
124
+ ## Async reactive context
125
+
126
+ `AsyncContext` (from `@lazily-hub/lazily-js/reactive-async`) is a **separate**
127
+ reactive surface for derivations whose values are produced by `async` functions.
128
+ It is not an overload of the synchronous `Context`: futures introduce in-flight
129
+ state, stale completion, cancellation, and dependency tracking across `await`
130
+ that the synchronous graph does not have. Cells remain the synchronous input
131
+ layer; computed slots, memos, and effects are async.
132
+
133
+ Each async slot runs an `Empty → Computing → Resolved/Error` state machine with
134
+ **revision-guarded publish** (a completion is published only if the slot's
135
+ revision is still current, so a stale result is discarded), **in-flight
136
+ deduplication** (concurrent `getAsync` callers share one compute), and
137
+ cooperative cancellation. Async effects serialize reruns and always run the
138
+ previous cleanup before the next body.
139
+
140
+ ```js
141
+ import { AsyncContext } from "@lazily-hub/lazily-js/reactive-async";
142
+
143
+ const ctx = new AsyncContext();
144
+ const userId = ctx.cell(1);
145
+
146
+ const profile = ctx.computedAsync(async (cctx) => {
147
+ const id = cctx.getCell(userId); // dependency registered before the await
148
+ return await fetchProfile(id);
149
+ });
150
+
151
+ await ctx.getAsync(profile); // spawns the compute, awaits the value
152
+ ctx.get(profile); // synchronous cached read once resolved (undefined while pending)
153
+
154
+ ctx.setCell(userId, 2); // supersedes any in-flight compute; slot re-resolves
155
+ await ctx.getAsync(profile); // the profile for user 2
156
+ ```
157
+
99
158
  ## State machine and state charts
100
159
 
101
160
  `StateMachine` is the flat finite-state-machine kernel: a pure
@@ -280,17 +339,54 @@ snapshot.filterReadable(permissions, 11).nodes.length; // 0
280
339
 
281
340
  `BINDING_CAPABILITIES` advertises the JS binding truthfully: reactive core,
282
341
  IPC, CRDT, keyed collections, semantic tree, sequence/text CRDT, stable-id,
283
- state machine, state charts, permissions, and capability negotiation are
284
- shipped; C-ABI FFI is `none` because browser/Worker JS cannot host a native
285
- in-process ABI; async context, signaling, and WebRTC transports are not shipped
286
- by this package. The same payload types can still be carried by any transport a
287
- host application owns.
342
+ state machine, state charts, permissions, capability negotiation, async context,
343
+ signaling, and the WebRTC transport are shipped; C-ABI FFI is `none` because
344
+ browser/Worker JS cannot host a native in-process ABI. The same payload types
345
+ can still be carried by any transport a host application owns.
346
+
347
+ ## Distributed plane
348
+
349
+ The `@lazily-hub/lazily-js/signaling` and `@lazily-hub/lazily-js/distributed`
350
+ entry points ship the distributed plane. Signaling is the kebab-tagged discovery
351
+ wire protocol (`ClientMessage` / `ServerMessage`), a transport-agnostic
352
+ `SignalingRoom` that enforces the anti-spoof forwarded-`from` invariant, and a
353
+ `SignalingClient` over a pluggable socket seam. The distributed module is the
354
+ WebRTC DataChannel IPC transport (`WebRtcSink` / `WebRtcSource` with outbound
355
+ permission filtering, over any `DataChannel`) plus `CrdtPlaneRuntime`, the CRDT
356
+ anti-entropy runtime. Both are koffi-free and testable over an in-memory loopback
357
+ with zero network; "real" WebRTC is reached through a browser platform adapter
358
+ (`RtcPeerChannel` / `RtcPeerConnector`) that wraps the `RTCDataChannel` /
359
+ `RTCPeerConnection` globals with no npm dependency.
360
+
361
+ ```js
362
+ import { CrdtPlaneRuntime, InMemoryDataChannel, WebRtcSink, WebRtcSource } from "@lazily-hub/lazily-js/distributed";
363
+ import { SignalingRoom, ClientMessage } from "@lazily-hub/lazily-js/signaling";
364
+ import { IpcMessage, IpcValue, PeerPermissions, OpKind, Snapshot, NodeSnapshot } from "@lazily-hub/lazily-js";
365
+
366
+ // Route a signaling handshake with server-stamped `from` (anti-spoof).
367
+ const room = new SignalingRoom();
368
+ room.receive("a", ClientMessage.join(1));
369
+ room.receive("b", ClientMessage.join(2));
370
+ room.receive("a", ClientMessage.offer(2, "SDP-A")); // -> { to: "b", message: offer{ from: 1, sdp: "SDP-A" } }
371
+
372
+ // Two replicas converge over an anti-entropy exchange.
373
+ const alice = new CrdtPlaneRuntime(1);
374
+ const bob = new CrdtPlaneRuntime(2);
375
+ alice.register(1, "doc/title");
376
+ bob.register(1, "doc/title");
377
+ const op = alice.localUpdate(1, Date.now() * 1000, IpcValue.inline([66]));
378
+ bob.ingest(alice.syncFrame(), Date.now() * 1000); // 1 op applied; re-ingest applies 0
379
+ ```
288
380
 
289
381
  ## Conformance
290
382
 
291
383
  lazily-js replays the shared `lazily-spec` fixtures for IPC, agent-doc state,
292
384
  keyed collections (`CellMap`, `CellTree`, LIS reconciliation), semantic tree,
293
- sequence and text CRDTs, manufactured text identity, and Harel state charts.
385
+ sequence and text CRDTs (incl. `TextCrdt` delta sync, `#lztextsync`:
386
+ `textcrdt_convergence.json` + `textcrdt_delta_sync.json`), manufactured text
387
+ identity, Harel state charts, the signaling protocol (`signaling/frames.json`,
388
+ `signaling/anti_spoof_session.json`), and the distributed CRDT plane
389
+ (`distributed/crdt_sync_frames.json`, `distributed/anti_entropy_converge.json`).
294
390
  It also validates generated wire values against the canonical JSON Schemas.
295
391
 
296
392
  `npm test` builds the [`lazily-formal`][formal] Lean 4 model when that sibling
@@ -309,9 +405,15 @@ names the Lean theorems it mirrors:
309
405
  | `Collection` | `collection-properties.test.js` | `setEntryValue_preserves_{membership,order,siblings}`, `moveKey_preserves_{membership,values}`, `moveKey_advances_order`, `addKey_advances_membership_and_order`, `Family.get_idempotent_after_first` |
310
406
  | `Tree` | `tree-properties.test.js` | `setNodeValue_preserves_{other_nodes,node_signals}`, `moveChild_preserves_{non_parent,parent_value}`, `moveChild_advances_order_signal_only` |
311
407
  | `Reconciliation` | `reconciliation-properties.test.js` | `lisBy_longest`, `reconcile_move_minimized`, `reconcile_stable_not_invalidated` |
408
+ | `AsyncSlotState` | `reactive-async.test.js` | `stale_completeOk_discarded`, `current_completeOk_publishes`, `current_completeErr_to_error` |
409
+ | `AsyncEffect` | `reactive-async.test.js` | `fire_blocked_during_cleanup`, `invalidate_from_idle_schedules`, `cleanupDone_resumes_deferred`, `dispose_absorbing`, `disposed_terminal` |
312
410
 
313
- `AsyncSlotState`, `AsyncEffect`, and thread-safe context theorems are not mirrored
314
- because lazily-js does not ship async or thread-safe context surfaces.
411
+ Thread-safe context theorems are not mirrored because JavaScript is
412
+ single-threaded (one event loop): a lock-backed context has no meaning on this
413
+ runtime — the concurrency-layer platform carve-out. The `Signaling` /
414
+ `SignalingRoster` formal models are exercised through the `SignalingRoom`
415
+ fixture replay (`signaling/anti_spoof_session.json`) rather than a named-theorem
416
+ property test.
315
417
 
316
418
  ## The lazily family
317
419
 
@@ -319,7 +421,7 @@ because lazily-js does not ship async or thread-safe context surfaces.
319
421
  |---------|----------|----------------|
320
422
  | [`lazily-rs`][rs] | Rust | `lazily` on crates.io; single-threaded, thread-safe, and async context layers |
321
423
  | [`lazily-py`][py] | Python | `lazily` on PyPI; dict-backed context plus IPC/shared-blob host types |
322
- | **`lazily-js`** | JavaScript / TypeScript | `@lazily-hub/lazily-js`; reactive core, spec wire types, state charts, CRDTs |
424
+ | **`lazily-js`** | JavaScript / TypeScript | `@lazily-hub/lazily-js`; reactive core + async context, spec wire types, state charts, CRDTs, distributed plane (signaling + WebRTC) |
323
425
  | [`lazily-zig`][zig] | Zig | Zig library / FFI-oriented embedding surface |
324
426
  | [`lazily-kt`][kt] | Kotlin/JVM | Kotlin reactive core plus typed state charts |
325
427
  | [`lazily-dart`][dart] | Dart | Dart binding with statechart conformance |
@@ -0,0 +1,179 @@
1
+ // Reactive-graph benchmarks for lazily-js — parity port of lazily-rs
2
+ // `benches/context.rs` (the single-threaded `Context` variants).
3
+ //
4
+ // JS runs on a single event-loop thread, so the lazily-rs `ThreadSafeContext`
5
+ // multi-worker contention benchmarks (thread_safe_contention,
6
+ // thread_safe_effect_contention, thread_safe_graph_propagation) have no
7
+ // like-for-like single-process counterpart here and are intentionally omitted.
8
+ // Every group/case below is a 1:1 port of the matching `context`-variant in
9
+ // lazily-rs, using the same widths/depths so numbers are cross-language
10
+ // comparable. Constants mirror `benches/context.rs` exactly.
11
+
12
+ import { bench, blackBox, run } from "./harness.mjs";
13
+ import { Context } from "../src/reactive.js";
14
+
15
+ const FAN_OUT_WIDTHS = [32, 256];
16
+ const MEMO_CHAIN_DEPTH = 32;
17
+ const BATCH_STORM_CELLS = 64;
18
+ const SET_CELL_INVALIDATION_FAN_OUT = 512;
19
+
20
+ // --- setup helpers (ports of setup_context_* in benches/context.rs) ---------
21
+
22
+ function setupFanOut(width) {
23
+ const ctx = new Context();
24
+ const root = ctx.cell(0);
25
+ const slots = [];
26
+ for (let offset = 0; offset < width; offset++) {
27
+ slots.push(ctx.computed(() => (ctx.getCell(root) + offset) >>> 0));
28
+ }
29
+ for (const slot of slots) blackBox(ctx.get(slot));
30
+ return { ctx, root, slots };
31
+ }
32
+
33
+ function setupMemoChain(depth) {
34
+ const ctx = new Context();
35
+ const root = ctx.cell(0);
36
+ let tail = ctx.memo(() => ctx.getCell(root) % 2);
37
+ for (let i = 0; i < depth; i++) {
38
+ const previous = tail;
39
+ tail = ctx.computed(() => (ctx.get(previous) + 1) >>> 0);
40
+ }
41
+ blackBox(ctx.get(tail));
42
+ return { ctx, root, tail };
43
+ }
44
+
45
+ function setupBatchStorm(cellsLen) {
46
+ const ctx = new Context();
47
+ const cells = [];
48
+ for (let idx = 0; idx < cellsLen; idx++) cells.push(ctx.cell(idx));
49
+ let sink = 0;
50
+ const effectCells = cells.slice();
51
+ ctx.effect(() => {
52
+ let total = 0;
53
+ for (const cell of effectCells) total = (total + ctx.getCell(cell)) >>> 0;
54
+ sink = total;
55
+ });
56
+ return { ctx, cells, getSink: () => sink };
57
+ }
58
+
59
+ // --- cached_reads: steady-state cached slot read ----------------------------
60
+ // Parity: bench_cached_reads "context" in lazily-rs/context.rs.
61
+ // Setup runs once before registration; only the cached `get` is timed.
62
+ {
63
+ const ctx = new Context();
64
+ const root = ctx.cell(21);
65
+ const doubled = ctx.computed(() => ctx.getCell(root) * 2);
66
+ blackBox(ctx.get(doubled)); // prime the cache
67
+ bench("cached_reads", "context", () => blackBox(ctx.get(blackBox(doubled))));
68
+ }
69
+
70
+ // --- cold_first_get: first (uncached) read of a freshly built slot ---------
71
+ // Parity: bench_cold_first_get "context" (iter_batched — fresh ctx each call).
72
+ bench.batched(
73
+ "cold_first_get",
74
+ "context",
75
+ () => {
76
+ const ctx = new Context();
77
+ const root = ctx.cell(21);
78
+ const doubled = ctx.computed(() => ctx.getCell(root) * 2);
79
+ return { ctx, doubled };
80
+ },
81
+ ({ ctx, doubled }) => blackBox(ctx.get(blackBox(doubled))),
82
+ );
83
+
84
+ // --- dependency_fan_out: one root cell invalidates N dependents ------------
85
+ // Parity: bench_dependency_fan_out "context / {32,256}" (iter_batched).
86
+ for (const width of FAN_OUT_WIDTHS) {
87
+ bench.batched(
88
+ "dependency_fan_out",
89
+ `context / ${width}`,
90
+ () => setupFanOut(width),
91
+ ({ ctx, root, slots }) => {
92
+ ctx.setCell(root, blackBox(1));
93
+ let total = 0;
94
+ for (const slot of slots) total = (total + ctx.get(slot)) >>> 0;
95
+ blackBox(total);
96
+ },
97
+ );
98
+ }
99
+
100
+ // --- set_cell_invalidation: cost of invalidating a wide fan-out ------------
101
+ // Parity: bench_set_cell_invalidation "high_fan_out / 512" (iter_batched).
102
+ // Only the setCell invalidation is timed (not the downstream recompute) —
103
+ // matches the lazily-rs routine which times `set_cell` + `black_box(slots.len())`.
104
+ bench.batched(
105
+ "set_cell_invalidation",
106
+ "high_fan_out / 512",
107
+ () => setupFanOut(SET_CELL_INVALIDATION_FAN_OUT),
108
+ ({ ctx, root, slots }) => {
109
+ ctx.setCell(root, blackBox(1));
110
+ blackBox(slots.length);
111
+ },
112
+ );
113
+
114
+ // --- memo_equality_suppression: memo chain that stays equal downstream -----
115
+ // Parity: bench_memo_equality_suppression "context" (iter_batched).
116
+ // root%2 memo head; setting root to an even value keeps the head's value equal
117
+ // (0) so downstream caches survive — the memo guard path.
118
+ bench.batched(
119
+ "memo_equality_suppression",
120
+ "context",
121
+ () => setupMemoChain(MEMO_CHAIN_DEPTH),
122
+ ({ ctx, root, tail }) => {
123
+ ctx.setCell(root, blackBox(2));
124
+ blackBox(ctx.get(blackBox(tail)));
125
+ },
126
+ );
127
+
128
+ // --- effect_flushing: effect re-runs on every cell change ------------------
129
+ // Parity: bench_effect_flushing "context" (idempotent: advances next each call
130
+ // so each setCell is a real 1->2->3 ... change, not a ==-guarded no-op).
131
+ {
132
+ const ctx = new Context();
133
+ const root = ctx.cell(0);
134
+ let seen = 0;
135
+ ctx.effect(() => {
136
+ seen = (seen + ctx.getCell(root)) >>> 0;
137
+ });
138
+ let next = 0;
139
+ bench("effect_flushing", "context", () => {
140
+ next = (next + 1) >>> 0;
141
+ ctx.setCell(root, blackBox(next));
142
+ blackBox(seen);
143
+ });
144
+ }
145
+
146
+ // --- batch_storms: coalesced batched writes to many cells -----------------
147
+ // Parity: bench_batch_storms "context / 64".
148
+ {
149
+ const { ctx, cells, getSink } = setupBatchStorm(BATCH_STORM_CELLS);
150
+ let base = BATCH_STORM_CELLS;
151
+ bench("batch_storms", `context / ${BATCH_STORM_CELLS}`, () => {
152
+ base = (base + BATCH_STORM_CELLS) >>> 0;
153
+ ctx.batch(() => {
154
+ for (let i = 0; i < cells.length; i++) {
155
+ ctx.setCell(cells[i], blackBox((base + i) >>> 0));
156
+ }
157
+ });
158
+ blackBox(getSink());
159
+ });
160
+ }
161
+
162
+ // --- typed_cache_reads: direct slot/cell reads ----------------------------
163
+ // Parity: bench_typed_cache_reads "context_slot" / "context_cell".
164
+ {
165
+ const ctx = new Context();
166
+ const cell = ctx.cell(42);
167
+ const slot = ctx.computed(() => ctx.getCell(cell));
168
+ blackBox(ctx.get(slot)); // prime
169
+ bench("typed_cache_reads", "context_slot", () => blackBox(ctx.get(blackBox(slot))));
170
+ }
171
+ {
172
+ const ctx = new Context();
173
+ const cell = ctx.cell(99);
174
+ bench("typed_cache_reads", "context_cell", () => blackBox(ctx.getCell(blackBox(cell))));
175
+ }
176
+
177
+ await run({
178
+ format: process.env.BENCH_FORMAT === "json" ? "json" : "markdown",
179
+ });