@lazily-hub/lazily-js 0.7.0 → 0.9.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/README.md CHANGED
@@ -39,6 +39,7 @@ notes and platform carve-outs lives in
39
39
  | Feature | Rust | Python | Kotlin | JS | Dart | Zig | Go | C++ |
40
40
  | --------- | :----: | :------: | :------: | :--: | :----: | :---: | :--: | :---: |
41
41
  | Reactive graph — `Cell` / `Slot` / `Signal` / `Effect` / memo / batch | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
42
+ | Reactive family (`ReactiveFamily`) — keyed cell/slot family + materialization mode (`#lzmatmode`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
42
43
  | Thread-safe context (lock-backed) | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ |
43
44
  | Async reactive context | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
44
45
  | Flat state machine | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -46,21 +47,22 @@ notes and platform carve-outs lives in
46
47
  | Keyed cell collections (`CellMap` / `CellTree`) + reconcile | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
47
48
  | Memoized semantic tree (`SemTree`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
48
49
  | Stable-id alignment (manufactured identity) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
49
- | Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) | ✅ | | ✅ | ✅ | | ✅ | | ✅ |
50
+ | Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) | ✅ | | ✅ | ✅ | | ✅ | | ✅ |
50
51
  | Free-text character CRDT (`TextCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
51
52
  | `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
52
53
  | 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 | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
54
+ | Lossless tree CRDT core (`LosslessTreeCrdt`, M1) | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
55
+ | Lossless tree — dotted-frontier anti-entropy | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
56
+ | Lossless tree — concurrent merge convergence | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
56
57
  | Registers (LWW / MV) + `PnCounter` + `CellCrdt` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
57
58
  | IPC wire — `Snapshot` + `Delta` + `CrdtSync` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
58
- | Shared-memory blob path (`ShmBlobArena`) | ✅ | ✅ | ✅ | ~ | ~ | ✅ | ✅ | ✅ |
59
+ | Shared-memory blob path (`ShmBlobArena`) | ✅ | ✅ | ✅ | | ~ | ✅ | ✅ | ✅ |
60
+ | Cross-process zero-copy transport (`BlobBackend` / shm / arrow) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
59
61
  | Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
60
62
  | Distributed plane — WebRTC transport + signaling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
61
63
  | State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
62
64
  | Causal receipts (`CausalReceipts` outcome projection) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
63
- | Message-passing + RPC command plane (`command-plane-v1`) | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
65
+ | Message-passing + RPC command plane (`command-plane-v1`) | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
64
66
  | C-ABI FFI boundary | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ |
65
67
  | Permission boundary (`PeerPermissions` / `RemoteOp`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
66
68
  | Capability negotiation (`SessionHandshake`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -74,8 +76,10 @@ and JSON Schemas in `lazily-spec` and the Lean models in `lazily-formal`.
74
76
  | Import | What it is |
75
77
  |--------|------------|
76
78
  | `@lazily-hub/lazily-js` | `lazily-spec` IPC wire types: `Snapshot`, `Delta`, `DeltaOp`, `IpcMessage` (`Snapshot` / `Delta` / `CrdtSync`), `NodeState`, `IpcValue`, `PeerPermissions`, `SessionHandshake`, `BINDING_CAPABILITIES` |
79
+ | `@lazily-hub/lazily-js/transport` | Cross-process zero-copy transport (`#lzzcpy`): `ShmBlobArena`, `InProcessBackend` / `ArrowBackend`, `BlobRouter`, `spillMessage` / `resolveValue`, and the FFI-gated `createShmBackend` (Node/Bun/Deno). Isomorphic — no FFI import; browser-safe |
77
80
  | `@lazily-hub/lazily-js/reactive` | Reactive dependency graph: `Context`, `Cell`, `Slot`, `Signal`, `Effect` |
78
81
  | `@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 |
82
+ | `@lazily-hub/lazily-js/reactive-family` | Unified keyed reactive family: `ReactiveFamily` (`EntryKind` cell/slot × `MaterializationMode` eager/lazy) + `cellFamily` input-cell specialization (`#lzmatmode`) |
79
83
  | `@lazily-hub/lazily-js/state-machine` | Flat finite-state-machine kernel backed by a reactive `Cell` |
80
84
  | `@lazily-hub/lazily-js/statechart` | Harel/SCXML chart interpreter plus `ChartBuilder`, `StateBuilder`, `TransitionBuilder` |
81
85
  | `@lazily-hub/lazily-js/collections` | `CellMap`, `CellTree`, keyed reconciliation, and LIS move minimization |
@@ -155,6 +159,44 @@ ctx.setCell(userId, 2); // supersedes any in-flight compute; slot re-resolves
155
159
  await ctx.getAsync(profile); // the profile for user 2
156
160
  ```
157
161
 
162
+ ## Reactive family and materialization mode
163
+
164
+ `ReactiveFamily` (from `@lazily-hub/lazily-js/reactive-family`) is the unified
165
+ **keyed reactive family** (`#lzmatmode`): it maps keys to per-entry reactive
166
+ nodes and abstracts over the entry's handle kind. Two orthogonal axes:
167
+
168
+ - **Entry kind** — `EntryKind.Cell` entries are input cells (**always
169
+ materialized**, any mode); `EntryKind.Slot` entries are derived slots (what
170
+ materialization governs). `cellFamily(...)` is the input-cell specialization.
171
+ - **Materialization mode** — `MaterializationMode.Eager` (**default**) allocates
172
+ every derived node up front; `MaterializationMode.Lazy` (opt-in) allocates a
173
+ derived node on its **first read** ("materialize on pull") and caches it.
174
+
175
+ Mode is **observationally transparent**: a read returns the same value under
176
+ either mode — it changes allocation timing and memory, never results. Lazy pays
177
+ off only for sparsely-touched large keyed address spaces.
178
+
179
+ ```js
180
+ import { Context } from "@lazily-hub/lazily-js/reactive";
181
+ import { ReactiveFamily } from "@lazily-hub/lazily-js/reactive-family";
182
+
183
+ const ctx = new Context();
184
+
185
+ // A derived (slot) family of key*3 over a large address space, built lazily:
186
+ // nothing is allocated until a key is read.
187
+ const fam = ReactiveFamily.lazy(ctx, range(0, 1_000_000), (k) => k * 3);
188
+ fam.presentCount(); // 0
189
+
190
+ fam.observe(5); // 15 — first read materializes just this entry
191
+ fam.presentCount(); // 1
192
+ fam.isPresent(5); // true
193
+ fam.isPresent(6); // false
194
+
195
+ // Eager builds the same values up front — observationally identical.
196
+ const eager = ReactiveFamily.eager(ctx, [0, 1, 2, 3], (k) => k * 3);
197
+ eager.observe(2) === fam.observe(2); // true
198
+ ```
199
+
158
200
  ## State machine and state charts
159
201
 
160
202
  `StateMachine` is the flat finite-state-machine kernel: a pure
@@ -344,6 +386,69 @@ signaling, and the WebRTC transport are shipped; C-ABI FFI is `none` because
344
386
  browser/Worker JS cannot host a native in-process ABI. The same payload types
345
387
  can still be carried by any transport a host application owns.
346
388
 
389
+ ## Cross-process zero-copy transport
390
+
391
+ `@lazily-hub/lazily-js/transport` implements the pluggable blob-backend
392
+ transport (`#lzzcpy`). A large payload is not copied through the wire codec: the
393
+ producer **spills** it to a backend (which mints a `ShmBlobRef` descriptor) and
394
+ ships only the descriptor; the receiver **routes** the descriptor by its
395
+ `backend` discriminator and **resolves** it zero-copy — reading the backend's own
396
+ bytes in place. `ShmBlobRef` gained an optional `backend` field (`shm` | `arrow`
397
+ | `in_process`) that defaults to `shm` and is omitted from the wire, so every
398
+ pre-transport descriptor round-trips byte-for-byte.
399
+
400
+ The module is **isomorphic**: it imports no FFI, so it bundles and runs in the
401
+ browser. `InProcessBackend`, `ArrowBackend`, the `BlobRouter`, and the
402
+ spill/resolve policy are pure JS and available everywhere (including a
403
+ main-thread ↔ Web Worker deployment).
404
+
405
+ ```js
406
+ import {
407
+ BlobRouter,
408
+ InProcessBackend,
409
+ ArrowBackend,
410
+ spillMessage,
411
+ } from "@lazily-hub/lazily-js/transport";
412
+ import { Delta, DeltaOp, IpcMessage, IpcValue } from "@lazily-hub/lazily-js";
413
+
414
+ const backend = new InProcessBackend(); // or ArrowBackend for columnar payloads
415
+ const big = IpcValue.inline(new Uint8Array(4096));
416
+ const { message, spilledBytes } = spillMessage(
417
+ IpcMessage.delta(new Delta({ baseEpoch: 0, epoch: 1, ops: [DeltaOp.slotValue(7, big)] })),
418
+ backend,
419
+ ); // message now carries a small SharedBlob descriptor; spilledBytes === 4096
420
+
421
+ const router = new BlobRouter().register(backend);
422
+ router.resolve(message.delta.ops[0].payload); // Uint8Array view, zero copy
423
+ ```
424
+
425
+ The genuine **cross-process** `shm` backend (POSIX `shm_open` + `mmap`) is loaded
426
+ lazily and only where a runtime provides FFI — Node (via `koffi`), Bun (`bun:ffi`),
427
+ or Deno (`Deno.dlopen`, needs `--allow-ffi --unstable-ffi`). A peer process on the
428
+ same host that attaches the same name resolves the descriptor without copying
429
+ across the process boundary. In the browser (or any runtime without FFI)
430
+ `createShmBackend` rejects with `ShmUnavailableError`; guard with `shmSupported()`
431
+ and fall back to `InProcessBackend` / `ArrowBackend`.
432
+
433
+ ```js
434
+ import { createShmBackend, shmSupported } from "@lazily-hub/lazily-js/transport";
435
+
436
+ if (shmSupported()) {
437
+ const shm = await createShmBackend("my-session", { capacity: 1 << 20 });
438
+ const ref = shm.write(new Uint8Array([1, 2, 3])); // ref.backend === "shm"
439
+ // ...ship `ref` to a peer; the peer attaches `createShmBackend("my-session",
440
+ // { capacity: 1 << 20, create: false })` and calls `shm.readView(ref)`.
441
+ shm.close();
442
+ }
443
+ ```
444
+
445
+ All three runtimes are verified end-to-end, including cross-runtime interop: a
446
+ Node process writes a region that a separate Deno process attaches and resolves,
447
+ proving the layout is byte-identical across FFI implementations. The `shm` region
448
+ is a bump-allocated arena with a fixed header (magic / version / capacity / epoch
449
+ / generation / cursor) and per-entry `{ generation, epoch, len, checksum }`
450
+ validation.
451
+
347
452
  ## Distributed plane
348
453
 
349
454
  The `@lazily-hub/lazily-js/signaling` and `@lazily-hub/lazily-js/distributed`
@@ -384,7 +489,11 @@ lazily-js replays the shared `lazily-spec` fixtures for IPC, agent-doc state,
384
489
  keyed collections (`CellMap`, `CellTree`, LIS reconciliation), semantic tree,
385
490
  sequence and text CRDTs (incl. `TextCrdt` delta sync, `#lztextsync`:
386
491
  `textcrdt_convergence.json` + `textcrdt_delta_sync.json`), manufactured text
387
- identity, Harel state charts, the signaling protocol (`signaling/frames.json`,
492
+ identity, the reactive family / materialization mode (`#lzmatmode`:
493
+ `materialization/observational_transparency.json`,
494
+ `materialization/deferral_not_deallocation.json`,
495
+ `materialization/entry_kind_orthogonal_to_mode.json`), Harel state charts, the
496
+ signaling protocol (`signaling/frames.json`,
388
497
  `signaling/anti_spoof_session.json`), and the distributed CRDT plane
389
498
  (`distributed/crdt_sync_frames.json`, `distributed/anti_entropy_converge.json`).
390
499
  It also validates generated wire values against the canonical JSON Schemas.
@@ -404,6 +513,7 @@ names the Lean theorems it mirrors:
404
513
  | `Reactive` | `reactive-properties.test.js` | `setCell_equal_preserves_graph`, `setCell_different_invalidates_dependents`, `recomputeSlot_equal_preserves_dependents`, `recomputeSlot_different_invalidates_dependents`, `signal_materialized_after_recompute` |
405
514
  | `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` |
406
515
  | `Tree` | `tree-properties.test.js` | `setNodeValue_preserves_{other_nodes,node_signals}`, `moveChild_preserves_{non_parent,parent_value}`, `moveChild_advances_order_signal_only` |
516
+ | `Materialization` | `reactive-family.test.js` | `observe_canonical`, `eager_lazy_observationally_equivalent`, `eager_materializes_all`, `lazy_defers_slots`, `materialize_present_monotone`, `lazy_present_subset_eager`, `materialize_preserves_observe`, `cell_entries_materialized_in_every_mode`, `slot_entries_deferred_under_lazy` |
407
517
  | `Reconciliation` | `reconciliation-properties.test.js` | `lisBy_longest`, `reconcile_move_minimized`, `reconcile_stable_not_invalidated` |
408
518
  | `AsyncSlotState` | `reactive-async.test.js` | `stale_completeOk_discarded`, `current_completeOk_publishes`, `current_completeErr_to_error` |
409
519
  | `AsyncEffect` | `reactive-async.test.js` | `fire_blocked_during_cleanup`, `invalidate_from_idle_schedules`, `cleanupDone_resumes_deferred`, `dispose_absorbing`, `disposed_terminal` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazily-hub/lazily-js",
3
- "version": "0.7.0",
3
+ "version": "0.9.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": {
@@ -15,6 +15,10 @@
15
15
  "types": "./src/index.d.ts",
16
16
  "default": "./src/index.js"
17
17
  },
18
+ "./transport": {
19
+ "types": "./src/transport.d.ts",
20
+ "default": "./src/transport.js"
21
+ },
18
22
  "./reactive": {
19
23
  "types": "./src/reactive.d.ts",
20
24
  "default": "./src/reactive.js"
@@ -23,6 +27,10 @@
23
27
  "types": "./src/reactive-async.d.ts",
24
28
  "default": "./src/reactive-async.js"
25
29
  },
30
+ "./reactive-family": {
31
+ "types": "./src/reactive-family.d.ts",
32
+ "default": "./src/reactive-family.js"
33
+ },
26
34
  "./state-machine": {
27
35
  "types": "./src/state-machine.d.ts",
28
36
  "default": "./src/state-machine.js"
@@ -73,7 +81,7 @@
73
81
  }
74
82
  },
75
83
  "scripts": {
76
- "build": "node --check src/index.js && node --check src/reactive.js && node --check src/reactive-async.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",
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",
77
85
  "test:formal": "node scripts/formal-check.mjs",
78
86
  "bench": "node bench/context.bench.mjs",
79
87
  "benchmark-update": "node scripts/run-benchmarks.mjs",
package/src/index.d.ts CHANGED
@@ -2,6 +2,14 @@ export type NodeId = number;
2
2
  export type PeerId = number;
3
3
  export type WireBytes = readonly number[] | Uint8Array;
4
4
 
5
+ export type BlobBackendKindValue = "shm" | "arrow" | "in_process";
6
+
7
+ export const BlobBackendKind: {
8
+ readonly Shm: "shm";
9
+ readonly Arrow: "arrow";
10
+ readonly InProcess: "in_process";
11
+ };
12
+
5
13
  export class ShmBlobRef {
6
14
  constructor(fields: {
7
15
  offset: number;
@@ -9,12 +17,15 @@ export class ShmBlobRef {
9
17
  generation: number;
10
18
  epoch: number;
11
19
  checksum: number;
20
+ backend?: BlobBackendKindValue;
12
21
  });
13
22
  readonly offset: number;
14
23
  readonly len: number;
15
24
  readonly generation: number;
16
25
  readonly epoch: number;
17
26
  readonly checksum: number;
27
+ readonly backend: BlobBackendKindValue;
28
+ withBackend(backend: BlobBackendKindValue): ShmBlobRef;
18
29
  toWire(): ShmBlobRefWire;
19
30
  static fromWire(value: ShmBlobRefWire): ShmBlobRef;
20
31
  }
@@ -25,6 +36,7 @@ export type ShmBlobRefWire = {
25
36
  generation: number;
26
37
  epoch: number;
27
38
  checksum: number;
39
+ backend?: BlobBackendKindValue;
28
40
  };
29
41
 
30
42
  export class NodeStatePayload {
package/src/index.js CHANGED
@@ -73,24 +73,62 @@ function normalizeNodeKey(key) {
73
73
  return key;
74
74
  }
75
75
 
76
+ // The pluggable blob backends a descriptor may name (zero-copy transport,
77
+ // `#lzzcpy`). `shm` is the default and is omitted from the wire so legacy
78
+ // descriptors round-trip byte-for-byte. See src/transport.js and
79
+ // lazily-spec/docs/zero-copy-transport.md.
80
+ export const BlobBackendKind = Object.freeze({
81
+ Shm: "shm",
82
+ Arrow: "arrow",
83
+ InProcess: "in_process",
84
+ });
85
+
86
+ const BLOB_BACKEND_KINDS = new Set(Object.values(BlobBackendKind));
87
+
76
88
  export class ShmBlobRef {
77
- constructor({ offset, len, generation, epoch, checksum }) {
89
+ constructor({ offset, len, generation, epoch, checksum, backend }) {
78
90
  this.offset = assertInteger(offset, "offset");
79
91
  this.len = assertInteger(len, "len");
80
92
  this.generation = assertInteger(generation, "generation");
81
93
  this.epoch = assertInteger(epoch, "epoch");
82
94
  this.checksum = assertInteger(checksum, "checksum");
95
+ // Optional pluggable-backend discriminator. Defaults to `shm` so every
96
+ // pre-transport descriptor keeps meaning "POSIX shared memory".
97
+ const kind = backend ?? BlobBackendKind.Shm;
98
+ if (!BLOB_BACKEND_KINDS.has(kind)) {
99
+ throw new TypeError(`unknown blob backend: ${kind}`);
100
+ }
101
+ this.backend = kind;
83
102
  Object.freeze(this);
84
103
  }
85
104
 
105
+ // Return a copy of this descriptor tagged for `backend`. Backends stamp their
106
+ // own kind onto an arena-minted descriptor via this helper.
107
+ withBackend(backend) {
108
+ return new ShmBlobRef({
109
+ offset: this.offset,
110
+ len: this.len,
111
+ generation: this.generation,
112
+ epoch: this.epoch,
113
+ checksum: this.checksum,
114
+ backend,
115
+ });
116
+ }
117
+
86
118
  toWire() {
87
- return {
119
+ const wire = {
88
120
  offset: this.offset,
89
121
  len: this.len,
90
122
  generation: this.generation,
91
123
  epoch: this.epoch,
92
124
  checksum: this.checksum,
93
125
  };
126
+ // Omit the default `shm` so the descriptor stays byte-compatible with the
127
+ // pre-`#lzzcpy` wire form (the `backend`-absent conformance fixture).
128
+ if (this.backend !== BlobBackendKind.Shm) {
129
+ wire.backend = this.backend;
130
+ }
131
+ return wire;
94
132
  }
95
133
 
96
134
  static fromWire(value) {
@@ -101,6 +139,7 @@ export class ShmBlobRef {
101
139
  generation: object.generation,
102
140
  epoch: object.epoch,
103
141
  checksum: object.checksum,
142
+ backend: object.backend ?? BlobBackendKind.Shm,
104
143
  });
105
144
  }
106
145
  }
@@ -0,0 +1,100 @@
1
+ import type { CellHandle, Context, SlotHandle } from "./reactive.js";
2
+
3
+ /**
4
+ * Which kind of reactive node a {@link ReactiveFamily} entry is — the
5
+ * handle-kind axis the family abstracts over, orthogonal to
6
+ * {@link MaterializationMode}.
7
+ */
8
+ export const EntryKind: {
9
+ /** An input cell — always materialized, any mode. */
10
+ readonly Cell: "cell";
11
+ /** A derived slot — materialized eagerly, or lazily on first read. */
12
+ readonly Slot: "slot";
13
+ };
14
+ export type EntryKind = (typeof EntryKind)[keyof typeof EntryKind];
15
+
16
+ /**
17
+ * When a {@link ReactiveFamily}'s derived (slot) entries are allocated.
18
+ * Orthogonal to {@link EntryKind}; never observable on the value axis.
19
+ */
20
+ export const MaterializationMode: {
21
+ /** Allocate every derived node up front at build time. Required default. */
22
+ readonly Eager: "eager";
23
+ /** Allocate a derived node on its first read, keyed rather than held. */
24
+ readonly Lazy: "lazy";
25
+ };
26
+ export type MaterializationMode =
27
+ (typeof MaterializationMode)[keyof typeof MaterializationMode];
28
+
29
+ /** The default materialization mode (eager). */
30
+ export const DEFAULT_MATERIALIZATION_MODE: MaterializationMode;
31
+
32
+ export type EntryKindResolver<K> = EntryKind | ((key: K) => EntryKind);
33
+ export type FamilyHandle<V> = CellHandle<V> | SlotHandle<V>;
34
+
35
+ /**
36
+ * The unified keyed reactive family (`#lzmatmode`): keys map to per-entry
37
+ * reactive nodes (input cells or derived slots), allocated per the family's
38
+ * {@link MaterializationMode}.
39
+ */
40
+ export class ReactiveFamily<K = unknown, V = unknown> {
41
+ constructor(
42
+ ctx: Context,
43
+ mode: MaterializationMode,
44
+ keys: Iterable<K>,
45
+ factory: (key: K) => V,
46
+ entryKind?: EntryKindResolver<K>,
47
+ );
48
+
49
+ /** Build an eager family: every declared key's node is allocated now. */
50
+ static eager<K, V>(
51
+ ctx: Context,
52
+ keys: Iterable<K>,
53
+ factory: (key: K) => V,
54
+ entryKind?: EntryKindResolver<K>,
55
+ ): ReactiveFamily<K, V>;
56
+
57
+ /** Build a lazy family: derived (slot) entries are deferred to first read. */
58
+ static lazy<K, V>(
59
+ ctx: Context,
60
+ keys: Iterable<K>,
61
+ factory: (key: K) => V,
62
+ entryKind?: EntryKindResolver<K>,
63
+ ): ReactiveFamily<K, V>;
64
+
65
+ /** Build a family in the default (eager) mode. */
66
+ static create<K, V>(
67
+ ctx: Context,
68
+ keys: Iterable<K>,
69
+ factory: (key: K) => V,
70
+ entryKind?: EntryKindResolver<K>,
71
+ ): ReactiveFamily<K, V>;
72
+
73
+ /** Materialize (lazy pull) and return the entry handle for `key`. */
74
+ get(key: K): FamilyHandle<V>;
75
+ /** Observe `key`'s value — identical under either mode. */
76
+ observe(key: K): V;
77
+ /** Set a cell entry's value (input entries only). */
78
+ setCell(key: K, value: V): void;
79
+ /** Whether `key` is currently materialized. Non-reactive. */
80
+ isPresent(key: K): boolean;
81
+ /** Currently-materialized keys, in first-materialization order. */
82
+ presentKeys(): K[];
83
+ /** Number of currently-materialized entries. */
84
+ presentCount(): number;
85
+ /** This family's entry kind for `key`. */
86
+ entryKind(key: K): EntryKind;
87
+ /** This family's materialization mode. */
88
+ readonly mode: MaterializationMode;
89
+ }
90
+
91
+ /**
92
+ * The input-cell specialization of {@link ReactiveFamily}: a keyed family whose
93
+ * entries are all input cells (always materialized).
94
+ */
95
+ export function cellFamily<K, V>(
96
+ ctx: Context,
97
+ keys: Iterable<K>,
98
+ factory: (key: K) => V,
99
+ mode?: MaterializationMode,
100
+ ): ReactiveFamily<K, V>;