@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 +11 -5
- package/package.json +6 -2
- package/src/distributed.js +4 -1
- package/src/ffi.js +13 -1
- package/src/index.d.ts +79 -1
- package/src/index.js +231 -1
- package/src/state-graph-mirror.d.ts +58 -0
- package/src/state-graph-mirror.js +366 -0
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.
|
|
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",
|
package/src/distributed.js
CHANGED
|
@@ -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 !(
|
|
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
|
-
|
|
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
|
+
}
|