@lazily-hub/lazily-js 0.16.0 → 0.19.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,12 +33,12 @@ 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++ |
40
40
  | --------- | :----: | :------: | :------: | :--: | :----: | :---: | :--: | :---: |
41
- | Reactive graph — `Cell` / `Slot` / `Signal` / `Effect` / memo / batch | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
41
+ | Reactive graph — core `Cell` / `Slot` / `Effect` (+ derived `Signal` = `Slot.eager`) / memo / batch | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
42
42
  | Keyed-map materialization (`SlotMap`) — mint-on-access derived slots: transparency + deferral (`#lzmatmode`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
43
43
  | Thread-safe keyed map (`ThreadSafeSlotMap`) — `Send + Sync` + materialization confluence (`#lzmatmode`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
44
44
  | Async keyed map (`AsyncSlotMap`) — eventual transparency (`#lzmatmode`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -51,10 +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`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
54
56
  | Merge algebra + `MergeCell` — associative `MergePolicy` (`KeepLatest`/`Sum`/`Max`/`SetUnion`/`RawFifo`), `Cell ≡ MergeCell<KeepLatest>`, `Reactive`/`Source` split (`#relaycell`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
55
- | RelayCell — conflating relay + `BackpressurePolicy` + `SpillStore` + `Transport` + Inbox/Outbox + Rate/Window/Expiry/Priority/keyed policies (`#relaycell`) | ✅ | | | ✅ | | | | |
57
+ | RelayCell — conflating relay + `BackpressurePolicy` + `SpillStore` + `Transport` + Inbox/Outbox + Rate/Window/Expiry/Priority/keyed policies (`#relaycell`) | ✅ | | | ✅ | | | | |
56
58
  | Free-text character CRDT (`TextCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
57
59
  | `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
60
+ | `CrdtTree` lossless document contract (`#lzcrdttree`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
58
61
  | Move-aware sequence CRDT (`SeqCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
59
62
  | Lossless tree CRDT core (`LosslessTreeCrdt`, M1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
60
63
  | Lossless tree — dotted-frontier anti-entropy | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -65,6 +68,7 @@ notes and platform carve-outs lives in
65
68
  | Cross-process zero-copy transport (`BlobBackend` / shm / arrow) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
66
69
  | Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
67
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) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
68
72
  | Reliable-sync transport seam + full-duplex `SyncDriver` loop (`IpcSink`/`IpcSource`, `#sync-driver`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
69
73
  | Distributed plane — WebRTC transport + signaling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
70
74
  | State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -74,6 +78,14 @@ notes and platform carve-outs lives in
74
78
  | Permission boundary (`PeerPermissions` / `RemoteOp`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
75
79
  | Capability negotiation (`SessionHandshake`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
76
80
  | Instrumentation / benchmarks | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
81
+ | Temporal sources — `TimerCell` / `IntervalCell` / `CronCell` / `DeadlineCell` over a logical clock (`#lztime`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
82
+ | Rate-shaping operators — `DebounceCell` / `ThrottleCell` / `SampleCell` / `ProbabilisticSampleCell` (`#lzrateshape`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
83
+ | Membership + failure detection — `MembershipCell` (SWIM + Phi-accrual) / `PeerSet` / `PeerChangeEvent` (`#lzmemb`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
84
+ | Distributed coordination — `LeaseCell` / `LeaderCell` / `LockCell` / `SemaphoreCell` / `BarrierCell`+`QuorumCell` (`#lzcoord`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
85
+ | Presence + ephemeral plane — `PresenceCell` / `AwarenessCell` / `EphemeralCell` + `Ephemeral`/`Durable` markers (`#lzpresence`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
86
+ | Stream windowing — `TumblingWindow` / `SlidingWindow` / `SessionWindow` over the merge algebra (`#lzwindow`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
87
+ | Fault tolerance — `CircuitBreakerCell` / `RetryPolicyCell` / `BulkheadCell` / `TimeoutCell` (`#lzresilience`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
88
+ | Embedded-service plane — `HealthCell` / `ReadinessCell` / `DiscoveryCell` / `ServiceRegistry` (`#lzservice`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
77
89
  <!-- coverage-table:end -->
78
90
 
79
91
  Two JS ✅ marks are backed by runtime-specific mechanisms while keeping the core isomorphic:
@@ -115,10 +127,12 @@ JavaScript. The family is:
115
127
 
116
128
  - **Slot** - lazy, memoized derived value;
117
129
  - **Cell** - mutable source value;
118
- - **Signal** - eager derived value that re-materializes as soon as a dependency
119
- invalidates;
120
130
  - **Effect** - side-effecting observer with cleanup.
121
131
 
132
+ The core primitives are **Cell** / **Slot** / **Effect**. **`Signal` is a
133
+ derived construct, not a core primitive** — `Signal ≡ Slot.eager`, a memo Slot
134
+ plus a puller Effect that re-materializes as soon as a dependency invalidates.
135
+
122
136
  Dependencies are discovered dynamically while a slot/effect/signal computes.
123
137
  Invalidation is pull-based and glitch-free; `memo`/`Signal` use equality guards
124
138
  to suppress downstream work when a recompute produces the same value. `batch`
@@ -337,6 +351,26 @@ tree.value(); // 1
337
351
  tree.setValue("leaf", 99); // only the ancestor chain recomputes
338
352
  ```
339
353
 
354
+ ## Competing-consumer work queue
355
+
356
+ The `queue` package exports `WorkQueueCell`, a pull-based local authority with
357
+ exclusive FIFO claims, stable item IDs, fresh delivery IDs per attempt,
358
+ worker-owned ack/nack, strict visibility-timeout redelivery, bounded attempts,
359
+ and DLQ routing. Every mutation reports exact `pending_len` / `is_empty` /
360
+ `in_flight_len` / `dead_letter_len` invalidation metadata.
361
+
362
+ ```js
363
+ import { WorkQueueCell } from "@lazily-hub/lazily-js/queue";
364
+
365
+ const work = new WorkQueueCell({ visibility_timeout: 30, max_deliveries: 3 });
366
+ work.push("render-report");
367
+ const delivery = work.claim("worker-a", 100).returns;
368
+ work.ack("worker-a", delivery.delivery_id);
369
+ ```
370
+
371
+ The instance serializes local claims; distributed/HA assignment still requires
372
+ a leader or consensus-committed assignment log.
373
+
340
374
  ## CRDTs
341
375
 
342
376
  `SeqCrdt` is the move-aware sequence CRDT: each element has independent LWW
@@ -344,7 +378,10 @@ registers for value, position, and deletion, so a move is one position
344
378
  assignment rather than delete plus reinsert. `TextCrdt` is a Fugue/RGA
345
379
  character CRDT: concurrent same-point inserts are preserved, deletes are sticky
346
380
  tombstones, and merge is commutative / associative / idempotent. Both expose
347
- tombstone GC behind caller-supplied causal-stability watermarks.
381
+ tombstone GC behind caller-supplied causal-stability watermarks. `TextCrdt`
382
+ also satisfies the `CrdtTree` document contract: its snapshot is the delta from
383
+ an empty frontier, so full hydration and incremental exchange preserve the same
384
+ identity-bearing state.
348
385
 
349
386
  ```js
350
387
  import { SeqCrdt } from "@lazily-hub/lazily-js/seq-crdt";
@@ -360,6 +397,16 @@ peer.insert(2, "!");
360
397
  text.merge(peer); // converges
361
398
  ```
362
399
 
400
+ ## Durable outbox stores
401
+
402
+ The root `Outbox` class owns one append/ack/prune/replay protocol over the
403
+ five-operation `OutboxStore` boundary. `InMemoryStore` exercises that path in
404
+ tests. Browsers can open an `IndexedDbStore` from
405
+ `@lazily-hub/lazily-js/indexeddb-outbox`; await `append` before transport send
406
+ and `ackThrough` before treating an acknowledgement as committed. Reopening the
407
+ same database and channel restores the durable cursor and only unacknowledged
408
+ frames.
409
+
363
410
  ## IPC wire types and capability negotiation
364
411
 
365
412
  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.16.0",
3
+ "version": "0.19.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": {
@@ -77,6 +77,38 @@
77
77
  "types": "./src/relay.d.ts",
78
78
  "default": "./src/relay.js"
79
79
  },
80
+ "./temporal": {
81
+ "types": "./src/temporal.d.ts",
82
+ "default": "./src/temporal.js"
83
+ },
84
+ "./rateshape": {
85
+ "types": "./src/rateshape.d.ts",
86
+ "default": "./src/rateshape.js"
87
+ },
88
+ "./membership": {
89
+ "types": "./src/membership.d.ts",
90
+ "default": "./src/membership.js"
91
+ },
92
+ "./coordination": {
93
+ "types": "./src/coordination.d.ts",
94
+ "default": "./src/coordination.js"
95
+ },
96
+ "./presence": {
97
+ "types": "./src/presence.d.ts",
98
+ "default": "./src/presence.js"
99
+ },
100
+ "./windowing": {
101
+ "types": "./src/windowing.d.ts",
102
+ "default": "./src/windowing.js"
103
+ },
104
+ "./resilience": {
105
+ "types": "./src/resilience.d.ts",
106
+ "default": "./src/resilience.js"
107
+ },
108
+ "./service": {
109
+ "types": "./src/service.d.ts",
110
+ "default": "./src/service.js"
111
+ },
80
112
  "./sem-tree": {
81
113
  "types": "./src/sem-tree.d.ts",
82
114
  "default": "./src/sem-tree.js"
@@ -97,6 +129,11 @@
97
129
  "types": "./src/lossless-tree-crdt.d.ts",
98
130
  "default": "./src/lossless-tree-crdt.js"
99
131
  },
132
+ "./indexeddb-outbox": {
133
+ "types": "./src/indexeddb-outbox.d.ts",
134
+ "browser": "./src/indexeddb-outbox.js",
135
+ "default": "./src/indexeddb-outbox.js"
136
+ },
100
137
  "./state-projection": {
101
138
  "types": "./src/state-projection.d.ts",
102
139
  "default": "./src/state-projection.js"
@@ -111,7 +148,7 @@
111
148
  }
112
149
  },
113
150
  "scripts": {
114
- "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/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",
151
+ "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/rateshape.js && node --check src/membership.js && node --check src/coordination.js && node --check src/presence.js && node --check src/windowing.js && node --check src/resilience.js && node --check src/service.js && node --check src/temporal.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",
115
152
  "test:formal": "node scripts/formal-check.mjs",
116
153
  "bench": "node bench/context.bench.mjs",
117
154
  "bench:scale": "node --max-old-space-size=8192 bench/scale.bench.mjs",
@@ -125,6 +162,7 @@
125
162
  "devDependencies": {
126
163
  "@types/node": "^22.0.0",
127
164
  "ajv": "^8.20.0",
165
+ "fake-indexeddb": "^6.2.5",
128
166
  "tsx": "^4.19.0",
129
167
  "typescript": "^5.6.0"
130
168
  }
@@ -0,0 +1,81 @@
1
+ // Distributed coordination (#lzcoord) — the JS port.
2
+ // See coordination.js and `lazily-spec/docs/coordination.md`.
3
+
4
+ import type { Context, CellHandle } from "./reactive.js";
5
+
6
+ export class LeaseCore<P = unknown> {
7
+ fence: number;
8
+ isHeld(now: number): boolean;
9
+ holder(now: number): P | null;
10
+ acquire(peer: P, now: number, ttl: number): number | null;
11
+ renew(peer: P, now: number, ttl: number): boolean;
12
+ release(peer: P): void;
13
+ tick(now: number): boolean;
14
+ }
15
+
16
+ export class LeaseCell<P = unknown> {
17
+ constructor(ctx: Context);
18
+ readonly holderCell: CellHandle<P | null>;
19
+ acquire(peer: P, now: number, ttl: number): number | null;
20
+ renew(peer: P, now: number, ttl: number): boolean;
21
+ release(peer: P, now: number): void;
22
+ tick(now: number): boolean;
23
+ holder(now: number): P | null;
24
+ isHeld(now: number): boolean;
25
+ fence(): number;
26
+ }
27
+
28
+ export type LeaderRoleLabel = "Leader" | "Follower" | "Candidate";
29
+ export const LeaderRole: Readonly<{ Leader: "Leader"; Follower: "Follower"; Candidate: "Candidate" }>;
30
+
31
+ export class LeaderCell<P = unknown> {
32
+ constructor(ctx: Context, me: P);
33
+ readonly currentLeaderCell: CellHandle<P | null>;
34
+ campaign(now: number, ttl: number): LeaderRoleLabel;
35
+ contend(peer: P, now: number, ttl: number): LeaderRoleLabel;
36
+ tick(now: number): LeaderRoleLabel;
37
+ currentLeader(now: number): P | null;
38
+ role(now: number): LeaderRoleLabel;
39
+ }
40
+
41
+ export class LockCell<P = unknown> {
42
+ constructor(ctx: Context);
43
+ readonly isLockedCell: CellHandle<boolean>;
44
+ acquire(peer: P, now: number, ttl: number): number | null;
45
+ release(peer: P, now: number): void;
46
+ tick(now: number): boolean;
47
+ validate(fence: number): boolean;
48
+ isLocked(now: number): boolean;
49
+ fence(): number;
50
+ }
51
+
52
+ export class SemaphoreCore {
53
+ constructor(capacity: number);
54
+ available(): number;
55
+ acquire(): boolean;
56
+ release(): void;
57
+ }
58
+
59
+ export class SemaphoreCell {
60
+ constructor(ctx: Context, capacity: number);
61
+ readonly permitsAvailableCell: CellHandle<number>;
62
+ acquire(): boolean;
63
+ release(): void;
64
+ permitsAvailable(): number;
65
+ }
66
+
67
+ export class BarrierCore<P = unknown> {
68
+ constructor(required: number);
69
+ arrive(peer: P): boolean;
70
+ count(): number;
71
+ isOpen(): boolean;
72
+ }
73
+
74
+ export class BarrierCell<P = unknown> {
75
+ constructor(ctx: Context, required: number);
76
+ static quorum<P = unknown>(ctx: Context, total: number): BarrierCell<P>;
77
+ readonly isOpenCell: CellHandle<boolean>;
78
+ arrive(peer: P): boolean;
79
+ count(): number;
80
+ isOpen(): boolean;
81
+ }
@@ -0,0 +1,283 @@
1
+ // Distributed coordination (#lzcoord) — the JS port.
2
+ //
3
+ // See `lazily-spec/docs/coordination.md` and the formal model
4
+ // `lazily-formal/LazilyFormal/Coordination.lean`. Lease / leader / lock /
5
+ // semaphore / barrier + quorum primitives, each a pure compute core split from a
6
+ // reactive cell projecting the salient reader. Time is the logical clock. A
7
+ // holder of `null` means "no holder".
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Lease + fencing token
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** Single-writer lease authority with a monotone fencing token. */
14
+ export class LeaseCore {
15
+ constructor() {
16
+ this.holderPeer = null;
17
+ this.expiry = 0;
18
+ this.fence = 0;
19
+ }
20
+ #isExpired(now) {
21
+ return this.holderPeer !== null && now >= this.expiry;
22
+ }
23
+ isHeld(now) {
24
+ return this.holderPeer !== null && !this.#isExpired(now);
25
+ }
26
+ holder(now) {
27
+ return this.isHeld(now) ? this.holderPeer : null;
28
+ }
29
+ acquire(peer, now, ttl) {
30
+ if (this.holderPeer === null || this.#isExpired(now)) {
31
+ this.fence += 1;
32
+ this.holderPeer = peer;
33
+ this.expiry = now + ttl;
34
+ return this.fence;
35
+ }
36
+ if (this.holderPeer === peer) {
37
+ this.expiry = now + ttl; // renew keeps fence
38
+ return this.fence;
39
+ }
40
+ return null;
41
+ }
42
+ renew(peer, now, ttl) {
43
+ if (this.isHeld(now) && this.holderPeer === peer) {
44
+ this.expiry = now + ttl;
45
+ return true;
46
+ }
47
+ return false;
48
+ }
49
+ release(peer) {
50
+ if (this.holderPeer === peer) this.holderPeer = null;
51
+ }
52
+ tick(now) {
53
+ if (this.#isExpired(now)) {
54
+ this.holderPeer = null;
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /** Reactive lease: projects the holder onto a cell. */
62
+ export class LeaseCell {
63
+ constructor(ctx) {
64
+ this.ctx = ctx;
65
+ this.core = new LeaseCore();
66
+ this.holderCell = ctx.cell(null);
67
+ }
68
+ #refresh(now) {
69
+ this.ctx.setCell(this.holderCell, this.core.holder(now));
70
+ }
71
+ acquire(peer, now, ttl) {
72
+ const r = this.core.acquire(peer, now, ttl);
73
+ this.#refresh(now);
74
+ return r;
75
+ }
76
+ renew(peer, now, ttl) {
77
+ const r = this.core.renew(peer, now, ttl);
78
+ this.#refresh(now);
79
+ return r;
80
+ }
81
+ release(peer, now) {
82
+ this.core.release(peer);
83
+ this.#refresh(now);
84
+ }
85
+ tick(now) {
86
+ const r = this.core.tick(now);
87
+ this.#refresh(now);
88
+ return r;
89
+ }
90
+ holder(now) {
91
+ return this.core.holder(now);
92
+ }
93
+ isHeld(now) {
94
+ return this.core.isHeld(now);
95
+ }
96
+ fence() {
97
+ return this.core.fence;
98
+ }
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Leader / follower / candidate
103
+ // ---------------------------------------------------------------------------
104
+
105
+ export const LeaderRole = Object.freeze({
106
+ Leader: "Leader",
107
+ Follower: "Follower",
108
+ Candidate: "Candidate",
109
+ });
110
+
111
+ /** Reactive leadership over a lease from node `me`'s perspective. */
112
+ export class LeaderCell {
113
+ constructor(ctx, me) {
114
+ this.ctx = ctx;
115
+ this.me = me;
116
+ this.core = new LeaseCore();
117
+ this.currentLeaderCell = ctx.cell(null);
118
+ }
119
+ #refresh(now) {
120
+ this.ctx.setCell(this.currentLeaderCell, this.core.holder(now));
121
+ }
122
+ campaign(now, ttl) {
123
+ this.core.acquire(this.me, now, ttl);
124
+ this.#refresh(now);
125
+ return this.role(now);
126
+ }
127
+ contend(peer, now, ttl) {
128
+ this.core.acquire(peer, now, ttl);
129
+ this.#refresh(now);
130
+ return this.role(now);
131
+ }
132
+ tick(now) {
133
+ this.core.tick(now);
134
+ this.#refresh(now);
135
+ return this.role(now);
136
+ }
137
+ currentLeader(now) {
138
+ return this.core.holder(now);
139
+ }
140
+ role(now) {
141
+ const h = this.core.holder(now);
142
+ if (h === null) return LeaderRole.Candidate;
143
+ return h === this.me ? LeaderRole.Leader : LeaderRole.Follower;
144
+ }
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Distributed lock + fencing
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /** Reactive distributed mutex over a lease + fencing token. */
152
+ export class LockCell {
153
+ constructor(ctx) {
154
+ this.ctx = ctx;
155
+ this.core = new LeaseCore();
156
+ this.isLockedCell = ctx.cell(false);
157
+ }
158
+ #refresh(now) {
159
+ this.ctx.setCell(this.isLockedCell, this.core.isHeld(now));
160
+ }
161
+ acquire(peer, now, ttl) {
162
+ const r = this.core.acquire(peer, now, ttl);
163
+ this.#refresh(now);
164
+ return r;
165
+ }
166
+ release(peer, now) {
167
+ this.core.release(peer);
168
+ this.#refresh(now);
169
+ }
170
+ tick(now) {
171
+ const r = this.core.tick(now);
172
+ this.#refresh(now);
173
+ return r;
174
+ }
175
+ /** Whether `fence` is the current (non-stale) fencing token. */
176
+ validate(fence) {
177
+ return this.core.fence === fence;
178
+ }
179
+ isLocked(now) {
180
+ return this.core.isHeld(now);
181
+ }
182
+ fence() {
183
+ return this.core.fence;
184
+ }
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Semaphore
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /** Bounded permit pool compute core. */
192
+ export class SemaphoreCore {
193
+ constructor(capacity) {
194
+ this.capacity = capacity;
195
+ this.acquired = 0;
196
+ }
197
+ available() {
198
+ return this.capacity - this.acquired;
199
+ }
200
+ acquire() {
201
+ if (this.acquired < this.capacity) {
202
+ this.acquired += 1;
203
+ return true;
204
+ }
205
+ return false;
206
+ }
207
+ release() {
208
+ if (this.acquired > 0) this.acquired -= 1;
209
+ }
210
+ }
211
+
212
+ /** Reactive semaphore: projects permitsAvailable onto a cell. */
213
+ export class SemaphoreCell {
214
+ constructor(ctx, capacity) {
215
+ this.ctx = ctx;
216
+ this.core = new SemaphoreCore(capacity);
217
+ this.permitsAvailableCell = ctx.cell(capacity);
218
+ }
219
+ #refresh() {
220
+ this.ctx.setCell(this.permitsAvailableCell, this.core.available());
221
+ }
222
+ acquire() {
223
+ const r = this.core.acquire();
224
+ this.#refresh();
225
+ return r;
226
+ }
227
+ release() {
228
+ this.core.release();
229
+ this.#refresh();
230
+ }
231
+ permitsAvailable() {
232
+ return this.ctx.getCell(this.permitsAvailableCell);
233
+ }
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Barrier / quorum
238
+ // ---------------------------------------------------------------------------
239
+
240
+ /** Wait-for-N gate over distinct arriving peers. */
241
+ export class BarrierCore {
242
+ constructor(required) {
243
+ this.required = required;
244
+ this.arrived = new Set();
245
+ }
246
+ arrive(peer) {
247
+ this.arrived.add(peer);
248
+ return this.isOpen();
249
+ }
250
+ count() {
251
+ return this.arrived.size;
252
+ }
253
+ isOpen() {
254
+ return this.count() >= this.required;
255
+ }
256
+ }
257
+
258
+ /** Reactive wait-for-N gate. A quorum is a barrier with required = total/2 + 1. */
259
+ export class BarrierCell {
260
+ constructor(ctx, required) {
261
+ this.ctx = ctx;
262
+ this.core = new BarrierCore(required);
263
+ this.isOpenCell = ctx.cell(this.core.isOpen());
264
+ }
265
+ /** A quorum gate: opens at strict majority of `total`. */
266
+ static quorum(ctx, total) {
267
+ return new BarrierCell(ctx, Math.floor(total / 2) + 1);
268
+ }
269
+ #refresh() {
270
+ this.ctx.setCell(this.isOpenCell, this.core.isOpen());
271
+ }
272
+ arrive(peer) {
273
+ const r = this.core.arrive(peer);
274
+ this.#refresh();
275
+ return r;
276
+ }
277
+ count() {
278
+ return this.core.count();
279
+ }
280
+ isOpen() {
281
+ return this.ctx.getCell(this.isOpenCell);
282
+ }
283
+ }
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;