@lazily-hub/lazily-js 0.13.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 +1 -0
- package/package.json +1 -1
- package/src/index.d.ts +75 -0
- package/src/index.js +211 -0
package/README.md
CHANGED
|
@@ -63,6 +63,7 @@ notes and platform carve-outs lives in
|
|
|
63
63
|
| Cross-process zero-copy transport (`BlobBackend` / shm / arrow) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
64
64
|
| Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
65
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`) | ✅ | — | ✅ | ✅ | — | — | — | — |
|
|
66
67
|
| Distributed plane — WebRTC transport + signaling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
67
68
|
| State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
68
69
|
| Causal receipts (`CausalReceipts` outcome projection) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
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": {
|
package/src/index.d.ts
CHANGED
|
@@ -412,6 +412,8 @@ export class IpcMessage {
|
|
|
412
412
|
readonly isSnapshot: boolean;
|
|
413
413
|
readonly isDelta: boolean;
|
|
414
414
|
readonly isCrdtSync: boolean;
|
|
415
|
+
readonly isResyncRequest: boolean;
|
|
416
|
+
readonly isOutboxAck: boolean;
|
|
415
417
|
readonly isControl: boolean;
|
|
416
418
|
toWire(): unknown;
|
|
417
419
|
encodeJson(): Uint8Array;
|
|
@@ -478,6 +480,79 @@ export class WireLwwRegister<V = unknown> {
|
|
|
478
480
|
join(other: WireLwwRegister<V>): void;
|
|
479
481
|
}
|
|
480
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
|
+
|
|
481
556
|
export const OpKind: {
|
|
482
557
|
readonly Read: "read";
|
|
483
558
|
readonly Write: "write";
|
package/src/index.js
CHANGED
|
@@ -1087,6 +1087,14 @@ export class IpcMessage {
|
|
|
1087
1087
|
return this.kind === "CrdtSync";
|
|
1088
1088
|
}
|
|
1089
1089
|
|
|
1090
|
+
get isResyncRequest() {
|
|
1091
|
+
return this.kind === "ResyncRequest";
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
get isOutboxAck() {
|
|
1095
|
+
return this.kind === "OutboxAck";
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1090
1098
|
// Reliable-sync reverse-channel control frame (no node content).
|
|
1091
1099
|
get isControl() {
|
|
1092
1100
|
return this.kind === "ResyncRequest" || this.kind === "OutboxAck";
|
|
@@ -1346,6 +1354,209 @@ export class WireLwwRegister {
|
|
|
1346
1354
|
}
|
|
1347
1355
|
}
|
|
1348
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
|
+
|
|
1349
1560
|
function assertString(value, name) {
|
|
1350
1561
|
if (typeof value !== "string") {
|
|
1351
1562
|
throw new TypeError(`${name} must be a string`);
|