@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.
- package/.github/workflows/release.yml +22 -1
- package/BENCHMARKS.md +91 -0
- package/README.md +131 -29
- package/bench/context.bench.mjs +179 -0
- package/bench/harness.mjs +144 -0
- package/package.json +21 -2
- package/src/distributed.d.ts +136 -0
- package/src/distributed.js +647 -0
- package/src/index.d.ts +390 -0
- package/src/index.js +969 -5
- package/src/lossless-tree-crdt.d.ts +52 -0
- package/src/lossless-tree-crdt.js +537 -0
- package/src/reactive-async.d.ts +80 -0
- package/src/reactive-async.js +625 -0
- package/src/signaling.d.ts +320 -0
- package/src/signaling.js +725 -0
- package/src/utf8-offsets.js +37 -0
|
@@ -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
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
|
34
|
-
|
|
|
35
|
-
|
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
|
45
|
-
|
|
|
46
|
-
|
|
|
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,
|
|
284
|
-
shipped; C-ABI FFI is `none` because
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
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
|
+
});
|