@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 +12 -5
- package/package.json +6 -2
- package/src/distributed.js +4 -1
- package/src/ffi.js +13 -1
- package/src/index.d.ts +154 -1
- package/src/index.js +442 -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,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.
|
|
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",
|
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,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
|
-
|
|
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
|
+
}
|