@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 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.13.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`);