@lazily-hub/lazily-js 0.9.0 → 0.11.0

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/BENCHMARKS.md CHANGED
@@ -89,3 +89,118 @@ node scripts/run-benchmarks.mjs # record "before" baseline in BENCHMA
89
89
  # apply the performance patch
90
90
  node scripts/run-benchmarks.mjs # compare against the new numbers
91
91
  ```
92
+
93
+ ## Scale (≥1M cells) — spreadsheet-shaped graph
94
+
95
+ A second suite ([`bench/scale.bench.mjs`](bench/scale.bench.mjs)) replicates the
96
+ lazily-rs [`scale`](https://github.com/lazily-hub/lazily-rs/blob/main/benches/scale.rs)
97
+ group, the lazily-go `scale` group, and lazily-py's
98
+ [`scale_bench.py`](https://github.com/lazily-hub/lazily-py/blob/main/src/lazily/scale_bench.py)
99
+ on a **spreadsheet-shaped** graph: `N` input cells + `N` formula slots where
100
+ `formula[i] = input[i] + input[i - 1]` (local fan-in, like a column of
101
+ `=A_i + A_{i-1}`). With the default `N = 1,000,000` that is **~2,000,000
102
+ reactive nodes**. Four scenarios cover the spreadsheet lifecycle:
103
+
104
+ - `build` — construct all `2N` nodes (formulas lazy, not yet computed).
105
+ - `cold_full_recalc` — first read of every formula (forces every compute + edge-tracking).
106
+ - `viewport_recalc` — edit one input, read only a 1,000-cell viewport (the
107
+ lazy-pull win: off-viewport formulas stay dirty and never recompute).
108
+ - `full_recalc_invalidate_all` — re-set every input, then read every formula
109
+ (worst-case full-sheet edit).
110
+
111
+ > **A "cell count" here counts two cells per row** — the graph models a column of
112
+ > formulas `=A_i + A_{i-1}`, so each row is **one input cell `A_i` plus one
113
+ > formula cell**. `N` rows ⇒ `N` inputs + `N` formulas = `2N` cells.
114
+
115
+ Timings use `performance.now()` (`node:perf_hooks`); single wall-clock run per
116
+ scenario. Lower is better. Treat the absolute numbers as indicative — the shapes
117
+ (relative costs, size-scaling behavior) are what transfer across runs and hosts.
118
+
119
+ ### Reproduce
120
+
121
+ ```bash
122
+ make bench-scale # scale suite at N = 1,000,000
123
+ node bench/scale.bench.mjs # same, directly
124
+
125
+ # scale at a specific size / viewport:
126
+ LAZILY_SCALE_N=1000000 node bench/scale.bench.mjs
127
+ LAZILY_SCALE_N=5000000 node bench/scale.bench.mjs # Google Sheets 10M-cell workbook
128
+ LAZILY_SCALE_VIEWPORT=1000 node bench/scale.bench.mjs
129
+ BENCH_FORMAT=json node bench/scale.bench.mjs # machine-readable output
130
+ ```
131
+
132
+ Large `N` needs headroom for the V8 heap — run with
133
+ `node --max-old-space-size=8192 bench/scale.bench.mjs` at 1M and `16384` at 5M.
134
+
135
+ ### Hardware / environment
136
+
137
+ | | |
138
+ |---|---|
139
+ | CPU | AMD Ryzen 9 9950X3D (16 cores / 32 threads) |
140
+ | RAM | 186 GiB |
141
+ | OS | Linux 7.1.3 (CachyOS), x86-64 |
142
+ | Node.js | 26.4.0 (V8) |
143
+
144
+ ### 1,000,000 rows (~2M cells / nodes)
145
+
146
+ Peak RSS ~1.0 GiB (~550 B/node).
147
+
148
+ | Benchmark | Time | Per cell | What it measures |
149
+ |-----------|-----:|---------:|------------------|
150
+ | `build` | ~0.90 s | ~450 ns | Construct all 2N nodes (each `Cell`/`Slot` allocates its own dependency structures — allocation- and GC-bound under V8). |
151
+ | `cold_full_recalc` | ~0.65 s | ~650 ns | First read of every formula — forces every compute + edge-tracking. |
152
+ | `viewport_recalc` | **~100 µs** | — | Edit one input, read only a 1,000-cell viewport. ~6,500× cheaper than a full cold recalc. |
153
+ | `full_recalc_invalidate_all` | ~1.30 s | ~1.30 µs | Re-set every input, then recompute the whole sheet (worst-case full-sheet edit). |
154
+
155
+ ### 5,000,000 rows (10M cells — a full Google Sheets workbook)
156
+
157
+ Google Sheets caps a workbook at **10,000,000 cells**. Modeled as 5,000,000
158
+ input cells + 5,000,000 formula cells (`LAZILY_SCALE_N=5000000`). This is the
159
+ **largest size actually measured** — no extrapolation. Peak RSS ~5.0 GiB.
160
+
161
+ | Benchmark | Time | Per cell | What it measures |
162
+ |-----------|-----:|---------:|------------------|
163
+ | `build` | ~6.56 s | ~656 ns | Build the full 10M-node workbook (allocation/GC-bound). |
164
+ | `cold_full_recalc` | ~3.37 s | ~674 ns | Compute all 5M formulas cold. |
165
+ | `viewport_recalc` | **~106 µs** | — | Edit one input, read a 1,000-cell viewport. ~31,800× cheaper than a full cold recalc. |
166
+ | `full_recalc_invalidate_all` | ~7.38 s | ~1.48 µs | Re-set every input, recompute the whole workbook. |
167
+
168
+ So lazily-js backs a **full-capacity Google Sheets workbook** on V8: building it
169
+ is the expensive part (~6.6 s, allocation/GC-bound — one JS object graph per
170
+ node), but once built, a full cold recompute is ~3.4 s, and a one-cell edit +
171
+ bounded-viewport read stays in the **~100 µs range**. The lazy pull-based model
172
+ leaves off-viewport formulas dirty and never recomputes them — only ~2 formulas
173
+ actually recompute per edit (the two that read the edited input), regardless of
174
+ sheet size, which is exactly the property a viewport-rendered spreadsheet needs.
175
+
176
+ ### Spreadsheet cell-count context
177
+
178
+ | Spreadsheet | Documented limit | Cells |
179
+ |-------------|------------------|------:|
180
+ | Google Sheets | 10,000,000 cells per workbook (18,278 columns max) | 10,000,000 |
181
+ | Microsoft Excel | 1,048,576 rows × 16,384 columns per worksheet | 17,179,869,184 |
182
+
183
+ The `LAZILY_SCALE_N=5000000` run above covers a full Google Sheets workbook. A
184
+ grid-complete Excel worksheet (17 billion cells) is unrepresentative — real
185
+ sheets populate a tiny fraction of the grid, and lazily only stores the cells you
186
+ create, so the `scale` group measures the populated-cell path that matters.
187
+
188
+ ### Viewport scaling — flat
189
+
190
+ lazily-js's viewport recalc is **effectively size-independent** (~100 µs at 2M
191
+ nodes, ~106 µs at 10M nodes). The value cache is keyed by node identity, so both
192
+ the ~1,000 viewport cache-hit reads and the ~2 actual recomputes are O(1) map
193
+ operations that don't scale with total sheet size. This matches lazily-rs's and
194
+ lazily-py's flat curves. At 10M cells a one-cell edit + 1,000-cell viewport read
195
+ is ~31,800× cheaper than a full cold recalc and never touches off-viewport
196
+ formulas.
197
+
198
+ ### Cross-language honesty
199
+
200
+ V8 is heavier per node than compiled Rust (lazily-rs builds the same 2M-node
201
+ graph in ~0.1 s vs ~0.9 s here) but far lighter than CPython (lazily-py's build
202
+ is ~10.6 s at 1M). The recompute paths (`cold_full_recalc` ~650 ns/formula) are
203
+ JIT-compiled and closer to the compiled bindings. These numbers are reported
204
+ honestly, not to claim parity — the transferable result is the **shape**: the
205
+ lazy-pull viewport property holds identically across all bindings (a one-cell
206
+ edit + bounded-viewport read is microseconds, independent of sheet size).
package/README.md CHANGED
@@ -40,6 +40,9 @@ notes and platform carve-outs lives in
40
40
  | --------- | :----: | :------: | :------: | :--: | :----: | :---: | :--: | :---: |
41
41
  | Reactive graph — `Cell` / `Slot` / `Signal` / `Effect` / memo / batch | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
42
42
  | Reactive family (`ReactiveFamily`) — keyed cell/slot family + materialization mode (`#lzmatmode`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
