@lazily-hub/lazily-js 0.12.0 → 0.14.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
@@ -40,10 +40,10 @@ 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`) | ✅ | | ✅ | ✅ | | ✅ | | ✅ |
46
- | Thread-safe context (lock-backed) | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ |
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`) | ✅ | | ✅ | ✅ | | ✅ | | ✅ |
46
+ | Thread-safe context (lock-backed) | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ |
47
47
  | Async reactive context | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
48
48
  | Flat state machine | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
49
49
  | Harel state charts | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -59,9 +59,11 @@ notes and platform carve-outs lives in
59
59
  | Lossless tree — concurrent merge convergence | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
60
60
  | Registers (LWW / MV) + `PnCounter` + `CellCrdt` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
61
61
  | IPC wire — `Snapshot` + `Delta` + `CrdtSync` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
62
- | Shared-memory blob path (`ShmBlobArena`) | ✅ | ✅ | ✅ | ✅ | ~ | ✅ | ✅ | ✅ |
62
+ | Shared-memory blob path (`ShmBlobArena`) | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ |
63
63
  | Cross-process zero-copy transport (`BlobBackend` / shm / arrow) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
64
64
  | Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
65
+ | Reliable sync — resync coordinator + at-least-once durable outbox + OR-set/LWW liveness (`#lzsync`) | ✅ | — | ✅ | ✅ | — | — | — | — |
66
+ | Reliable-sync transport seam + full-duplex `SyncDriver` loop (`IpcSink`/`IpcSource`, `#sync-driver`) | ✅ | — | ✅ | ✅ | — | — | — | — |
65
67
  | Distributed plane — WebRTC transport + signaling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
66
68
  | State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
67
69
  | Causal receipts (`CausalReceipts` outcome projection) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -72,6 +74,11 @@ notes and platform carve-outs lives in
72
74
  | Instrumentation / benchmarks | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
73
75
  <!-- coverage-table:end -->
74
76
 
77
+ Two JS ✅ marks are backed by runtime-specific mechanisms while keeping the core isomorphic:
78
+
79
+ - **Thread-safe** (`ThreadSafeReactiveFamily`, `Thread-safe context`) — cross-realm mutual exclusion via a `SharedArrayBuffer` + `Atomics` reentrant mutex shared across Web Workers / `worker_threads`; degrades to a single-realm guard where shared memory is unavailable (e.g. a browser without cross-origin isolation), which is sound because no shared memory means no cross-realm concurrency.
80
+ - **C-ABI FFI** — the normative codec + in-process `FfiChannel` are pure-JS and run unchanged in the browser; the Node build additionally binds the real `lazily` shared library (`lazily_ffi_channel_*`) via koffi. Both speak the identical byte contract, so browser and native are drop-in interchangeable.
81
+
75
82
  CRDT convergence and the wire protocol are pinned by the shared conformance fixtures
76
83
  and JSON Schemas in `lazily-spec` and the Lean models in `lazily-formal`.
77
84
  ## Packages
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazily-hub/lazily-js",
3
- "version": "0.12.0",
3
+ "version": "0.14.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 families with materialization mode, 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": {
@@ -93,6 +93,10 @@
93
93
  "types": "./src/state-projection.d.ts",
94
94
  "default": "./src/state-projection.js"
95
95
  },
96
+ "./state-graph-mirror": {
97
+ "types": "./src/state-graph-mirror.d.ts",
98
+ "default": "./src/state-graph-mirror.js"
99
+ },
96
100
  "./signaling": {
97
101
  "types": "./src/signaling.d.ts",
98
102
  "default": "./src/signaling.js"
@@ -103,7 +107,7 @@
103
107
  }
104
108
  },
105
109
  "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/signaling.js && node --check src/distributed.js",
110
+ "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/state-graph-mirror.js && node --check src/signaling.js && node --check src/distributed.js",
107
111
  "test:formal": "node scripts/formal-check.mjs",
108
112
  "bench": "node bench/context.bench.mjs",
109
113
  "bench:scale": "node --max-old-space-size=8192 bench/scale.bench.mjs",
