@lazily-hub/lazily-js 0.15.0 → 0.18.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
@@ -33,7 +33,7 @@ the FFI projection transport is used.
33
33
  The full `lazily` capability set across every binding. Legend: ✅ shipped ·
34
34
  `~` partial · `—` absent or not applicable. The canonical matrix with per-cell
35
35
  notes and platform carve-outs lives in
36
- [`lazily-spec` § Cross-Language Coverage](../lazily-spec/docs/coverage.md).
36
+ [`lazily-spec` § Cross-Language Coverage](https://github.com/lazily-hub/lazily-spec/blob/main/docs/coverage.md).
37
37
 
38
38
  <!-- coverage-table:start -->
39
39
  | Feature | Rust | Python | Kotlin | JS | Dart | Zig | Go | C++ |
@@ -51,8 +51,13 @@ notes and platform carve-outs lives in
51
51
  | Memoized semantic tree (`SemTree`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
52
52
  | Stable-id alignment (manufactured identity) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
53
53
  | Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
54
+ | Broadcast topic (`TopicCell`) — independent cursors + durable replay + safe GC (`#lztopiccell`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
55
+ | Competing-consumer work queue (`WorkQueueCell`) — exclusive leases + ack/nack + redelivery + DLQ (`#lzworkqueue`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
56
+ | Merge algebra + `MergeCell` — associative `MergePolicy` (`KeepLatest`/`Sum`/`Max`/`SetUnion`/`RawFifo`), `Cell ≡ MergeCell<KeepLatest>`, `Reactive`/`Source` split (`#relaycell`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
57
+ | RelayCell — conflating relay + `BackpressurePolicy` + `SpillStore` + `Transport` + Inbox/Outbox + Rate/Window/Expiry/Priority/keyed policies (`#relaycell`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
54
58
  | Free-text character CRDT (`TextCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
55
59
  | `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
60
+ | `CrdtTree` lossless document contract (`#lzcrdttree`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
56
61
  | Move-aware sequence CRDT (`SeqCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
57
62
  | Lossless tree CRDT core (`LosslessTreeCrdt`, M1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
58
63
  | Lossless tree — dotted-frontier anti-entropy | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -63,6 +68,7 @@ notes and platform carve-outs lives in
63
68
  | Cross-process zero-copy transport (`BlobBackend` / shm / arrow) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
64
69
  | Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
65
70
  | Reliable sync — resync coordinator + at-least-once durable outbox + OR-set/LWW liveness (`#lzsync`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
71
+ | Storage-independent durable outbox (`OutboxStore` + shared outbox protocol; SQLite/Room/IndexedDB/file adapters) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
66
72
  | Reliable-sync transport seam + full-duplex `SyncDriver` loop (`IpcSink`/`IpcSource`, `#sync-driver`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
67
73
  | Distributed plane — WebRTC transport + signaling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
68
74
  | State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -335,6 +341,26 @@ tree.value(); // 1
335
341
  tree.setValue("leaf", 99); // only the ancestor chain recomputes
336
342
  ```
337
343
 
344
+ ## Competing-consumer work queue
345
+
346
+ The `queue` package exports `WorkQueueCell`, a pull-based local authority with
347
+ exclusive FIFO claims, stable item IDs, fresh delivery IDs per attempt,
348
+ worker-owned ack/nack, strict visibility-timeout redelivery, bounded attempts,
349
+ and DLQ routing. Every mutation reports exact `pending_len` / `is_empty` /
350
+ `in_flight_len` / `dead_letter_len` invalidation metadata.
351
+
352
+ ```js
353
+ import { WorkQueueCell } from "@lazily-hub/lazily-js/queue";
354
+
355
+ const work = new WorkQueueCell({ visibility_timeout: 30, max_deliveries: 3 });
356
+ work.push("render-report");
357
+ const delivery = work.claim("worker-a", 100).returns;
358
+ work.ack("worker-a", delivery.delivery_id);
359
+ ```
360
+
361
+ The instance serializes local claims; distributed/HA assignment still requires
362
+ a leader or consensus-committed assignment log.
363
+
338
364
  ## CRDTs
339
365
 
340
366
  `SeqCrdt` is the move-aware sequence CRDT: each element has independent LWW
@@ -342,7 +368,10 @@ registers for value, position, and deletion, so a move is one position
342
368
  assignment rather than delete plus reinsert. `TextCrdt` is a Fugue/RGA
343
369
  character CRDT: concurrent same-point inserts are preserved, deletes are sticky
344
370
  tombstones, and merge is commutative / associative / idempotent. Both expose
345
- tombstone GC behind caller-supplied causal-stability watermarks.
371
+ tombstone GC behind caller-supplied causal-stability watermarks. `TextCrdt`
372
+ also satisfies the `CrdtTree` document contract: its snapshot is the delta from
373
+ an empty frontier, so full hydration and incremental exchange preserve the same
374
+ identity-bearing state.
346
375
 
347
376
  ```js
348
377
  import { SeqCrdt } from "@lazily-hub/lazily-js/seq-crdt";
@@ -358,6 +387,16 @@ peer.insert(2, "!");
358
387
  text.merge(peer); // converges
359
388
  ```
360
389
 
390
+ ## Durable outbox stores
391
+
392
+ The root `Outbox` class owns one append/ack/prune/replay protocol over the
393
+ five-operation `OutboxStore` boundary. `InMemoryStore` exercises that path in
394
+ tests. Browsers can open an `IndexedDbStore` from
395
+ `@lazily-hub/lazily-js/indexeddb-outbox`; await `append` before transport send
396
+ and `ackThrough` before treating an acknowledgement as committed. Reopening the
397
+ same database and channel restores the durable cursor and only unacknowledged
398
+ frames.
399
+
361
400
  ## IPC wire types and capability negotiation
362
401
 
363
402
  Every IPC value round-trips the canonical externally-tagged
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazily-hub/lazily-js",
3
- "version": "0.15.0",
3
+ "version": "0.18.0",
4
4
  "description": "Native JavaScript port of the lazily reactive core: a full reactive graph (Cell/Slot/Signal/Effect), sync/async/thread-safe keyed reactive maps (ReactiveMap/CellMap/SlotMap), the lazily-spec IPC wire types + C-ABI FFI boundary (isomorphic channel + Node native binding), 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, an FFI state-projection consumer, and an in-library instrumentation/benchmark API.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -69,6 +69,14 @@
69
69
  "types": "./src/queue.d.ts",
70
70
  "default": "./src/queue.js"
71
71
  },
72
+ "./merge": {
73
+ "types": "./src/merge.d.ts",
74
+ "default": "./src/merge.js"
75
+ },
76
+ "./relay": {
77
+ "types": "./src/relay.d.ts",
78
+ "default": "./src/relay.js"
79
+ },
72
80
  "./sem-tree": {
73
81
  "types": "./src/sem-tree.d.ts",
74
82
  "default": "./src/sem-tree.js"
@@ -89,6 +97,11 @@
89
97
  "types": "./src/lossless-tree-crdt.d.ts",
90
98
  "default": "./src/lossless-tree-crdt.js"
91
99
  },
100
+ "./indexeddb-outbox": {
101
+ "types": "./src/indexeddb-outbox.d.ts",
102
+ "browser": "./src/indexeddb-outbox.js",
103
+ "default": "./src/indexeddb-outbox.js"
104
+ },
92
105
  "./state-projection": {
93
106
  "types": "./src/state-projection.d.ts",
94
107
  "default": "./src/state-projection.js"
@@ -103,7 +116,7 @@
103
116
  }
104
117
  },
105
118
  "scripts": {
106
- "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/async-reactive-family.js && node --check src/thread-safe.js && node --check src/thread-safe-reactive-family.js && node --check src/ffi.js && node --check src/ffi-node.js && node --check src/instrumentation.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/graph-view.js && node --check src/signaling.js && node --check src/distributed.js",
119
+ "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/async-reactive-family.js && node --check src/thread-safe.js && node --check src/thread-safe-reactive-family.js && node --check src/ffi.js && node --check src/ffi-node.js && node --check src/instrumentation.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/merge.js && node --check src/relay.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/indexeddb-outbox.js && node --check src/utf8-offsets.js && node --check src/lossless-tree-crdt.js && node --check src/state-projection.js && node --check src/graph-view.js && node --check src/signaling.js && node --check src/distributed.js",
107
120
  "test:formal": "node scripts/formal-check.mjs",
108
121
  "bench": "node bench/context.bench.mjs",
109
122
  "bench:scale": "node --max-old-space-size=8192 bench/scale.bench.mjs",
@@ -117,6 +130,7 @@
117
130
  "devDependencies": {
118
131
  "@types/node": "^22.0.0",
119
132
  "ajv": "^8.20.0",
133
+ "fake-indexeddb": "^6.2.5",
120
134
  "tsx": "^4.19.0",
121
135
  "typescript": "^5.6.0"
122
136
  }
package/src/index.d.ts CHANGED
@@ -449,20 +449,40 @@ export class ResyncCoordinator {
449
449
  }
450
450
 
451
451
  export interface DurableOutbox {
452
- append(epoch: number, msg: IpcMessage): void;
453
- ackThrough(epoch: number): void;
452
+ append(epoch: number, msg: IpcMessage): void | Promise<void>;
453
+ ackThrough(epoch: number): void | Promise<void>;
454
454
  replayFrom(cursor: number): Array<[number, IpcMessage]>;
455
455
  retainedEpochs(): number[];
456
456
  }
457
457
 
458
- export class InMemoryOutbox implements DurableOutbox {
458
+ export interface OutboxStore {
459
+ put(epoch: number, frame: Uint8Array): void | Promise<void>;
460
+ deleteThrough(epoch: number): void | Promise<void>;
461
+ scanAfter(cursor: number): Array<[number, Uint8Array]>;
462
+ loadCursor(): number;
463
+ saveCursor(epoch: number): void | Promise<void>;
464
+ }
465
+
466
+ export class Outbox<S extends OutboxStore = OutboxStore> implements DurableOutbox {
467
+ constructor(store: S);
468
+ readonly store: S;
459
469
  ackedThrough: number;
460
- append(epoch: number, msg: IpcMessage): void;
461
- ackThrough(epoch: number): void;
470
+ append(epoch: number, msg: IpcMessage): void | Promise<void>;
471
+ ackThrough(epoch: number): void | Promise<void>;
462
472
  replayFrom(cursor: number): Array<[number, IpcMessage]>;
463
473
  retainedEpochs(): number[];
464
474
  }
465
475
 
476
+ export class InMemoryStore implements OutboxStore {
477
+ put(epoch: number, frame: Uint8Array): void;
478
+ deleteThrough(epoch: number): void;
479
+ scanAfter(cursor: number): Array<[number, Uint8Array]>;
480
+ loadCursor(): number;
481
+ saveCursor(epoch: number): void;
482
+ }
483
+
484
+ export class InMemoryOutbox extends Outbox<InMemoryStore> {}
485
+
466
486
  export class OrSet {
467
487
  add(tag: string): void;
468
488
  removeObserved(tags: Iterable<string>): void;
package/src/index.js CHANGED
@@ -1269,29 +1269,86 @@ export class ResyncCoordinator {
1269
1269
  }
1270
1270
  }
1271
1271
 
1272
- // In-memory durable outbox correct within a process lifetime; the default.
1273
- // A DurableOutbox is any object with append/ackThrough/replayFrom/retainedEpochs.
1274
- export class InMemoryOutbox {
1275
- constructor() {
1276
- this.entries = []; // [epoch, IpcMessage]
1277
- this.ackedThrough = 0;
1272
+ // A byte-oriented OutboxStore is any object with
1273
+ // put/deleteThrough/scanAfter/loadCursor/saveCursor. Stores are deliberately
1274
+ // protocol-dumb; Outbox is the one append/ack/replay implementation.
1275
+ export class Outbox {
1276
+ constructor(store) {
1277
+ this.store = store;
1278
+ this.ackedThrough = store.loadCursor();
1278
1279
  }
1279
1280
 
1280
1281
  append(epoch, msg) {
1281
- this.entries.push([epoch, msg]);
1282
+ return this.store.put(epoch, msg.encodeJson());
1282
1283
  }
1283
1284
 
1284
1285
  ackThrough(epoch) {
1285
- if (epoch > this.ackedThrough) this.ackedThrough = epoch;
1286
- this.entries = this.entries.filter(([e]) => e > this.ackedThrough);
1286
+ if (epoch <= this.ackedThrough) return undefined;
1287
+ const target = () => Math.max(this.ackedThrough, epoch, this.store.loadCursor());
1288
+ const finish = () => {
1289
+ this.ackedThrough = target();
1290
+ };
1291
+ const remove = () => this.store.deleteThrough(target());
1292
+ const saved = this.store.saveCursor(epoch);
1293
+ if (saved && typeof saved.then === "function") {
1294
+ return saved.then(remove).then(finish);
1295
+ }
1296
+ const removed = remove();
1297
+ if (removed && typeof removed.then === "function") {
1298
+ return removed.then(finish);
1299
+ }
1300
+ finish();
1301
+ return undefined;
1287
1302
  }
1288
1303
 
1289
1304
  replayFrom(cursor) {
1290
- return this.entries.filter(([e]) => e > cursor).sort((a, b) => a[0] - b[0]);
1305
+ return this.store
1306
+ .scanAfter(Math.max(cursor, this.ackedThrough))
1307
+ .sort((a, b) => a[0] - b[0])
1308
+ .map(([epoch, frame]) => [epoch, IpcMessage.decodeJson(frame)]);
1291
1309
  }
1292
1310
 
1293
1311
  retainedEpochs() {
1294
- return this.entries.map(([e]) => e).sort((a, b) => a - b);
1312
+ return this.store.scanAfter(this.ackedThrough).map(([epoch]) => epoch).sort((a, b) => a - b);
1313
+ }
1314
+ }
1315
+
1316
+ export class InMemoryStore {
1317
+ constructor() {
1318
+ this.entries = new Map();
1319
+ this.cursor = 0;
1320
+ }
1321
+
1322
+ put(epoch, frame) {
1323
+ this.entries.set(epoch, Uint8Array.from(frame));
1324
+ }
1325
+
1326
+ deleteThrough(epoch) {
1327
+ for (const key of this.entries.keys()) {
1328
+ if (key <= epoch) this.entries.delete(key);
1329
+ }
1330
+ }
1331
+
1332
+ scanAfter(epoch) {
1333
+ return [...this.entries]
1334
+ .filter(([key]) => key > epoch)
1335
+ .sort((a, b) => a[0] - b[0])
1336
+ .map(([key, frame]) => [key, Uint8Array.from(frame)]);
1337
+ }
1338
+
1339
+ loadCursor() {
1340
+ return this.cursor;
1341
+ }
1342
+
1343
+ saveCursor(epoch) {
1344
+ this.cursor = Math.max(this.cursor, epoch);
1345
+ }
1346
+ }
1347
+
1348
+ // Process-local default, routed through the same protocol as persistent stores.
1349
+ export class InMemoryOutbox extends Outbox {
1350
+ constructor() {
1351
+ super(new InMemoryStore());
1295
1352
  }
1296
1353
  }
1297
1354
 
@@ -0,0 +1,18 @@
1
+ import type { OutboxStore } from "./index.js";
2
+
3
+ export interface IndexedDbStoreOptions {
4
+ channel: string;
5
+ database?: string;
6
+ version?: number;
7
+ indexedDB?: IDBFactory;
8
+ }
9
+
10
+ export class IndexedDbStore implements OutboxStore {
11
+ static open(options: IndexedDbStoreOptions): Promise<IndexedDbStore>;
12
+ put(epoch: number, frame: Uint8Array): Promise<void>;
13
+ deleteThrough(epoch: number): Promise<void>;
14
+ scanAfter(cursor: number): Array<[number, Uint8Array]>;
15
+ loadCursor(): number;
16
+ saveCursor(epoch: number): Promise<void>;
17
+ close(): void;
18
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Browser IndexedDB storage adapter for the root `Outbox` protocol.
3
+ *
4
+ * `open()` hydrates a synchronous replay mirror. Mutations return promises;
5
+ * callers must await `Outbox.append` before sending and `Outbox.ackThrough`
6
+ * before treating an ack as committed.
7
+ */
8
+ export class IndexedDbStore {
9
+ #db;
10
+ #channel;
11
+ #entries;
12
+ #cursor;
13
+ #cursorWrites = Promise.resolve();
14
+
15
+ constructor(db, channel, entries, cursor) {
16
+ this.#db = db;
17
+ this.#channel = channel;
18
+ this.#entries = entries;
19
+ this.#cursor = cursor;
20
+ }
21
+
22
+ static async open({
23
+ channel,
24
+ database = "lazily-reliable-sync",
25
+ version = 1,
26
+ indexedDB = globalThis.indexedDB,
27
+ }) {
28
+ if (!indexedDB) throw new Error("IndexedDB is unavailable in this environment");
29
+ const db = await new Promise((resolve, reject) => {
30
+ const request = indexedDB.open(database, version);
31
+ request.onupgradeneeded = () => {
32
+ const next = request.result;
33
+ if (!next.objectStoreNames.contains("frames")) {
34
+ next.createObjectStore("frames", { keyPath: ["channel", "epoch"] });
35
+ }
36
+ if (!next.objectStoreNames.contains("cursors")) {
37
+ next.createObjectStore("cursors", { keyPath: "channel" });
38
+ }
39
+ };
40
+ request.onsuccess = () => resolve(request.result);
41
+ request.onerror = () => reject(request.error);
42
+ });
43
+
44
+ const entries = new Map();
45
+ const cursor = await new Promise((resolve, reject) => {
46
+ const tx = db.transaction(["frames", "cursors"], "readonly");
47
+ const frameRequest = tx.objectStore("frames").openCursor();
48
+ frameRequest.onsuccess = () => {
49
+ const row = frameRequest.result;
50
+ if (!row) return;
51
+ if (row.value.channel === channel) {
52
+ entries.set(row.value.epoch, Uint8Array.from(row.value.frame));
53
+ }
54
+ row.continue();
55
+ };
56
+ const cursorRequest = tx.objectStore("cursors").get(channel);
57
+ tx.oncomplete = () => resolve(cursorRequest.result?.epoch ?? 0);
58
+ tx.onerror = () => reject(tx.error);
59
+ tx.onabort = () => reject(tx.error);
60
+ });
61
+ return new IndexedDbStore(db, channel, entries, cursor);
62
+ }
63
+
64
+ #transaction(storeName, mode, operation) {
65
+ return new Promise((resolve, reject) => {
66
+ const tx = this.#db.transaction(storeName, mode);
67
+ operation(tx.objectStore(storeName));
68
+ tx.oncomplete = () => resolve();
69
+ tx.onerror = () => reject(tx.error);
70
+ tx.onabort = () => reject(tx.error);
71
+ });
72
+ }
73
+
74
+ async put(epoch, frame) {
75
+ const bytes = Uint8Array.from(frame);
76
+ await this.#transaction("frames", "readwrite", (store) => {
77
+ store.put({ channel: this.#channel, epoch, frame: bytes });
78
+ });
79
+ this.#entries.set(epoch, bytes);
80
+ }
81
+
82
+ async deleteThrough(epoch) {
83
+ const keys = [...this.#entries.keys()].filter((key) => key <= epoch);
84
+ await this.#transaction("frames", "readwrite", (store) => {
85
+ for (const key of keys) store.delete([this.#channel, key]);
86
+ });
87
+ for (const key of keys) this.#entries.delete(key);
88
+ }
89
+
90
+ scanAfter(epoch) {
91
+ return [...this.#entries]
92
+ .filter(([key]) => key > epoch)
93
+ .sort((a, b) => a[0] - b[0])
94
+ .map(([key, frame]) => [key, Uint8Array.from(frame)]);
95
+ }
96
+
97
+ loadCursor() {
98
+ return this.#cursor;
99
+ }
100
+
101
+ saveCursor(epoch) {
102
+ const previous = this.#cursorWrites.catch(() => undefined);
103
+ const write = previous.then(async () => {
104
+ let next = Math.max(this.#cursor, epoch);
105
+ await this.#transaction("cursors", "readwrite", (store) => {
106
+ // IndexedDB serializes readwrite transactions that overlap this store.
107
+ // Reading and writing inside one transaction makes this a database-wide
108
+ // max, not merely a per-JavaScript-handle max.
109
+ const request = store.get(this.#channel);
110
+ request.onsuccess = () => {
111
+ next = Math.max(request.result?.epoch ?? 0, this.#cursor, epoch);
112
+ store.put({ channel: this.#channel, epoch: next });
113
+ };
114
+ });
115
+ this.#cursor = next;
116
+ });
117
+ this.#cursorWrites = write;
118
+ return write;
119
+ }
120
+
121
+ close() {
122
+ this.#db.close();
123
+ }
124
+ }
package/src/merge.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ // Phase 1 of the RelayCell backpressure plan (#relaycell) — merge algebra +
2
+ // the Reactive/Source read/write split. See lazily-spec/docs/reactive-graph.md
3
+ // § "MergeCell and the merge algebra".
4
+
5
+ import type { CellHandle, Context } from "./reactive.js";
6
+
7
+ /** An associative merge policy `⊕: T×T→T` with its transport-selected flags. */
8
+ export interface MergePolicy<T> {
9
+ readonly name: string;
10
+ /** The associative fold. MUST satisfy `(a⊕b)⊕c == a⊕(b⊕c)`. */
11
+ merge(old: T, op: T): T;
12
+ /** `⊕` is commutative (reordering tax). */
13
+ readonly commutative: boolean;
14
+ /** `⊕` is idempotent — `(a⊕b)⊕b == a⊕b` (durability tax). */
15
+ readonly idempotent: boolean;
16
+ /** Conflation bounds the state (the `Conflate` overflow precondition). */
17
+ readonly conflates: boolean;
18
+ }
19
+
20
+ export const KeepLatest: MergePolicy<unknown>;
21
+ export const Sum: MergePolicy<number>;
22
+ export const Max: MergePolicy<number>;
23
+ export const SetUnion: MergePolicy<Set<unknown>>;
24
+ export const RawFifo: MergePolicy<unknown[]>;
25
+
26
+ /** A cell whose write is a merge under `policy` (`Cell ≡ MergeCell(KeepLatest)`). */
27
+ export class MergeCell<T> {
28
+ readonly ctx: Context;
29
+ readonly cell: CellHandle<T>;
30
+ readonly policy: MergePolicy<T>;
31
+ constructor(ctx: Context, cell: CellHandle<T>, policy: MergePolicy<T>);
32
+ /** Read the current converged value. */
33
+ get(): T;
34
+ /** Replace the value (keep-latest write). */
35
+ set(value: T): void;
36
+ /** Fold `op` into the value under the policy. */
37
+ merge(op: T): void;
38
+ }
39
+
40
+ /** Create a `MergeCell` over `ctx`. */
41
+ export function mergeCell<T>(ctx: Context, initial: T, policy: MergePolicy<T>): MergeCell<T>;
42
+
43
+ /** Adapt a plain cell to the `Source` shape (get/set/merge; merge == replace). */
44
+ export function asSource<T>(
45
+ ctx: Context,
46
+ cellHandle: CellHandle<T>,
47
+ ): { get(): T; set(value: T): void; merge(op: T): void };
48
+
49
+ /** Adapt any read handle to the `Reactive` shape (`{ get }`). */
50
+ export function asReactive<T>(ctx: Context, handle: unknown): { get(): T };
package/src/merge.js ADDED
@@ -0,0 +1,142 @@
1
+ // Phase 1 of the RelayCell backpressure plan (#relaycell) — the merge algebra
2
+ // and the Reactive/Source read/write split.
3
+ //
4
+ // See lazily-spec/docs/reactive-graph.md § "MergeCell and the merge algebra" and
5
+ // relaycell-backpressure-analysis.md §4.0/§4.3. A merge policy is an *associative*
6
+ // fold ⊕: T×T→T; the properties it satisfies (associativity always; commutativity
7
+ // = reordering tax; idempotency = durability tax) select which overflow behaviour
8
+ // is sound. `MergeCell` generalizes a plain `Cell` — `Cell ≡ MergeCell(KeepLatest)`
9
+ // — a source whose write is a merge. Backed by an ordinary cell, so it inherits
10
+ // the Phase-0 `==` store-guard and store-without-cascade.
11
+
12
+ // -- Merge policies ----------------------------------------------------------
13
+ //
14
+ // A policy is a plain object: { name, merge(old, op), commutative, idempotent,
15
+ // conflates }. Associativity is a law (verified by law-tests), not a field. The
16
+ // three flags surface the transport-selected branches; `conflates` gates the
17
+ // `Conflate` overflow (only RawFifo — concat — cannot bound, Phase 2).
18
+
19
+ /** Keep-latest band: `old ⊕ op = op`. The policy behind a plain `Cell`. */
20
+ export const KeepLatest = Object.freeze({
21
+ name: "KeepLatest",
22
+ merge: (_old, op) => op,
23
+ commutative: false,
24
+ idempotent: true,
25
+ conflates: true,
26
+ });
27
+
28
+ /** Additive commutative monoid: `old + op`. Not idempotent. */
29
+ export const Sum = Object.freeze({
30
+ name: "Sum",
31
+ merge: (old, op) => old + op,
32
+ commutative: true,
33
+ idempotent: false,
34
+ conflates: true,
35
+ });
36
+
37
+ /** Max semilattice: `max(old, op)`. Associative, commutative, idempotent. */
38
+ export const Max = Object.freeze({
39
+ name: "Max",
40
+ merge: (old, op) => (op > old ? op : old),
41
+ commutative: true,
42
+ idempotent: true,
43
+ conflates: true,
44
+ });
45
+
46
+ /** Grow-only set-union semilattice over `Set`. */
47
+ export const SetUnion = Object.freeze({
48
+ name: "SetUnion",
49
+ merge: (old, op) => {
50
+ const out = new Set(old);
51
+ for (const x of op) out.add(x);
52
+ return out;
53
+ },
54
+ commutative: true,
55
+ idempotent: true,
56
+ conflates: true,
57
+ });
58
+
59
+ /** Raw FIFO append over arrays: `old ++ op`. Order + multiplicity are meaning —
60
+ * associative only; cannot conflate. */
61
+ export const RawFifo = Object.freeze({
62
+ name: "RawFifo",
63
+ merge: (old, op) => old.concat(op),
64
+ commutative: false,
65
+ idempotent: false,
66
+ conflates: false,
67
+ });
68
+
69
+ // -- MergeCell ---------------------------------------------------------------
70
+
71
+ /**
72
+ * A cell whose write is a *merge* under `policy`, rather than a replace.
73
+ * `Cell ≡ MergeCell(KeepLatest)`.
74
+ */
75
+ export class MergeCell {
76
+ /** @param {import("./reactive.js").Context} ctx */
77
+ constructor(ctx, cell, policy) {
78
+ this.ctx = ctx;
79
+ /** underlying CellHandle */
80
+ this.cell = cell;
81
+ this.policy = policy;
82
+ Object.freeze(this);
83
+ }
84
+
85
+ /** Read the current converged value (tracks a dependency inside a computation). */
86
+ get() {
87
+ return this.ctx.getCell(this.cell);
88
+ }
89
+
90
+ /** Replace the value outright (the keep-latest write), bypassing the policy. */
91
+ set(value) {
92
+ this.ctx.setCell(this.cell, value);
93
+ }
94
+
95
+ /** Fold `op` into the current value under the policy. Routes through `setCell`
96
+ * so the `==` store-guard (free dedup for idempotent ⊕) + store-without-cascade
97
+ * apply unchanged. */
98
+ merge(op) {
99
+ const old = this.ctx.getCell(this.cell);
100
+ this.ctx.setCell(this.cell, this.policy.merge(old, op));
101
+ }
102
+ }
103
+
104
+ /** Create a `MergeCell` over `ctx` with `initial` value under `policy`. */
105
+ export function mergeCell(ctx, initial, policy) {
106
+ return new MergeCell(ctx, ctx.cell(initial), policy);
107
+ }
108
+
109
+ // -- Reactive / Source -------------------------------------------------------
110
+ //
111
+ // JavaScript is structurally typed, so the `Reactive<T>` read supertype and the
112
+ // `Source<T>: Reactive<T>` write sub-interface are documented shapes rather than
113
+ // declared types. A reader (Slot/Signal) exposes { get }; a source (Cell/MergeCell)
114
+ // exposes { get, set, merge }. `asSource` adapts a plain CellHandle to the Source
115
+ // shape (its `merge` is a replace — Cell ≡ MergeCell(KeepLatest)).
116
+
117
+ /** Adapt a plain `CellHandle` to the `Source` shape (get/set/merge). */
118
+ export function asSource(ctx, cellHandle) {
119
+ return {
120
+ get: () => ctx.getCell(cellHandle),
121
+ set: (value) => ctx.setCell(cellHandle, value),
122
+ // Cell ≡ MergeCell(KeepLatest): merge replaces.
123
+ merge: (op) => ctx.setCell(cellHandle, op),
124
+ };
125
+ }
126
+
127
+ /** Adapt any read handle (Slot/Signal/Cell) to the `Reactive` shape ({ get }). */
128
+ export function asReactive(ctx, handle) {
129
+ // Cells read via getCell; slots/signals via get/getSignal. Detect by shape.
130
+ if (handle instanceof MergeCell) return { get: () => handle.get() };
131
+ if (handle && handle.slot !== undefined) return { get: () => ctx.getSignal(handle) };
132
+ // Fall back: try getCell then get.
133
+ return {
134
+ get: () => {
135
+ try {
136
+ return ctx.getCell(handle);
137
+ } catch {
138
+ return ctx.get(handle);
139
+ }
140
+ },
141
+ };
142
+ }