43
+ | Thread-safe reactive family (`ThreadSafeReactiveFamily`) — `Send + Sync` keyed family + materialization confluence (`#lzmatmode`) | ✅ | — | — | — | — | — | — | — |
44
+ | Async reactive family (`AsyncReactiveFamily`) — keyed family + eventual transparency (`#lzmatmode`) | ✅ | — | — | — | — | — | — | — |
45
+ | Reactive family sync — membership propagation + materialize-on-ingest + derived-aggregate transparency (`#lzfamilysync`) | ✅ | — | ✅ | ✅ | — | — | — | — |
43
46
  | Thread-safe context (lock-backed) | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ |
44
47
  | Async reactive context | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
45
48
  | Flat state machine | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -549,6 +552,31 @@ make check # npm run build && npm test
549
552
  `lake` are present.
550
553
  - `npm test` runs the formal check and the Node test suite.
551
554
 
555
+ ## Benchmarks
556
+
557
+ Wall-clock benchmarks live in [`BENCHMARKS.md`](BENCHMARKS.md), with two suites
558
+ built on a zero-dependency `node:perf_hooks` harness:
559
+
560
+ - **Micro-benchmarks** ([`bench/context.bench.mjs`](bench/context.bench.mjs)) — a
561
+ 1:1 port of the single-threaded `Context` cases in lazily-rs's
562
+ `benches/context.rs` (cached reads, cold first get, dependency fan-out,
563
+ set-cell invalidation, memo equality suppression, effect flushing, batch
564
+ storms, typed cache reads) so JS and Rust numbers are directly comparable.
565
+ - **Scale** ([`bench/scale.bench.mjs`](bench/scale.bench.mjs)) — a
566
+ spreadsheet-shaped graph (`N` input cells + `N` formula slots,
567
+ `formula[i] = input[i] + input[i - 1]`) mirroring the lazily-rs/-go/-py `scale`
568
+ groups. At the default `N = 1,000,000` that is ~2M reactive nodes; the
569
+ `LAZILY_SCALE_N=5000000` run covers a full 10M-cell Google Sheets workbook. A
570
+ one-cell edit + 1,000-cell viewport read stays ~100 µs **independent of sheet
571
+ size** — the lazy-pull property a viewport-rendered spreadsheet needs.
572
+
573
+ ```bash
574
+ make bench # micro-suite (prints a markdown table)
575
+ make bench-scale # scale suite at N = 1,000,000
576
+ npm run benchmark-update # refresh BENCHMARKS.md's generated micro-bench table
577
+ npm run benchmark-check # CI gate: exit 1 if the micro-bench row set is stale
578
+ ```
579
+
552
580
  ## See also