@@ -163,10 +163,13 @@ export class WebRtcSink {
163
163
  filtered = IpcMessage.delta(
164
164
  message.delta.filterReadable(this.#permissions, this.#peer),
165
165
  );
166
- } else {
166
+ } else if (message.isCrdtSync) {
167
167
  filtered = IpcMessage.crdtSync(
168
168
  message.crdtSync.filterReadable(this.#permissions, this.#peer),
169
169
  );
170
+ } else {
171
+ // Reliable-sync control frames carry no node content; filtering is identity.
172
+ filtered = message;
170
173
  }
171
174
  let frame;
172
175
  try {
package/src/ffi.js CHANGED
@@ -21,7 +21,13 @@ import { IpcMessage, LazilyFfiMessageKind, LazilyFfiStatus } from "./index.js";
21
21
  export { LazilyFfiMessageKind, LazilyFfiStatus, IpcMessage };
22
22
 
23
23
  function isUnknownKind(message) {
24
- return !(message && (message.isSnapshot || message.isDelta || message.isCrdtSync));
24
+ return !(
25
+ message &&
26
+ (message.isSnapshot ||
27
+ message.isDelta ||
28
+ message.isCrdtSync ||
29
+ message.isControl)
30
+ );
25
31
  }
26
32
 
27
33
  /**
@@ -43,6 +49,12 @@ export function kindOf(message) {
43
49
  if (message.isCrdtSync) {
44
50
  return LazilyFfiMessageKind.CrdtSync;
45
51
  }
52
+ if (message.kind === "ResyncRequest") {
53
+ return LazilyFfiMessageKind.ResyncRequest;
54
+ }
55
+ if (message.kind === "OutboxAck") {
56
+ return LazilyFfiMessageKind.OutboxAck;
57
+ }
46
58
  return LazilyFfiMessageKind.Unknown;
47
59
  }
48
60
 
package/src/index.d.ts CHANGED
@@ -233,6 +233,8 @@ export class Delta {
233
233
  readonly epoch: number;
234
234
  readonly ops: readonly DeltaOpValue[];
235
235
  isNextAfter(lastEpoch: number): boolean;
236
+ /** Accepted-event span `epoch - baseEpoch` (>= 0); > 1 for a multi-epoch-span delta (#lzsync). */
237
+ span(): number;
236
238
  applyStatus(lastEpoch: number): DeltaApplyStatus;
237
239
  filterReadable(permissions: PeerPermissions, peer: PeerId): Delta;
238
240
  toWire(): unknown;
@@ -385,23 +387,172 @@ export class ReceiptProjection {
385
387
  staleReceiptIds(): string[];
386
388
  }
387
389
 
390
+ // Reliable-sync reverse-channel control frames (#lzsync).
391
+ export class ResyncRequest {
392
+ constructor(fields: { fromEpoch: number });
393
+ readonly fromEpoch: number;
394
+ toWire(): { from_epoch: number };
395
+ static fromWire(value: unknown): ResyncRequest;
396
+ }
397
+
398
+ export class OutboxAck {
399
+ constructor(fields: { throughEpoch: number });
400
+ readonly throughEpoch: number;
401
+ toWire(): { through_epoch: number };
402
+ static fromWire(value: unknown): OutboxAck;
403
+ }
404
+
388
405
  export class IpcMessage {
389
- readonly kind: "Snapshot" | "Delta" | "CrdtSync";
406
+ readonly kind: "Snapshot" | "Delta" | "CrdtSync" | "ResyncRequest" | "OutboxAck";
390
407
  readonly snapshot?: Snapshot;
391
408
  readonly delta?: Delta;
392
409
  readonly crdtSync?: CrdtSync;
410
+ readonly resyncRequest?: ResyncRequest;
411
+ readonly outboxAck?: OutboxAck;
393
412
  readonly isSnapshot: boolean;
394
413
  readonly isDelta: boolean;
395
414
  readonly isCrdtSync: boolean;
415
+ readonly isResyncRequest: boolean;
416
+ readonly isOutboxAck: boolean;
417
+ readonly isControl: boolean;
396
418
  toWire(): unknown;
397
419
  encodeJson(): Uint8Array;
398
420
  static snapshot(snapshot: Snapshot): IpcMessage;
399
421
  static delta(delta: Delta): IpcMessage;
400
422
  static crdtSync(crdtSync: CrdtSync): IpcMessage;
423
+ static resyncRequestMessage(request: ResyncRequest): IpcMessage;
424
+ static outboxAckMessage(ack: OutboxAck): IpcMessage;
401
425
  static fromWire(value: unknown): IpcMessage;
402
426
  static decodeJson(data: Uint8Array | string): IpcMessage;
403
427
  }
404
428
 
429
+ // Reliable sync protocol (#lzsync).
430
+ export const ResyncAction: {
431
+ readonly Apply: "Apply";
432
+ readonly RequestSnapshot: "RequestSnapshot";
433
+ readonly Ignore: "Ignore";
434
+ };
435
+
436
+ export type ResyncActionResult = {
437
+ action: "Apply" | "RequestSnapshot" | "Ignore";
438
+ fromEpoch?: number;
439
+ };
440
+
441
+ export class ResyncCoordinator {
442
+ constructor(lastEpoch?: number);
443
+ lastEpoch: number;
444
+ resyncing: boolean;
445
+ ingestDelta(delta: Delta): ResyncActionResult;
446
+ ingestSnapshot(snapshotEpoch: number): ResyncActionResult;
447
+ ingest(msg: IpcMessage): ResyncActionResult;
448
+ ack(): IpcMessage;
449
+ }
450
+
451
+ export interface DurableOutbox {
452
+ append(epoch: number, msg: IpcMessage): void;
453
+ ackThrough(epoch: number): void;
454
+ replayFrom(cursor: number): Array<[number, IpcMessage]>;
455
+ retainedEpochs(): number[];
456
+ }
457
+
458
+ export class InMemoryOutbox implements DurableOutbox {
459
+ ackedThrough: number;
460
+ append(epoch: number, msg: IpcMessage): void;
461
+ ackThrough(epoch: number): void;
462
+ replayFrom(cursor: number): Array<[number, IpcMessage]>;
463
+ retainedEpochs(): number[];
464
+ }
465
+
466
+ export class OrSet {
467
+ add(tag: string): void;
468
+ removeObserved(tags: Iterable<string>): void;
469
+ present(): boolean;
470
+ join(other: OrSet): void;
471
+ }
472
+
473
+ export function wireStampGreater(a: WireStamp, b: WireStamp): boolean;
474
+
475
+ export class WireLwwRegister<V = unknown> {
476
+ constructor(stamp: WireStamp, value: V);
477
+ stamp: WireStamp;
478
+ value: V;
479
+ set(stamp: WireStamp, value: V): void;
480
+ join(other: WireLwwRegister<V>): void;
481
+ }
482
+
483
+ // Full-duplex reliable-sync loop driver (#sync-driver).
484
+
485
+ // Outbound transport seam: deliver one already-framed IpcMessage. Returning
486
+ // `false` (or throwing) means the frame was not durably handed to the peer;
487
+ // at-least-once is a driver property, not a sink property.
488
+ export interface IpcSink {
489
+ send(msg: IpcMessage): boolean | void;
490
+ }
491
+
492
+ // Inbound transport seam: poll for the next frame without blocking. `null`/
493
+ // `undefined` means currently exhausted/closed; a throw is the reconnect signal
494
+ // (surfaces from tick() as a DriverError).
495
+ export interface IpcSource {
496
+ recv(): IpcMessage | null | undefined;
497
+ }
498
+
499
+ // Monotonic clock seam (host owns cadence; the driver only timestamps stalls).
500
+ export interface Clock {
501
+ nowMillis(): number;
502
+ }
503
+
504
+ // Sender-side answer to a peer's ResyncRequest: a covering Snapshot at
505
+ // epoch >= fromEpoch.
506
+ export interface SnapshotProvider {
507
+ snapshot(fromEpoch: number): IpcMessage;
508
+ }
509
+
510
+ export class SystemClock implements Clock {
511
+ nowMillis(): number;
512
+ }
513
+
514
+ export class Progress {
515
+ sent: number;
516
+ applied: IpcMessage[];
517
+ resyncRequested: boolean;
518
+ snapshotsServed: number;
519
+ peerAckedThrough: number;
520
+ retained: number;
521
+ }
522
+
523
+ export class DriverError extends Error {
524
+ readonly name: "DriverError";
525
+ readonly kind: "Source";
526
+ readonly cause: unknown;
527
+ }
528
+
529
+ export interface SyncDriverOptions {
530
+ sink: IpcSink;
531
+ source: IpcSource;
532
+ outbox?: DurableOutbox;
533
+ clock?: Clock;
534
+ provider: SnapshotProvider;
535
+ lastEpoch?: number;
536
+ }
537
+
538
+ export class SyncDriver {
539
+ constructor(options: SyncDriverOptions);
540
+ sink: IpcSink;
541
+ source: IpcSource;
542
+ outbox: DurableOutbox;
543
+ clock: Clock;
544
+ provider: SnapshotProvider;
545
+ coordinator: ResyncCoordinator;
546
+ peerAckedThrough: number;
547
+ enqueue(epoch: number, msg: IpcMessage): void;
548
+ onReconnect(): void;
549
+ lastEpoch(): number;
550
+ isStalled(): boolean;
551
+ stalledFor(now: number): number;
552
+ /** One scheduler-driven step. Throws DriverError on an inbound source read failure. */
553
+ tick(): Progress;
554
+ }
555
+
405
556
  export const OpKind: {
406
557
  readonly Read: "read";
407
558
  readonly Write: "write";
@@ -449,6 +600,8 @@ export const LazilyFfiMessageKind: {
449
600
  readonly Snapshot: 1;
450
601
  readonly Delta: 2;
451
602
  readonly CrdtSync: 3;
603
+ readonly ResyncRequest: 4;
604
+ readonly OutboxAck: 5;
452
605
  };
453
606
 
454
607
  export const LazilyFfiStatus: {
package/src/index.js CHANGED
@@ -601,6 +601,13 @@ export class Delta {
601
601
  return this.baseEpoch === lastEpoch && this.epoch === this.baseEpoch + 1;
602
602
  }
603
603
 
604
+ // The accepted-event span this delta advances: epoch - baseEpoch (usually 1,
605
+ // > 1 for a coalesced multi-epoch-span delta; #lzsync, spec § Multi-epoch-span
606
+ // delta). Coerced to >= 0 for a malformed backward delta.
607
+ span() {
608
+ return Math.max(0, this.epoch - this.baseEpoch);
609
+ }
610
+
604
611
  applyStatus(lastEpoch) {
605
612
  if (this.isNextAfter(lastEpoch)) {
606
613
  return DeltaApplyStatus.apply();
@@ -1007,18 +1014,61 @@ export class ReceiptProjection {
1007
1014
  }
1008
1015
  }
1009
1016
 
1017
+ // Reliable-sync reverse-channel control frame: request a covering Snapshot on a
1018
+ // detected gap (#lzsync, spec § ResyncCoordinator). Carries no node content.
1019
+ export class ResyncRequest {
1020
+ constructor({ fromEpoch }) {
1021
+ this.fromEpoch = assertInteger(fromEpoch, "fromEpoch");
1022
+ Object.freeze(this);
1023
+ }
1024
+
1025
+ toWire() {
1026
+ return { from_epoch: this.fromEpoch };
1027
+ }
1028
+
1029
+ static fromWire(value) {
1030
+ const object = assertObject(value, "ResyncRequest");
1031
+ return new ResyncRequest({ fromEpoch: object.from_epoch });
1032
+ }
1033
+ }
1034
+
1035
+ // Reliable-sync reverse-channel control frame: prove receipt through throughEpoch
1036
+ // (#lzsync, spec § DurableOutbox). Advances the sender's outbox retention cursor
1037
+ // and doubles as the reconnect resume cursor. Carries no node content.
1038
+ export class OutboxAck {
1039
+ constructor({ throughEpoch }) {
1040
+ this.throughEpoch = assertInteger(throughEpoch, "throughEpoch");
1041
+ Object.freeze(this);
1042
+ }
1043
+
1044
+ toWire() {
1045
+ return { through_epoch: this.throughEpoch };
1046
+ }
1047
+
1048
+ static fromWire(value) {
1049
+ const object = assertObject(value, "OutboxAck");
1050
+ return new OutboxAck({ throughEpoch: object.through_epoch });
1051
+ }
1052
+ }
1053
+
1010
1054
  export class IpcMessage {
1011
1055
  constructor(kind, value) {
1012
1056
  this.kind = kind;
1013
1057
  this.snapshot = undefined;
1014
1058
  this.delta = undefined;
1015
1059
  this.crdtSync = undefined;
1060
+ this.resyncRequest = undefined;
1061
+ this.outboxAck = undefined;
1016
1062
  if (kind === "Snapshot") {
1017
1063
  this.snapshot = value;
1018
1064
  } else if (kind === "Delta") {
1019
1065
  this.delta = value;
1020
1066
  } else if (kind === "CrdtSync") {
1021
1067
  this.crdtSync = value;
1068
+ } else if (kind === "ResyncRequest") {
1069
+ this.resyncRequest = value;
1070
+ } else if (kind === "OutboxAck") {
1071
+ this.outboxAck = value;
1022
1072
  } else {
1023
1073
  throw new TypeError(`unknown IpcMessage kind: ${kind}`);
1024
1074
  }
@@ -1037,6 +1087,19 @@ export class IpcMessage {
1037
1087
  return this.kind === "CrdtSync";
1038
1088
  }
1039
1089
 
1090
+ get isResyncRequest() {
1091
+ return this.kind === "ResyncRequest";
1092
+ }
1093
+
1094
+ get isOutboxAck() {
1095
+ return this.kind === "OutboxAck";
1096
+ }
1097
+
1098
+ // Reliable-sync reverse-channel control frame (no node content).
1099
+ get isControl() {
1100
+ return this.kind === "ResyncRequest" || this.kind === "OutboxAck";
1101
+ }
1102
+
1040
1103
  toWire() {
1041
1104
  if (this.kind === "Snapshot") {
1042
1105
  return { Snapshot: this.snapshot.toWire() };
@@ -1044,7 +1107,13 @@ export class IpcMessage {
1044
1107
  if (this.kind === "Delta") {
1045
1108
  return { Delta: this.delta.toWire() };
1046
1109
  }
1047
- return { CrdtSync: this.crdtSync.toWire() };
1110
+ if (this.kind === "CrdtSync") {
1111
+ return { CrdtSync: this.crdtSync.toWire() };
1112
+ }
1113
+ if (this.kind === "ResyncRequest") {
1114
+ return { ResyncRequest: this.resyncRequest.toWire() };
1115
+ }
1116
+ return { OutboxAck: this.outboxAck.toWire() };
1048
1117
  }
1049
1118
 
1050
1119
  encodeJson() {
@@ -1063,6 +1132,14 @@ export class IpcMessage {
1063
1132
  return new IpcMessage("CrdtSync", crdtSync);
1064
1133
  }
1065
1134
 
1135
+ static resyncRequestMessage(request) {
1136
+ return new IpcMessage("ResyncRequest", request);
1137
+ }
1138
+
1139
+ static outboxAckMessage(ack) {
1140
+ return new IpcMessage("OutboxAck", ack);
1141
+ }
1142
+
1066
1143
  static fromWire(value) {
1067
1144
  const [tag, body] = assertTagged(value, "IpcMessage");
1068
1145
  switch (tag) {
@@ -1072,6 +1149,10 @@ export class IpcMessage {
1072
1149
  return IpcMessage.delta(Delta.fromWire(body));
1073
1150
  case "CrdtSync":
1074
1151
  return IpcMessage.crdtSync(CrdtSync.fromWire(body));
1152
+ case "ResyncRequest":
1153
+ return IpcMessage.resyncRequestMessage(ResyncRequest.fromWire(body));
1154
+ case "OutboxAck":
1155
+ return IpcMessage.outboxAckMessage(OutboxAck.fromWire(body));
1075
1156
  default:
1076
1157
  throw new TypeError(`unknown IpcMessage variant: ${tag}`);
1077
1158
  }
@@ -1104,6 +1185,8 @@ export const LazilyFfiMessageKind = Object.freeze({
1104
1185
  Snapshot: 1,
1105
1186
  Delta: 2,
1106
1187
  CrdtSync: 3,
1188
+ ResyncRequest: 4,
1189
+ OutboxAck: 5,
1107
1190
  });
1108
1191
 
1109
1192
  // The FFI status codes mirror schemas/ffi.json § LazilyFfiStatus.
@@ -1116,6 +1199,364 @@ export const LazilyFfiStatus = Object.freeze({
1116
1199
  Panic: 5,
1117
1200
  });
1118
1201
 
1202
+ // ---------------------------------------------------------------------------
1203
+ // Reliable sync protocol (#lzsync).
1204
+ //
1205
+ // Delivery-reliability over the Snapshot/Delta/CrdtSync planes (lazily-spec
1206
+ // § Reliable Sync): gap recovery, at-least-once outbox, and OR-set / LWW liveness
1207
+ // cells. Correctness backstop: lazily-formal ReliableSync.lean; cross-language
1208
+ // pins: lazily-spec/conformance/reliable-sync/.
1209
+ // ---------------------------------------------------------------------------
1210
+
1211
+ // Receiver decision for an inbound frame (spec § ResyncCoordinator).
1212
+ export const ResyncAction = Object.freeze({
1213
+ Apply: "Apply",
1214
+ RequestSnapshot: "RequestSnapshot",
1215
+ Ignore: "Ignore",
1216
+ });
1217
+
1218
+ // Receiver-side reliable-sync coordinator. Holds lastEpoch (highest epoch fully
1219
+ // applied) and a resyncing flag (a RequestSnapshot is outstanding until a covering
1220
+ // Snapshot lands). ingest advances lastEpoch on Apply; the caller MUST fold the
1221
+ // frame's ops on Apply. Mirrors ReliableSync.step.
1222
+ export class ResyncCoordinator {
1223
+ constructor(lastEpoch = 0) {
1224
+ this.lastEpoch = assertInteger(lastEpoch, "lastEpoch");
1225
+ this.resyncing = false;
1226
+ }
1227
+
1228
+ // Classify + fold an inbound Delta; advances to delta.epoch on Apply (multi-epoch aware).
1229
+ // Returns { action, fromEpoch? }.
1230
+ ingestDelta(delta) {
1231
+ if (delta.baseEpoch === this.lastEpoch) {
1232
+ if (delta.epoch >= delta.baseEpoch + 1) {
1233
+ this.lastEpoch = delta.epoch;
1234
+ this.resyncing = false;
1235
+ return { action: ResyncAction.Apply };
1236
+ }
1237
+ return { action: ResyncAction.Ignore }; // empty/backward epoch
1238
+ }
1239
+ if (delta.baseEpoch < this.lastEpoch) {
1240
+ return { action: ResyncAction.Ignore }; // already applied — re-delivery
1241
+ }
1242
+ // gap: baseEpoch > lastEpoch
1243
+ if (this.resyncing) {
1244
+ return { action: ResyncAction.Ignore }; // suppress duplicate request
1245
+ }
1246
+ this.resyncing = true;
1247
+ return { action: ResyncAction.RequestSnapshot, fromEpoch: this.lastEpoch };
1248
+ }
1249
+
1250
+ // Adopt a Snapshot — a full-state frame always applies.
1251
+ ingestSnapshot(snapshotEpoch) {
1252
+ this.lastEpoch = snapshotEpoch;
1253
+ this.resyncing = false;
1254
+ return { action: ResyncAction.Apply };
1255
+ }
1256
+
1257
+ // Classify an inbound IpcMessage. CrdtSync rides the CRDT plane and the
1258
+ // reverse-channel control frames are for the sender's driver, so both are
1259
+ // ignored by this data receiver.
1260
+ ingest(msg) {
1261
+ if (msg.isSnapshot) return this.ingestSnapshot(msg.snapshot.epoch);
1262
+ if (msg.isDelta) return this.ingestDelta(msg.delta);
1263
+ return { action: ResyncAction.Ignore };
1264
+ }
1265
+
1266
+ // The IpcMessage(OutboxAck) advertising this receiver's resume cursor.
1267
+ ack() {
1268
+ return IpcMessage.outboxAckMessage(new OutboxAck({ throughEpoch: this.lastEpoch }));
1269
+ }
1270
+ }
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;
1278
+ }
1279
+
1280
+ append(epoch, msg) {
1281
+ this.entries.push([epoch, msg]);
1282
+ }
1283
+
1284
+ ackThrough(epoch) {
1285
+ if (epoch > this.ackedThrough) this.ackedThrough = epoch;
1286
+ this.entries = this.entries.filter(([e]) => e > this.ackedThrough);
1287
+ }
1288
+
1289
+ replayFrom(cursor) {
1290
+ return this.entries.filter(([e]) => e > cursor).sort((a, b) => a[0] - b[0]);
1291
+ }
1292
+
1293
+ retainedEpochs() {
1294
+ return this.entries.map(([e]) => e).sort((a, b) => a - b);
1295
+ }
1296
+ }
1297
+
1298
+ // An observed-remove set (OR-set) liveness cell. A (doc, pid) is present iff some
1299
+ // add-tag is not shadowed by a remove that observed it (add-wins over a stale
1300
+ // remove). Join is the union of both tag sets — a semilattice, so out-of-order and
1301
+ // duplicate delivery converge (ReliableSync.joinOR_*).
1302
+ export class OrSet {
1303
+ constructor() {
1304
+ this.adds = new Set();
1305
+ this.removes = new Set();
1306
+ }
1307
+
1308
+ add(tag) {
1309
+ this.adds.add(tag);
1310
+ }
1311
+
1312
+ removeObserved(tags) {
1313
+ for (const t of tags) this.removes.add(t);
1314
+ }
1315
+
1316
+ present() {
1317
+ for (const t of this.adds) if (!this.removes.has(t)) return true;
1318
+ return false;
1319
+ }
1320
+
1321
+ join(other) {
1322
+ for (const t of other.adds) this.adds.add(t);
1323
+ for (const t of other.removes) this.removes.add(t);
1324
+ }
1325
+ }
1326
+
1327
+ // Total order (wallTime, logical, peer) — the wire mirror of the HLC stamp.
1328
+ export function wireStampGreater(a, b) {
1329
+ if (a.wallTime !== b.wallTime) return a.wallTime > b.wallTime;
1330
+ if (a.logical !== b.logical) return a.logical > b.logical;
1331
+ return a.peer > b.peer;
1332
+ }
1333
+
1334
+ // A last-writer-wins register liveness cell (per-pid alive, owner lease), keyed by
1335
+ // WireStamp: the highest stamp wins. Join is the stamp-max, a semilattice
1336
+ // (ReliableSync.joinReg_*).
1337
+ export class WireLwwRegister {
1338
+ constructor(stamp, value) {
1339
+ this.stamp = stamp;
1340
+ this.value = value;
1341
+ }
1342
+
1343
+ // Write value at stamp iff it dominates the current stamp.
1344
+ set(stamp, value) {
1345
+ if (wireStampGreater(stamp, this.stamp)) {
1346
+ this.stamp = stamp;
1347
+ this.value = value;
1348
+ }
1349
+ }
1350
+
1351
+ // Join another replica's register (keep the higher stamp).
1352
+ join(other) {
1353
+ this.set(other.stamp, other.value);
1354
+ }
1355
+ }
1356
+
1357
+ // -- SyncDriver: full-duplex reliable-sync loop (#sync-driver) --------------
1358
+ //
1359
+ // The transport seam is two host-supplied duck-typed objects (spec §
1360
+ // "Transport seam (IpcSink / IpcSource, #lzsync-transport-seam)"); the seams
1361
+ // carry no wire form of their own — what crosses the wire is the codec-encoded
1362
+ // IpcMessage frame. A binding names them idiomatically; the semantics are
1363
+ // normative:
1364
+ //
1365
+ // IpcSink — { send(msg): boolean } outbound: deliver one already-framed
1366
+ // IpcMessage. Returning `false` (or throwing) means the frame was
1367
+ // NOT durably handed to the peer; at-least-once is a DRIVER
1368
+ // property (retain-in-outbox + replay-on-reconnect), not a sink
1369
+ // property, so a sink is free to be plain best-effort.
1370
+ // IpcSource — { recv(): IpcMessage | null } inbound: poll for the next frame
1371
+ // without blocking. `null`/`undefined` = currently exhausted or
1372
+ // closed (no inbound progress this tick). A THROW is the reconnect
1373
+ // signal: it surfaces from tick() as a DriverError.Source and the
1374
+ // host re-establishes the carrier and calls onReconnect().
1375
+ // Clock — { nowMillis(): number } monotonic millis; the driver owns no
1376
+ // clock/runtime — the host ticks on its own cadence and the clock
1377
+ // only timestamps the stall signal.
1378
+ // SnapshotProvider — { snapshot(fromEpoch): IpcMessage } the sender-side
1379
+ // answer to a peer's ResyncRequest (a covering Snapshot at
1380
+ // epoch >= fromEpoch).
1381
+
1382
+ // A monotonic wall-clock Clock seam (Date.now millis). Hosts that need a
1383
+ // deterministic or steady clock inject their own { nowMillis() } instead.
1384
+ export class SystemClock {
1385
+ nowMillis() {
1386
+ return Date.now();
1387
+ }
1388
+ }
1389
+
1390
+ // What one SyncDriver.tick() accomplished (spec § SyncDriver). `applied` are the
1391
+ // inbound Snapshot/Delta/CrdtSync frames the host MUST fold into its projection
1392
+ // this tick — the driver has already advanced the receiver cursor for them.
1393
+ export class Progress {
1394
+ constructor() {
1395
+ this.sent = 0; // data frames pushed to the sink this tick
1396
+ this.applied = []; // inbound frames the host must fold (Apply)
1397
+ this.resyncRequested = false; // a gap was detected → ResyncRequest emitted
1398
+ this.snapshotsServed = 0; // inbound ResyncRequests answered with a snapshot
1399
+ this.peerAckedThrough = 0; // peer ack cursor after this tick (retention/resume)
1400
+ this.retained = 0; // outbox frames still unacked (reconnect replay depth)
1401
+ }
1402
+ }
1403
+
1404
+ // A transport error surfaced by SyncDriver.tick(). A SINK failure is not fatal
1405
+ // (retain-and-stall, reported via Progress/stall signals); only an inbound
1406
+ // SOURCE read failure is thrown, telling the host to reconnect + onReconnect().
1407
+ export class DriverError extends Error {
1408
+ constructor(cause) {
1409
+ super(`reliable-sync source read failed: ${cause}`);
1410
+ this.name = "DriverError";
1411
+ this.kind = "Source";
1412
+ this.cause = cause;
1413
+ }
1414
+ }
1415
+
1416
+ // Full-duplex reliable-sync loop driver (spec § SyncDriver). One driver drives
1417
+ // one peer connection over a host-supplied sink/source pair. It composes the
1418
+ // three pure-protocol pieces (ResyncCoordinator, DurableOutbox, SnapshotProvider)
1419
+ // into the loop shape the spec pins: drain (append-before-send) → retain-on-fail
1420
+ // → receive (route control frames + coordinator) → resync-on-reconnect. The
1421
+ // driver owns no threads, no clock source, and no storage engine — the host
1422
+ // injects all three and decides the tick cadence. Mirrors lazily-rs SyncDriver.
1423
+ export class SyncDriver {
1424
+ // options: { sink, source, outbox?, clock?, provider, lastEpoch? }.
1425
+ constructor(options) {
1426
+ const opts = assertObject(options, "SyncDriver");
1427
+ this.sink = opts.sink;
1428
+ this.source = opts.source;
1429
+ this.outbox = opts.outbox ?? new InMemoryOutbox();
1430
+ this.clock = opts.clock ?? new SystemClock();
1431
+ this.provider = opts.provider;
1432
+ this.coordinator = new ResyncCoordinator(opts.lastEpoch ?? 0);
1433
+ this.pending = []; // host-enqueued outbound [epoch, IpcMessage] staged for drain
1434
+ this.peerAckedThrough = 0; // highest epoch the peer has acked (retention/resume)
1435
+ this.ackOwed = false; // we applied a frame and owe the peer an OutboxAck
1436
+ this.replayPending = false; // a reconnect happened; next tick replays the suffix
1437
+ this.stalledSince = null; // millis of the last sink send failure (null = healthy)
1438
+ }
1439
+
1440
+ // Stage an outbound data frame at `epoch` for the next tick's drain. `epoch`
1441
+ // is the frame's accepted-event count (Delta.epoch / Snapshot.epoch); it
1442
+ // becomes the outbox retention key.
1443
+ enqueue(epoch, msg) {
1444
+ this.pending.push([epoch, msg]);
1445
+ }
1446
+
1447
+ // Signal that the transport was re-established; the next tick() replays the
1448
+ // unacked outbox suffix and re-advertises our receiver cursor.
1449
+ onReconnect() {
1450
+ this.replayPending = true;
1451
+ this.ackOwed = true;
1452
+ this.stalledSince = null;
1453
+ }
1454
+
1455
+ // The receiver's current applied epoch.
1456
+ lastEpoch() {
1457
+ return this.coordinator.lastEpoch;
1458
+ }
1459
+
1460
+ // Whether the sink is currently stalled (last send failed, awaiting reconnect).
1461
+ isStalled() {
1462
+ return this.stalledSince !== null;
1463
+ }
1464
+
1465
+ // Millis the sink has been stalled as of `now`, or 0 when healthy — a backoff
1466
+ // signal for the host scheduler (which owns cadence/backoff policy).
1467
+ stalledFor(now) {
1468
+ return this.stalledSince === null ? 0 : Math.max(0, now - this.stalledSince);
1469
+ }
1470
+
1471
+ // Deliver one frame; a `false` return OR a throw is a send failure (the outbox
1472
+ // already holds the frame, so it is retained + replayed on reconnect).
1473
+ #trySend(msg) {
1474
+ try {
1475
+ return this.sink.send(msg) !== false;
1476
+ } catch {
1477
+ return false;
1478
+ }
1479
+ }
1480
+
1481
+ // Run one loop pass. Sink failures retain-and-stall (not an error); only an
1482
+ // inbound source read failure throws a DriverError. Returns a Progress.
1483
+ tick() {
1484
+ const now = this.clock.nowMillis();
1485
+ const progress = new Progress();
1486
+
1487
+ // 1. resync-on-reconnect: replay the unacked outbox suffix, oldest first.
1488
+ if (this.replayPending) {
1489
+ this.replayPending = false;
1490
+ for (const [, msg] of this.outbox.replayFrom(this.peerAckedThrough)) {
1491
+ if (this.#trySend(msg)) {
1492
+ progress.sent += 1;
1493
+ } else {
1494
+ this.stalledSince = now;
1495
+ this.replayPending = true; // finish the replay after the next reconnect
1496
+ break;
1497
+ }
1498
+ }
1499
+ }
1500
+
1501
+ // 2. drain fresh enqueues: append-before-send, retain-and-stop on failure.
1502
+ // A pre-existing stall skips the drain — never push into a sink already
1503
+ // known to be down.
1504
+ while (this.stalledSince === null && this.pending.length > 0) {
1505
+ const [epoch, msg] = this.pending[0];
1506
+ this.outbox.append(epoch, msg); // append BEFORE send: at-least-once durability
1507
+ this.pending.shift();
1508
+ if (this.#trySend(msg)) {
1509
+ progress.sent += 1;
1510
+ } else {
1511
+ this.stalledSince = now; // retained in the outbox → replayed on reconnect
1512
+ break;
1513
+ }
1514
+ }
1515
+
1516
+ // 3. receive: route control frames + feed data frames through the coordinator.
1517
+ for (;;) {
1518
+ let msg;
1519
+ try {
1520
+ msg = this.source.recv();
1521
+ } catch (error) {
1522
+ throw new DriverError(error);
1523
+ }
1524
+ if (msg == null) break; // exhausted/closed → no inbound progress this tick
1525
+ if (msg.isOutboxAck) {
1526
+ const through = msg.outboxAck.throughEpoch;
1527
+ if (through > this.peerAckedThrough) this.peerAckedThrough = through;
1528
+ this.outbox.ackThrough(through);
1529
+ } else if (msg.isResyncRequest) {
1530
+ const snap = this.provider.snapshot(msg.resyncRequest.fromEpoch);
1531
+ if (this.#trySend(snap)) progress.snapshotsServed += 1;
1532
+ else this.stalledSince = now;
1533
+ } else if (msg.isCrdtSync) {
1534
+ progress.applied.push(msg); // idempotent anti-entropy plane — host folds it
1535
+ } else if (msg.isSnapshot || msg.isDelta) {
1536
+ const res = this.coordinator.ingest(msg);
1537
+ if (res.action === ResyncAction.Apply) {
1538
+ this.ackOwed = true;
1539
+ progress.applied.push(msg);
1540
+ } else if (res.action === ResyncAction.RequestSnapshot) {
1541
+ const req = IpcMessage.resyncRequestMessage(new ResyncRequest({ fromEpoch: res.fromEpoch }));
1542
+ if (this.#trySend(req)) progress.resyncRequested = true;
1543
+ else this.stalledSince = now;
1544
+ }
1545
+ // Ignore → drop
1546
+ }
1547
+ }
1548
+
1549
+ // 4. advertise our receiver cursor if we applied anything (retry until sent).
1550
+ if (this.ackOwed && this.#trySend(this.coordinator.ack())) {
1551
+ this.ackOwed = false;
1552
+ }
1553
+
1554
+ progress.peerAckedThrough = this.peerAckedThrough;
1555
+ progress.retained = this.outbox.retainedEpochs().length;
1556
+ return progress;
1557
+ }
1558
+ }
1559
+
1119
1560
  function assertString(value, name) {
1120
1561
  if (typeof value !== "string") {
1121
1562
  throw new TypeError(`${name} must be a string`);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * lazily-js reactive `StateGraphMirror` for the agent-doc FFI state backbone
3
+ * (`#r5at` / `#lazilystatesync4` / `#s5`). Library-owned port of lazily-kt's
4
+ * `StateGraphMirror.kt`; node payloads live in reactive cells and the summary
5
+ * is a memoized derived slot, so consumer reads are reactive.
6
+ */
7
+
8
+ /** The agent-doc state node `type_tag`s (cross-language vocabulary). */
9
+ export const AgentDocNodeType: {
10
+ readonly ROUTE: 'agent_doc.route';
11
+ readonly QUEUE: 'agent_doc.queue';
12
+ readonly QUEUE_HEAD: 'agent_doc.queue.head';
13
+ readonly CLOSEOUT_CYCLE: 'agent_doc.closeout.cycle';
14
+ readonly TRANSPORT_PATCH: 'agent_doc.transport.patch';
15
+ readonly SUPERVISOR_OWNER: 'agent_doc.supervisor.owner';
16
+ readonly DOCUMENT_BASELINE: 'agent_doc.document.baseline';
17
+ readonly DOCUMENT_AUTHORITY: 'agent_doc.document.authority';
18
+ readonly PROOF_MARKER: 'agent_doc.proof.marker';
19
+ };
20
+
21
+ /** One tracked node in the mirror graph. `payload` is `base64(serde_json(struct))`. */
22
+ export interface MirrorNode {
23
+ slotId: number;
24
+ typeTag: string;
25
+ payload: string | null;
26
+ }
27
+
28
+ /** Reactive projection summary derived from a {@link StateGraphMirror}'s cells. */
29
+ export interface MirrorProjectionSummary {
30
+ routeReadiness?: string;
31
+ routePaneId?: string;
32
+ latestTransportPatchId?: string;
33
+ latestTransportPhase?: string;
34
+ proofMarkers: number;
35
+ }
36
+
37
+ /** Decode a `base64(serde_json(struct))` payload to a JSON object, or null. */
38
+ export function decodePayload(payload: string | null | undefined): Record<string, any> | null;
39
+
40
+ /** Render the compact editor-visible status string (kt `.compact()` parity). */
41
+ export function compactMirrorSummary(summary: MirrorProjectionSummary): string;
42
+
43
+ /** The pure, FFI-free reactive mirror graph a plugin holds per document. */
44
+ export class StateGraphMirror {
45
+ constructor();
46
+ get epoch(): number;
47
+ get documentHash(): string | null;
48
+ get isInitialized(): boolean;
49
+ get nodeCount(): number;
50
+ applySnapshot(snapshot: any): boolean;
51
+ applyDelta(delta: any): boolean;
52
+ applyMessage(raw: string): boolean;
53
+ nodesOfType(typeTag: string): MirrorNode[];
54
+ singletonNode(typeTag: string): MirrorNode | null;
55
+ payloadObject(typeTag: string): Record<string, any> | null;
56
+ summary(): MirrorProjectionSummary;
57
+ compactSummary(): string;
58
+ }
@@ -0,0 +1,366 @@
1
+ // lazily-js reactive `StateGraphMirror` for the agent-doc FFI state backbone
2
+ // (`#r5at` / `#lazilystatesync4` / `#s5`).
3
+ //
4
+ // This is the library-owned port of lazily-kt's `StateGraphMirror.kt`
5
+ // (`io.github.lazily.StateGraphMirror` / `MirrorProjectionSummary`,
6
+ // `#n529`/`#lazilystatesync3`). The binary (lazily-rs) owns the authoritative
7
+ // reactive state graph; this mirror applies the `agent_doc_state_subscribe`
8
+ // snapshot/delta messages so editor UI (dispatch-ready / busy / queue
9
+ // indicators) derives from tracked reactive cells instead of re-rendering the
10
+ // full projection JSON on every observed event.
11
+ //
12
+ // Unlike the old hand-rolled `Map` fold that lived in the VS Code extension,
13
+ // this mirror is a REAL lazily reactive graph: node payloads live in
14
+ // per-`slot_id` reactive cells inside a `Context`, structure changes bump a
15
+ // membership cell, and the projection summary is a memoized derived slot. A
16
+ // `cell_set` on a tracked node reactively invalidates the summary; an unrelated
17
+ // `cell_set` does not.
18
+ //
19
+ // Wire shapes match `agent-doc-orchestration/src/state_wire.rs` and
20
+ // `src/lazily-spec/schemas/{snapshot,delta}.json`:
21
+ // - snapshot: { type:"snapshot", epoch, document_hash, nodes[], edges[], roots[] }
22
+ // - delta: { type:"delta", base_epoch, epoch, document_hash, ops[] }
23
+ //
24
+ // Because the projection is a pure fold of deduped events, delta application is
25
+ // deterministic and idempotent — a no-op delta (re-emit) leaves the mirror
26
+ // unchanged (`#qdedupsync` property). Field names + the `compact()` format are
27
+ // pinned 1:1 to the kt `MirrorProjectionSummary` for cross-language parity.
28
+
29
+ import { Context } from "./reactive.js";
30
+
31
+ /** The agent-doc state node `type_tag`s (cross-language vocabulary). */
32
+ export const AgentDocNodeType = Object.freeze({
33
+ ROUTE: "agent_doc.route",
34
+ QUEUE: "agent_doc.queue",
35
+ QUEUE_HEAD: "agent_doc.queue.head",
36
+ CLOSEOUT_CYCLE: "agent_doc.closeout.cycle",
37
+ TRANSPORT_PATCH: "agent_doc.transport.patch",
38
+ SUPERVISOR_OWNER: "agent_doc.supervisor.owner",
39
+ DOCUMENT_BASELINE: "agent_doc.document.baseline",
40
+ DOCUMENT_AUTHORITY: "agent_doc.document.authority",
41
+ PROOF_MARKER: "agent_doc.proof.marker",
42
+ });
43
+
44
+ /**
45
+ * Decode a `base64(serde_json(struct))` payload to a JSON object, or null on
46
+ * failure / unset payload. Pure — exported for tests + consumers.
47
+ *
48
+ * @param {string | null | undefined} payload
49
+ * @returns {Record<string, any> | null}
50
+ */
51
+ export function decodePayload(payload) {
52
+ if (payload == null || payload === "") return null;
53
+ try {
54
+ const json = Buffer.from(payload, "base64").toString("utf-8");
55
+ const parsed = JSON.parse(json);
56
+ return parsed && typeof parsed === "object" ? parsed : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Render the compact editor-visible status string (matches the kt
64
+ * `MirrorProjectionSummary.compact()` EXACTLY for cross-language parity).
65
+ *
66
+ * @param {{ routeReadiness?: string, routePaneId?: string, latestTransportPatchId?: string, latestTransportPhase?: string, proofMarkers: number }} summary
67
+ * @returns {string}
68
+ */
69
+ export function compactMirrorSummary(summary) {
70
+ return (
71
+ `route=${summary.routeReadiness ?? "unknown"} pane=${summary.routePaneId ?? "-"} ` +
72
+ `transport=${summary.latestTransportPatchId ?? "-"}:${summary.latestTransportPhase ?? "-"} ` +
73
+ `proof_markers=${summary.proofMarkers}`
74
+ );
75
+ }
76
+
77
+ function stringField(obj, key) {
78
+ if (!obj) return undefined;
79
+ const value = obj[key];
80
+ return typeof value === "string" ? value : undefined;
81
+ }
82
+
83
+ /**
84
+ * The pure, FFI-free reactive mirror graph a plugin holds per document.
85
+ *
86
+ * Node payloads live in reactive cells keyed by `slot_id`; the projection
87
+ * summary is a memoized derived slot over those cells + a membership cell. The
88
+ * public read surface (`summary`, `nodesOfType`, `singletonNode`,
89
+ * `payloadObject`) reads through the reactive `Context`.
90
+ */
91
+ export class StateGraphMirror {
92
+ /** @type {Context} */
93
+ #ctx;
94
+ /** slotId -> { typeTag, cell } — the reactive node cells. */
95
+ #nodes = new Map();
96
+ /** slotId insertion order (stable). */
97
+ #order = [];
98
+ /** "dependent:dependency" edge keys. */
99
+ #edges = new Set();
100
+ /** CellHandle<number> — bumped whenever the node set / types change. */
101
+ #membership;
102
+ #membershipVersion = 0;
103
+ /** SlotHandle — the memoized projection summary derived from tracked cells. */
104
+ #summaryMemo;
105
+
106
+ #declaredHash = null;
107
+ #epoch = 0;
108
+
109
+ constructor() {
110
+ this.#reset();
111
+ }
112
+
113
+ /** Rebuild a fresh reactive graph (cold snapshot / construction). */
114
+ #reset() {
115
+ this.#ctx = new Context();
116
+ this.#nodes = new Map();
117
+ this.#order = [];
118
+ this.#edges = new Set();
119
+ this.#membershipVersion = 0;
120
+ this.#membership = this.#ctx.cell(0);
121
+ this.#summaryMemo = this.#ctx.memo(() => this.#computeSummary());
122
+ }
123
+
124
+ #bumpMembership() {
125
+ this.#membershipVersion += 1;
126
+ this.#ctx.setCell(this.#membership, this.#membershipVersion);
127
+ }
128
+
129
+ static #edgeKey(dependent, dependency) {
130
+ return `${dependent}:${dependency}`;
131
+ }
132
+
133
+ /** Monotonic frontier — the highest lazily-spec epoch applied so far. */
134
+ get epoch() {
135
+ return this.#epoch;
136
+ }
137
+
138
+ /** The document hash declared by the last applied snapshot/delta, or null. */
139
+ get documentHash() {
140
+ return this.#declaredHash;
141
+ }
142
+
143
+ /** True until at least one snapshot/delta has been applied. */
144
+ get isInitialized() {
145
+ return this.#declaredHash !== null;
146
+ }
147
+
148
+ get nodeCount() {
149
+ return this.#nodes.size;
150
+ }
151
+
152
+ // -- Node materialization (reactive cells) ----------------------------
153
+
154
+ #putNode(slotId, typeTag, payload) {
155
+ const existing = this.#nodes.get(slotId);
156
+ if (existing) {
157
+ const typeChanged = existing.typeTag !== typeTag;
158
+ existing.typeTag = typeTag;
159
+ this.#ctx.setCell(existing.cell, payload ?? null);
160
+ if (typeChanged) this.#bumpMembership();
161
+ return;
162
+ }
163
+ const cell = this.#ctx.cell(payload ?? null);
164
+ this.#nodes.set(slotId, { typeTag, cell });
165
+ this.#order.push(slotId);
166
+ this.#bumpMembership();
167
+ }
168
+
169
+ #setNodePayload(slotId, payload) {
170
+ const node = this.#nodes.get(slotId);
171
+ if (node) this.#ctx.setCell(node.cell, payload ?? null);
172
+ }
173
+
174
+ #removeNode(slotId) {
175
+ if (!this.#nodes.delete(slotId)) return;
176
+ const idx = this.#order.indexOf(slotId);
177
+ if (idx !== -1) this.#order.splice(idx, 1);
178
+ this.#bumpMembership();
179
+ }
180
+
181
+ /** Read a node's live payload through the reactive context (registers dep). */
182
+ #nodePayload(slotId) {
183
+ const node = this.#nodes.get(slotId);
184
+ return node ? this.#ctx.getCell(node.cell) : null;
185
+ }
186
+
187
+ #materializedNodes() {
188
+ const out = [];
189
+ for (const slotId of this.#order) {
190
+ const node = this.#nodes.get(slotId);
191
+ if (node) out.push({ slotId, typeTag: node.typeTag, payload: this.#nodePayload(slotId) });
192
+ }
193
+ return out;
194
+ }
195
+
196
+ // -- Apply ------------------------------------------------------------
197
+
198
+ /**
199
+ * Apply a cold-read snapshot object, replacing the whole reactive graph.
200
+ * @param {any} snapshot parsed `{ type:"snapshot", epoch, document_hash, nodes, edges, roots }`
201
+ * @returns {boolean}
202
+ */
203
+ applySnapshot(snapshot) {
204
+ if (!snapshot || typeof snapshot !== "object") return false;
205
+ this.#reset();
206
+ if (typeof snapshot.document_hash === "string") this.#declaredHash = snapshot.document_hash;
207
+ if (Array.isArray(snapshot.nodes)) {
208
+ for (const node of snapshot.nodes) {
209
+ if (!node || typeof node.slot_id !== "number" || typeof node.type_tag !== "string") continue;
210
+ const payload = typeof node.payload === "string" ? node.payload : null;
211
+ this.#putNode(node.slot_id, node.type_tag, payload);
212
+ }
213
+ }
214
+ if (Array.isArray(snapshot.edges)) {
215
+ for (const edge of snapshot.edges) {
216
+ if (!edge || typeof edge.dependent !== "number" || typeof edge.dependency !== "number") continue;
217
+ this.#edges.add(StateGraphMirror.#edgeKey(edge.dependent, edge.dependency));
218
+ }
219
+ }
220
+ if (typeof snapshot.epoch === "number") this.#epoch = snapshot.epoch;
221
+ return true;
222
+ }
223
+
224
+ /**
225
+ * Apply a warm delta object. Ops are applied verbatim in emission order; the
226
+ * frontier advances to `max(epoch, delta.epoch)`. A no-op delta (empty ops)
227
+ * only advances the epoch.
228
+ * @param {any} delta parsed `{ type:"delta", base_epoch, epoch, document_hash, ops }`
229
+ * @returns {boolean}
230
+ */
231
+ applyDelta(delta) {
232
+ if (!delta || typeof delta !== "object") return false;
233
+ if (typeof delta.document_hash === "string") this.#declaredHash = delta.document_hash;
234
+ if (Array.isArray(delta.ops)) {
235
+ for (const op of delta.ops) {
236
+ if (!op || typeof op.op !== "string") continue;
237
+ switch (op.op) {
238
+ case "node_add": {
239
+ if (typeof op.slot_id !== "number" || typeof op.type_tag !== "string") break;
240
+ const payload = typeof op.payload === "string" ? op.payload : null;
241
+ this.#putNode(op.slot_id, op.type_tag, payload);
242
+ break;
243
+ }
244
+ case "cell_set":
245
+ case "slot_value": {
246
+ if (typeof op.slot_id !== "number") break;
247
+ this.#setNodePayload(op.slot_id, typeof op.payload === "string" ? op.payload : null);
248
+ break;
249
+ }
250
+ case "invalidate":
251
+ // Derived recompute is plugin-side; the mirror holds the stale
252
+ // payload until a cell_set arrives.
253
+ break;
254
+ case "node_remove":
255
+ if (typeof op.slot_id === "number") this.#removeNode(op.slot_id);
256
+ break;
257
+ case "edge_add":
258
+ if (typeof op.dependent === "number" && typeof op.dependency === "number") {
259
+ this.#edges.add(StateGraphMirror.#edgeKey(op.dependent, op.dependency));
260
+ }
261
+ break;
262
+ case "edge_remove":
263
+ if (typeof op.dependent === "number" && typeof op.dependency === "number") {
264
+ this.#edges.delete(StateGraphMirror.#edgeKey(op.dependent, op.dependency));
265
+ }
266
+ break;
267
+ default:
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ if (typeof delta.epoch === "number") this.#epoch = Math.max(this.#epoch, delta.epoch);
273
+ return true;
274
+ }
275
+
276
+ /**
277
+ * Apply a raw `agent_doc_state_subscribe` message, dispatching on the
278
+ * lazily-spec `"type"` discriminator (`snapshot` or `delta`). Returns false
279
+ * when the message cannot be parsed / has an unknown type.
280
+ * @param {string} raw
281
+ * @returns {boolean}
282
+ */
283
+ applyMessage(raw) {
284
+ let root;
285
+ try {
286
+ root = JSON.parse(raw);
287
+ } catch {
288
+ return false;
289
+ }
290
+ if (!root || typeof root !== "object" || typeof root.type !== "string") return false;
291
+ if (root.type === "snapshot") return this.applySnapshot(root);
292
+ if (root.type === "delta") return this.applyDelta(root);
293
+ return false;
294
+ }
295
+
296
+ // -- Read (reactive) --------------------------------------------------
297
+
298
+ /** All tracked nodes of `typeTag` (stable insertion order). */
299
+ nodesOfType(typeTag) {
300
+ const out = [];
301
+ for (const slotId of this.#order) {
302
+ const node = this.#nodes.get(slotId);
303
+ if (node && node.typeTag === typeTag) {
304
+ out.push({ slotId, typeTag: node.typeTag, payload: this.#nodePayload(slotId) });
305
+ }
306
+ }
307
+ return out;
308
+ }
309
+
310
+ /** The single document-level node for `typeTag`, or null. */
311
+ singletonNode(typeTag) {
312
+ for (const slotId of this.#order) {
313
+ const node = this.#nodes.get(slotId);
314
+ if (node && node.typeTag === typeTag) {
315
+ return { slotId, typeTag: node.typeTag, payload: this.#nodePayload(slotId) };
316
+ }
317
+ }
318
+ return null;
319
+ }
320
+
321
+ /** Decode a node payload (`base64(serde_json(struct))`) as a JSON object, or null. */
322
+ payloadObject(typeTag) {
323
+ const node = this.singletonNode(typeTag);
324
+ return node ? decodePayload(node.payload) : null;
325
+ }
326
+
327
+ #computeSummary() {
328
+ // Structure dependency: recompute the summary when the node set changes.
329
+ this.#ctx.getCell(this.#membership);
330
+
331
+ const routeNode = this.singletonNode(AgentDocNodeType.ROUTE);
332
+ const route = routeNode ? decodePayload(routeNode.payload) : null;
333
+ const routeReadiness = stringField(route, "readiness");
334
+ const routePaneId = stringField(route, "pane_id");
335
+
336
+ const patches = this.nodesOfType(AgentDocNodeType.TRANSPORT_PATCH);
337
+ const latest = patches.length > 0
338
+ ? patches.reduce((a, b) => (b.slotId > a.slotId ? b : a))
339
+ : undefined;
340
+ const latestTransportPhase = latest ? stringField(decodePayload(latest.payload), "phase") : undefined;
341
+
342
+ const proofMarkers = this.nodesOfType(AgentDocNodeType.PROOF_MARKER).length;
343
+
344
+ return {
345
+ routeReadiness,
346
+ routePaneId,
347
+ latestTransportPatchId: undefined,
348
+ latestTransportPhase,
349
+ proofMarkers,
350
+ };
351
+ }
352
+
353
+ /**
354
+ * Reactive projection summary derived from this mirror's tracked cells (the
355
+ * memoized analog of the kt `MirrorProjectionSummary.fromMirror`).
356
+ * @returns {{ routeReadiness?: string, routePaneId?: string, latestTransportPatchId?: string, latestTransportPhase?: string, proofMarkers: number }}
357
+ */
358
+ summary() {
359
+ return this.#ctx.get(this.#summaryMemo);
360
+ }
361
+
362
+ /** The compact editor-visible status string (kt `.compact()` parity). */
363
+ compactSummary() {
364
+ return compactMirrorSummary(this.summary());
365
+ }
366
+ }