@lazily-hub/lazily-js 0.12.0 → 0.13.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,10 @@ 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`) | ✅ | — | ✅ | ✅ | — | — | — | — |
65
66
  | Distributed plane — WebRTC transport + signaling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
66
67
  | State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
67
68
  | Causal receipts (`CausalReceipts` outcome projection) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -72,6 +73,11 @@ notes and platform carve-outs lives in
72
73
  | Instrumentation / benchmarks | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
73
74
  <!-- coverage-table:end -->
74
75
 
76
+ Two JS ✅ marks are backed by runtime-specific mechanisms while keeping the core isomorphic:
77
+
78
+ - **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.
79
+ - **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.
80
+
75
81
  CRDT convergence and the wire protocol are pinned by the shared conformance fixtures
76
82
  and JSON Schemas in `lazily-spec` and the Lean models in `lazily-formal`.
77
83
  ## 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.13.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,97 @@ 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 isControl: boolean;
396
416
  toWire(): unknown;
397
417
  encodeJson(): Uint8Array;
398
418
  static snapshot(snapshot: Snapshot): IpcMessage;
399
419
  static delta(delta: Delta): IpcMessage;
400
420
  static crdtSync(crdtSync: CrdtSync): IpcMessage;
421
+ static resyncRequestMessage(request: ResyncRequest): IpcMessage;
422
+ static outboxAckMessage(ack: OutboxAck): IpcMessage;
401
423
  static fromWire(value: unknown): IpcMessage;
402
424
  static decodeJson(data: Uint8Array | string): IpcMessage;
403
425
  }
404
426
 
427
+ // Reliable sync protocol (#lzsync).
428
+ export const ResyncAction: {
429
+ readonly Apply: "Apply";
430
+ readonly RequestSnapshot: "RequestSnapshot";
431
+ readonly Ignore: "Ignore";
432
+ };
433
+
434
+ export type ResyncActionResult = {
435
+ action: "Apply" | "RequestSnapshot" | "Ignore";
436
+ fromEpoch?: number;
437
+ };
438
+
439
+ export class ResyncCoordinator {
440
+ constructor(lastEpoch?: number);
441
+ lastEpoch: number;
442
+ resyncing: boolean;
443
+ ingestDelta(delta: Delta): ResyncActionResult;
444
+ ingestSnapshot(snapshotEpoch: number): ResyncActionResult;
445
+ ingest(msg: IpcMessage): ResyncActionResult;
446
+ ack(): IpcMessage;
447
+ }
448
+
449
+ export interface DurableOutbox {
450
+ append(epoch: number, msg: IpcMessage): void;
451
+ ackThrough(epoch: number): void;
452
+ replayFrom(cursor: number): Array<[number, IpcMessage]>;
453
+ retainedEpochs(): number[];
454
+ }
455
+
456
+ export class InMemoryOutbox implements DurableOutbox {
457
+ ackedThrough: number;
458
+ append(epoch: number, msg: IpcMessage): void;
459
+ ackThrough(epoch: number): void;
460
+ replayFrom(cursor: number): Array<[number, IpcMessage]>;
461
+ retainedEpochs(): number[];
462
+ }
463
+
464
+ export class OrSet {
465
+ add(tag: string): void;
466
+ removeObserved(tags: Iterable<string>): void;
467
+ present(): boolean;
468
+ join(other: OrSet): void;
469
+ }
470
+
471
+ export function wireStampGreater(a: WireStamp, b: WireStamp): boolean;
472
+
473
+ export class WireLwwRegister<V = unknown> {
474
+ constructor(stamp: WireStamp, value: V);
475
+ stamp: WireStamp;
476
+ value: V;
477
+ set(stamp: WireStamp, value: V): void;
478
+ join(other: WireLwwRegister<V>): void;
479
+ }
480
+
405
481
  export const OpKind: {
406
482
  readonly Read: "read";
407
483
  readonly Write: "write";
@@ -449,6 +525,8 @@ export const LazilyFfiMessageKind: {
449
525
  readonly Snapshot: 1;
450
526
  readonly Delta: 2;
451
527
  readonly CrdtSync: 3;
528
+ readonly ResyncRequest: 4;
529
+ readonly OutboxAck: 5;
452
530
  };
453
531
 
454
532
  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,11 @@ export class IpcMessage {
1037
1087
  return this.kind === "CrdtSync";
1038
1088
  }
1039
1089
 
1090
+ // Reliable-sync reverse-channel control frame (no node content).
1091
+ get isControl() {
1092
+ return this.kind === "ResyncRequest" || this.kind === "OutboxAck";
1093
+ }
1094
+
1040
1095
  toWire() {
1041
1096
  if (this.kind === "Snapshot") {
1042
1097
  return { Snapshot: this.snapshot.toWire() };
@@ -1044,7 +1099,13 @@ export class IpcMessage {
1044
1099
  if (this.kind === "Delta") {
1045
1100
  return { Delta: this.delta.toWire() };
1046
1101
  }
1047
- return { CrdtSync: this.crdtSync.toWire() };
1102
+ if (this.kind === "CrdtSync") {
1103
+ return { CrdtSync: this.crdtSync.toWire() };
1104
+ }
1105
+ if (this.kind === "ResyncRequest") {
1106
+ return { ResyncRequest: this.resyncRequest.toWire() };
1107
+ }
1108
+ return { OutboxAck: this.outboxAck.toWire() };
1048
1109
  }
1049
1110
 
1050
1111
  encodeJson() {
@@ -1063,6 +1124,14 @@ export class IpcMessage {
1063
1124
  return new IpcMessage("CrdtSync", crdtSync);
1064
1125
  }
1065
1126
 
1127
+ static resyncRequestMessage(request) {
1128
+ return new IpcMessage("ResyncRequest", request);
1129
+ }
1130
+
1131
+ static outboxAckMessage(ack) {
1132
+ return new IpcMessage("OutboxAck", ack);
1133
+ }
1134
+
1066
1135
  static fromWire(value) {
1067
1136
  const [tag, body] = assertTagged(value, "IpcMessage");
1068
1137
  switch (tag) {
@@ -1072,6 +1141,10 @@ export class IpcMessage {
1072
1141
  return IpcMessage.delta(Delta.fromWire(body));
1073
1142
  case "CrdtSync":
1074
1143
  return IpcMessage.crdtSync(CrdtSync.fromWire(body));
1144
+ case "ResyncRequest":
1145
+ return IpcMessage.resyncRequestMessage(ResyncRequest.fromWire(body));
1146
+ case "OutboxAck":
1147
+ return IpcMessage.outboxAckMessage(OutboxAck.fromWire(body));
1075
1148
  default:
1076
1149
  throw new TypeError(`unknown IpcMessage variant: ${tag}`);
1077
1150
  }
@@ -1104,6 +1177,8 @@ export const LazilyFfiMessageKind = Object.freeze({
1104
1177
  Snapshot: 1,
1105
1178
  Delta: 2,
1106
1179
  CrdtSync: 3,
1180
+ ResyncRequest: 4,
1181
+ OutboxAck: 5,
1107
1182
  });
1108
1183
 
1109
1184
  // The FFI status codes mirror schemas/ffi.json § LazilyFfiStatus.
@@ -1116,6 +1191,161 @@ export const LazilyFfiStatus = Object.freeze({
1116
1191
  Panic: 5,
1117
1192
  });
1118
1193
 
1194
+ // ---------------------------------------------------------------------------
1195
+ // Reliable sync protocol (#lzsync).
1196
+ //
1197
+ // Delivery-reliability over the Snapshot/Delta/CrdtSync planes (lazily-spec
1198
+ // § Reliable Sync): gap recovery, at-least-once outbox, and OR-set / LWW liveness
1199
+ // cells. Correctness backstop: lazily-formal ReliableSync.lean; cross-language
1200
+ // pins: lazily-spec/conformance/reliable-sync/.
1201
+ // ---------------------------------------------------------------------------
1202
+
1203
+ // Receiver decision for an inbound frame (spec § ResyncCoordinator).
1204
+ export const ResyncAction = Object.freeze({
1205
+ Apply: "Apply",
1206
+ RequestSnapshot: "RequestSnapshot",
1207
+ Ignore: "Ignore",
1208
+ });
1209
+
1210
+ // Receiver-side reliable-sync coordinator. Holds lastEpoch (highest epoch fully
1211
+ // applied) and a resyncing flag (a RequestSnapshot is outstanding until a covering
1212
+ // Snapshot lands). ingest advances lastEpoch on Apply; the caller MUST fold the
1213
+ // frame's ops on Apply. Mirrors ReliableSync.step.
1214
+ export class ResyncCoordinator {
1215
+ constructor(lastEpoch = 0) {
1216
+ this.lastEpoch = assertInteger(lastEpoch, "lastEpoch");
1217
+ this.resyncing = false;
1218
+ }
1219
+
1220
+ // Classify + fold an inbound Delta; advances to delta.epoch on Apply (multi-epoch aware).
1221
+ // Returns { action, fromEpoch? }.
1222
+ ingestDelta(delta) {
1223
+ if (delta.baseEpoch === this.lastEpoch) {
1224
+ if (delta.epoch >= delta.baseEpoch + 1) {
1225
+ this.lastEpoch = delta.epoch;
1226
+ this.resyncing = false;
1227
+ return { action: ResyncAction.Apply };
1228
+ }
1229
+ return { action: ResyncAction.Ignore }; // empty/backward epoch
1230
+ }
1231
+ if (delta.baseEpoch < this.lastEpoch) {
1232
+ return { action: ResyncAction.Ignore }; // already applied — re-delivery
1233
+ }
1234
+ // gap: baseEpoch > lastEpoch
1235
+ if (this.resyncing) {
1236
+ return { action: ResyncAction.Ignore }; // suppress duplicate request
1237
+ }
1238
+ this.resyncing = true;
1239
+ return { action: ResyncAction.RequestSnapshot, fromEpoch: this.lastEpoch };
1240
+ }
1241
+
1242
+ // Adopt a Snapshot — a full-state frame always applies.
1243
+ ingestSnapshot(snapshotEpoch) {
1244
+ this.lastEpoch = snapshotEpoch;
1245
+ this.resyncing = false;
1246
+ return { action: ResyncAction.Apply };
1247
+ }
1248
+
1249
+ // Classify an inbound IpcMessage. CrdtSync rides the CRDT plane and the
1250
+ // reverse-channel control frames are for the sender's driver, so both are
1251
+ // ignored by this data receiver.
1252
+ ingest(msg) {
1253
+ if (msg.isSnapshot) return this.ingestSnapshot(msg.snapshot.epoch);
1254
+ if (msg.isDelta) return this.ingestDelta(msg.delta);
1255
+ return { action: ResyncAction.Ignore };
1256
+ }
1257
+
1258
+ // The IpcMessage(OutboxAck) advertising this receiver's resume cursor.
1259
+ ack() {
1260
+ return IpcMessage.outboxAckMessage(new OutboxAck({ throughEpoch: this.lastEpoch }));
1261
+ }
1262
+ }
1263
+
1264
+ // In-memory durable outbox — correct within a process lifetime; the default.
1265
+ // A DurableOutbox is any object with append/ackThrough/replayFrom/retainedEpochs.
1266
+ export class InMemoryOutbox {
1267
+ constructor() {
1268
+ this.entries = []; // [epoch, IpcMessage]
1269
+ this.ackedThrough = 0;
1270
+ }
1271
+
1272
+ append(epoch, msg) {
1273
+ this.entries.push([epoch, msg]);
1274
+ }
1275
+
1276
+ ackThrough(epoch) {
1277
+ if (epoch > this.ackedThrough) this.ackedThrough = epoch;
1278
+ this.entries = this.entries.filter(([e]) => e > this.ackedThrough);
1279
+ }
1280
+
1281
+ replayFrom(cursor) {
1282
+ return this.entries.filter(([e]) => e > cursor).sort((a, b) => a[0] - b[0]);
1283
+ }
1284
+
1285
+ retainedEpochs() {
1286
+ return this.entries.map(([e]) => e).sort((a, b) => a - b);
1287
+ }
1288
+ }
1289
+
1290
+ // An observed-remove set (OR-set) liveness cell. A (doc, pid) is present iff some
1291
+ // add-tag is not shadowed by a remove that observed it (add-wins over a stale
1292
+ // remove). Join is the union of both tag sets — a semilattice, so out-of-order and
1293
+ // duplicate delivery converge (ReliableSync.joinOR_*).
1294
+ export class OrSet {
1295
+ constructor() {
1296
+ this.adds = new Set();
1297
+ this.removes = new Set();
1298
+ }
1299
+
1300
+ add(tag) {
1301
+ this.adds.add(tag);
1302
+ }
1303
+
1304
+ removeObserved(tags) {
1305
+ for (const t of tags) this.removes.add(t);
1306
+ }
1307
+
1308
+ present() {
1309
+ for (const t of this.adds) if (!this.removes.has(t)) return true;
1310
+ return false;
1311
+ }
1312
+
1313
+ join(other) {
1314
+ for (const t of other.adds) this.adds.add(t);
1315
+ for (const t of other.removes) this.removes.add(t);
1316
+ }
1317
+ }
1318
+
1319
+ // Total order (wallTime, logical, peer) — the wire mirror of the HLC stamp.
1320
+ export function wireStampGreater(a, b) {
1321
+ if (a.wallTime !== b.wallTime) return a.wallTime > b.wallTime;
1322
+ if (a.logical !== b.logical) return a.logical > b.logical;
1323
+ return a.peer > b.peer;
1324
+ }
1325
+
1326
+ // A last-writer-wins register liveness cell (per-pid alive, owner lease), keyed by
1327
+ // WireStamp: the highest stamp wins. Join is the stamp-max, a semilattice
1328
+ // (ReliableSync.joinReg_*).
1329
+ export class WireLwwRegister {
1330
+ constructor(stamp, value) {
1331
+ this.stamp = stamp;
1332
+ this.value = value;
1333
+ }
1334
+
1335
+ // Write value at stamp iff it dominates the current stamp.
1336
+ set(stamp, value) {
1337
+ if (wireStampGreater(stamp, this.stamp)) {
1338
+ this.stamp = stamp;
1339
+ this.value = value;
1340
+ }
1341
+ }
1342
+
1343
+ // Join another replica's register (keep the higher stamp).
1344
+ join(other) {
1345
+ this.set(other.stamp, other.value);
1346
+ }
1347
+ }
1348
+
1119
1349
  function assertString(value, name) {
1120
1350
  if (typeof value !== "string") {
1121
1351
  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
+ }