553
581
 
554
582
  - [`lazily-spec`][spec] - language-agnostic wire protocol, schemas, and
@@ -0,0 +1,176 @@
1
+ // Large-graph scale benchmark for lazily-js.
2
+ //
3
+ // Replicates the lazily-rs `scale` group (`benches/scale.rs`), the lazily-go
4
+ // `scale` group, and lazily-py's `scale_bench.py` on a spreadsheet-shaped
5
+ // graph: `N` input cells plus `N` formula slots, where
6
+ // `formula[i] = input[i] + input[i - 1]` (local fan-in, like a column of
7
+ // `=A_i + A_{i-1}`). With the default `N = 1_000_000` that is
8
+ // ~2,000,000 reactive nodes. Four scenarios cover the spreadsheet lifecycle:
9
+ //
10
+ // - build — construct all 2N nodes (formulas lazy, not yet computed).
11
+ // - cold_full_recalc — first read of every formula (forces every compute + edge-tracking).
12
+ // - viewport_recalc — edit one input, read only a bounded viewport (the lazy-pull win:
13
+ // off-viewport formulas stay dirty and never recompute).
14
+ // - full_recalc_invalidate_all — touch every input, then read every formula (worst-case full edit).
15
+ //
16
+ // Run as a script:
17
+ //
18
+ // node bench/scale.bench.mjs
19
+ // LAZILY_SCALE_N=1000000 node bench/scale.bench.mjs
20
+ // LAZILY_SCALE_N=5000000 node bench/scale.bench.mjs # Google Sheets 10M-cell workbook
21
+ // LAZILY_SCALE_VIEWPORT=1000 node bench/scale.bench.mjs
22
+ // BENCH_FORMAT=json node bench/scale.bench.mjs # machine-readable output
23
+ //
24
+ // JS runs on a single event-loop thread; this is a single-threaded benchmark
25
+ // (the concurrency surfaces are correctness-tested, not benchmarked here).
26
+
27
+ import { performance } from "node:perf_hooks";
28
+ import { Context } from "../src/reactive.js";
29
+ import { blackBox } from "./harness.mjs";
30
+
31
+ export function scaleN() {
32
+ const raw = Number(process.env.LAZILY_SCALE_N);
33
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 1_000_000;
34
+ }
35
+
36
+ export function scaleViewport(n) {
37
+ const raw = Number(process.env.LAZILY_SCALE_VIEWPORT);
38
+ const vp = Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 1_000;
39
+ return Math.min(vp, n);
40
+ }
41
+
42
+ // Construct N input cells + N formula slots (formulas lazy, not yet computed).
43
+ // formula[i] = input[i] + input[i - 1]; each read goes through ctx.getCell so
44
+ // the running slot is auto-tracked as a dependent (dynamic edge discovery).
45
+ export function buildScaleGraph(n) {
46
+ const ctx = new Context();
47
+ const inputs = new Array(n);
48
+ for (let i = 0; i < n; i++) inputs[i] = ctx.cell(i);
49
+ const formulas = new Array(n);
50
+ for (let i = 0; i < n; i++) {
51
+ const a = inputs[i];
52
+ const b = i > 0 ? inputs[i - 1] : inputs[0];
53
+ formulas[i] = ctx.computed(() => (ctx.getCell(a) + ctx.getCell(b)) >>> 0);
54
+ }
55
+ return { ctx, inputs, formulas };
56
+ }
57
+
58
+ function readAllFormulas(g) {
59
+ const { ctx, formulas } = g;
60
+ let acc = 0;
61
+ for (let i = 0; i < formulas.length; i++) acc = (acc + ctx.get(formulas[i])) >>> 0;
62
+ return acc;
63
+ }
64
+
65
+ function benchBuild(n) {
66
+ const start = performance.now();
67
+ const g = buildScaleGraph(n);
68
+ const elapsed = (performance.now() - start) / 1000;
69
+ const cells = 2 * n;
70
+ blackBox(g.formulas.length);
71
+ return {
72
+ name: "build",
73
+ n,
74
+ seconds: elapsed,
75
+ cells,
76
+ perCellNs: (elapsed / cells) * 1e9,
77
+ detail: `${cells.toLocaleString("en-US")} nodes`,
78
+ };
79
+ }
80
+
81
+ function benchColdFullRecalc(n) {
82
+ const g = buildScaleGraph(n);
83
+ const start = performance.now();
84
+ const sink = readAllFormulas(g);
85
+ const elapsed = (performance.now() - start) / 1000;
86
+ blackBox(sink);
87
+ return {
88
+ name: "cold_full_recalc",
89
+ n,
90
+ seconds: elapsed,
91
+ cells: n,
92
+ perCellNs: (elapsed / n) * 1e9,
93
+ detail: `${n.toLocaleString("en-US")} formulas`,
94
+ };
95
+ }
96
+
97
+ function benchViewportRecalc(n, samples = 1_000) {
98
+ const vp = scaleViewport(n);
99
+ const g = buildScaleGraph(n);
100
+ readAllFormulas(g); // warm the whole sheet once
101
+ const { ctx, inputs, formulas } = g;
102
+ const mid = Math.floor(n / 2);
103
+ const lo = Math.max(0, mid - Math.floor(vp / 2));
104
+ const hi = Math.min(n, lo + vp);
105
+ const window = formulas.slice(lo, hi);
106
+ const midInput = inputs[mid];
107
+ let acc = 0;
108
+ const start = performance.now();
109
+ for (let i = 0; i < samples; i++) {
110
+ ctx.setCell(midInput, i + 1); // edit one input (monotonic → passes PartialEq guard)
111
+ for (let j = 0; j < window.length; j++) acc = (acc + ctx.get(window[j])) >>> 0;
112
+ }
113
+ const elapsed = performance.now() - start; // ms total
114
+ blackBox(acc); // defeat dead-code elimination of the viewport reads
115
+ const perOpUs = (elapsed / samples) * 1000;
116
+ return {
117
+ name: "viewport_recalc",
118
+ n,
119
+ seconds: elapsed / 1000 / samples,
120
+ cells: vp,
121
+ perCellNs: null,
122
+ detail: `${perOpUs.toFixed(2)} us/edit, viewport=${vp}, ${samples} edits`,
123
+ };
124
+ }
125
+
126
+ function benchFullRecalcInvalidateAll(n) {
127
+ const g = buildScaleGraph(n);
128
+ readAllFormulas(g); // warm once
129
+ const { ctx, inputs } = g;
130
+ const start = performance.now();
131
+ for (let j = 0; j < inputs.length; j++) ctx.setCell(inputs[j], j + 1); // touch every input
132
+ const sink = readAllFormulas(g);
133
+ const elapsed = (performance.now() - start) / 1000;
134
+ blackBox(sink);
135
+ return {
136
+ name: "full_recalc_invalidate_all",
137
+ n,
138
+ seconds: elapsed,
139
+ cells: n,
140
+ perCellNs: (elapsed / n) * 1e9,
141
+ detail: `${n.toLocaleString("en-US")} inputs re-set + ${n.toLocaleString("en-US")} formulas recomputed`,
142
+ };
143
+ }
144
+
145
+ export function runScaleBenchmarks(n = scaleN()) {
146
+ return [
147
+ benchBuild(n),
148
+ benchColdFullRecalc(n),
149
+ benchViewportRecalc(n),
150
+ benchFullRecalcInvalidateAll(n),
151
+ ];
152
+ }
153
+
154
+ function fmtCell(perCellNs) {
155
+ return perCellNs == null ? "—" : `${perCellNs.toFixed(1).padStart(8)} ns/cell`;
156
+ }
157
+
158
+ function main() {
159
+ const n = scaleN();
160
+ const results = runScaleBenchmarks(n);
161
+ if (process.env.BENCH_FORMAT === "json") {
162
+ console.log(JSON.stringify({ n, nodes: 2 * n, results }, null, 2));
163
+ return;
164
+ }
165
+ console.log(
166
+ `lazily-js scale benchmarks (N=${n.toLocaleString("en-US")}, ${(2 * n).toLocaleString("en-US")} reactive nodes)`,
167
+ );
168
+ for (const r of results) {
169
+ const name = r.name.padEnd(28);
170
+ const size = `N=${n.toLocaleString("en-US").padStart(13)}`;
171
+ const ms = `${(r.seconds * 1000).toFixed(3).padStart(12)} ms`;
172
+ console.log(`${name} ${size} ${ms} ${fmtCell(r.perCellNs)} ${r.detail}`);
173
+ }
174
+ }
175
+
176
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazily-hub/lazily-js",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Native JavaScript port of the lazily reactive core: a full reactive graph (Cell/Slot/Signal/Effect), the lazily-spec IPC wire types, keyed cell collections + LIS reconciliation, the memoized semantic tree, the move-aware sequence CRDT, the Fugue/RGA text CRDT, manufactured text identity, full-Harel state charts, and an FFI state-projection consumer.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -84,6 +84,7 @@
84
84
  "build": "node --check src/index.js && node --check src/transport.js && node --check src/shm-backend.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/reactive-family.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.js && node --check src/queue.js && node --check src/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/utf8-offsets.js && node --check src/lossless-tree-crdt.js && node --check src/state-projection.js && node --check src/signaling.js && node --check src/distributed.js",
85
85
  "test:formal": "node scripts/formal-check.mjs",
86
86
  "bench": "node bench/context.bench.mjs",
87
+ "bench:scale": "node --max-old-space-size=8192 bench/scale.bench.mjs",
87
88
  "benchmark-update": "node scripts/run-benchmarks.mjs",
88
89
  "benchmark-check": "node scripts/run-benchmarks.mjs --check",
89
90
  "test": "npm run test:formal && node --test test/*.test.js"
@@ -103,6 +103,17 @@ export class CrdtPlaneRuntime {
103
103
  since: Iterable<FrontierEntry | [PeerId, WireStamp | unknown]>,
104
104
  ): CrdtSync;
105
105
  syncReply(request: CrdtSync): CrdtSync;
106
+ // Family sync (#lzfamilysync)
107
+ registerFamilyLww(namespace: string): this;
108
+ membershipEpoch(): number;
109
+ familyKeys(namespace: string): string[];
110
+ familyValueLww(namespace: string, keySuffix: string): boolean | undefined;
111
+ familySetLww(
112
+ namespace: string,
113
+ keySuffix: string,
114
+ value: boolean,
115
+ nowMicros: number,
116
+ ): CrdtOp | null;
106
117
  }
107
118
 
108
119
  // --- Browser WebRTC platform adapter ---
@@ -20,7 +20,7 @@
20
20
  // localUpdate→CrdtOp, ingest→count (op-log dedup by (node, stamp)), and the
21
21
  // sync-frame pull protocol.
22
22
 
23
- import { CrdtOp, CrdtSync, IpcMessage, WireStamp } from "./index.js";
23
+ import { CrdtOp, CrdtSync, IpcMessage, IpcValue, WireStamp } from "./index.js";
24
24
  import { Hlc } from "./seq-crdt.js";
25
25
 
26
26
  // Lexicographic (wall_time, logical, peer) order over two WireStamps.
@@ -255,6 +255,12 @@ function dedupKey(node, stamp) {
255
255
  return `${node}|${stamp.wallTime}|${stamp.logical}|${stamp.peer}`;
256
256
  }
257
257
 
258
+ // Base node id for entries a family materializes on first remote observation
259
+ // (#lzfamilysync). Family entry nodes are locally-private — keyed ops resolve by
260
+ // key string, never by raw node id — so this only needs to avoid colliding with
261
+ // application-assigned node ids; the runtime skips any id already in use.
262
+ const FAMILY_NODE_BASE = 2 ** 48;
263
+
258
264
  /**
259
265
  * The live runtime that folds distributed CRDT anti-entropy frames into a set of
260
266
  * replicated root cells (port of lazily-rs `CrdtPlaneRuntime`). One runtime per
@@ -283,6 +289,12 @@ export class CrdtPlaneRuntime {
283
289
  #frontier = new Map();
284
290
  #membership = new Set();
285
291
 
292
+ // -- Family sync (#lzfamilysync) -----------------------------------------
293
+ #families = new Set();
294
+ #familyMembers = new Map();
295
+ #familyEpoch = 0;
296
+ #nextFamilyNode = FAMILY_NODE_BASE;
297
+
286
298
  constructor(peer) {
287
299
  this.#peer = assertPeerId(peer);
288
300
  this.#hlc = new Hlc(this.#peer);
@@ -348,6 +360,90 @@ export class CrdtPlaneRuntime {
348
360
  return this.#cells.get(node)?.value;
349
361
  }
350
362
 
363
+ // -- Family sync (#lzfamilysync) -----------------------------------------
364
+
365
+ // Register a last-writer-wins family under `namespace`. An inbound keyed op whose
366
+ // first key segment matches materializes a fresh entry on `ingest` (instead of
367
+ // being dropped/mis-addressed), so membership propagates and a derived aggregate
368
+ // over the family converges.
369
+ registerFamilyLww(namespace) {
370
+ this.#families.add(namespace);
371
+ if (!this.#familyMembers.has(namespace)) this.#familyMembers.set(namespace, []);
372
+ return this;
373
+ }
374
+
375
+ // The membership signal (#lzfamilysync), bumped whenever a family entry
376
+ // materializes — a derived aggregate over the family reads it so a remote-added
377
+ // key forces a recompute. A monotonically-increasing counter.
378
+ membershipEpoch() {
379
+ return this.#familyEpoch;
380
+ }
381
+
382
+ // The materialized keys of family `namespace`, in first-materialization order.
383
+ familyKeys(namespace) {
384
+ return [...(this.#familyMembers.get(namespace) ?? [])];
385
+ }
386
+
387
+ // The current converged boolean value of family entry `namespace/keySuffix`.
388
+ familyValueLww(namespace, keySuffix) {
389
+ const node = this.#keyToNode.get(`${namespace}/${keySuffix}`);
390
+ if (node === undefined) return undefined;
391
+ const iv = this.value(node);
392
+ if (iv === undefined || iv.bytes === undefined) return undefined;
393
+ return iv.bytes.length > 0 && iv.bytes[0] !== 0;
394
+ }
395
+
396
+ // Insert or update local LWW family entry `namespace/keySuffix` to boolean
397
+ // `value`, returning the CrdtOp to broadcast (or null for a value-preserving
398
+ // update). Materializes the entry (and bumps membership) on first insert.
399
+ familySetLww(namespace, keySuffix, value, nowMicros) {
400
+ const key = `${namespace}/${keySuffix}`;
401
+ let node = this.#keyToNode.get(key);
402
+ if (node === undefined) {
403
+ node = this.#mintFamilyNode();
404
+ this.register(node, key);
405
+ this.#recordFamilyMember(namespace, key);
406
+ this.#bumpFamilyEpoch();
407
+ }
408
+ return this.localUpdate(node, nowMicros, IpcValue.inline(Uint8Array.of(value ? 1 : 0)));
409
+ }
410
+
411
+ #mintFamilyNode() {
412
+ for (;;) {
413
+ const candidate = this.#nextFamilyNode;
414
+ this.#nextFamilyNode += 1;
415
+ if (!this.#cells.has(candidate)) return candidate;
416
+ }
417
+ }
418
+
419
+ #recordFamilyMember(namespace, key) {
420
+ const members = this.#familyMembers.get(namespace) ?? [];
421
+ if (!members.includes(key)) members.push(key);
422
+ this.#familyMembers.set(namespace, members);
423
+ }
424
+
425
+ #bumpFamilyEpoch() {
426
+ this.#familyEpoch += 1;
427
+ }
428
+
429
+ // If `op` is a keyed op for a registered family whose entry is not yet known,
430
+ // materialize it under a fresh locally-private node (indexed by the wire key) and
431
+ // return that node; otherwise fall back to the normal node resolution.
432
+ #resolveNodeForFamily(op) {
433
+ if (op.key !== null && op.key !== undefined && !this.#keyToNode.has(op.key)) {
434
+ const namespace = String(op.key).split("/")[0];
435
+ if (this.#families.has(namespace)) {
436
+ const node = this.#mintFamilyNode();
437
+ this.#keyToNode.set(op.key, node);
438
+ this.#nodeToKey.set(node, op.key);
439
+ this.#recordFamilyMember(namespace, op.key);
440
+ this.#bumpFamilyEpoch();
441
+ return node;
442
+ }
443
+ }
444
+ return this.#resolveNode(op);
445
+ }
446
+
351
447
  // The winning CrdtOp at `node`, or undefined.
352
448
  winningOp(node) {
353
449
  const cell = this.#cells.get(node);
@@ -420,7 +516,10 @@ export class CrdtPlaneRuntime {
420
516
  this.#hlc.recv(op.stamp, nowMicros);
421
517
  }
422
518
  this.#observeStamp(op.stamp);
423
- const node = this.#resolveNode(op);
519
+ // Materialize-on-ingest (#lzfamilysync): a keyed op for a registered family
520
+ // whose entry is not yet known materializes it under a fresh local node
521
+ // instead of being mis-addressed to a colliding local family node.
522
+ const node = this.#resolveNodeForFamily(op);
424
523
  this.#cellFor(node).merge(op);
425
524
  }
426
525
  return applied;