Skip to content
D
Documentation

SyncAdapter

reference
4 min readUpdated

Import it from @grafloria/engine.

Binds one Replica to one SyncTransport.

The replica must be constructed with onLocalOp pointing at adapter.publish — use {@link createSyncSession}, which does that wiring for you and cannot get it backwards.

ts
class SyncAdapter

Properties

NameTypeDefaultDescription
awarenessAwareness
statsSyncStats

Methods

  • constructor( readonly replica: Replica, private readonly transport: SyncTransport, private readonly options: SyncAdapterOptions = {} )
  • get actor(): ActorId
  • get diagram(): DiagramModel
  • get pendingOps(): Op[] — Ops still waiting on an add that has not arrived. Should return to 0.
  • frontier(): VersionVectorJSON — What we tell peers we have.

THE INVARIANT, and it is worth stating as one because it is the whole correctness condition of this layer in a single line:

frontier() === VersionVector.fromOps(sharedHistory()) — ALWAYS.

SHARED history, not the local log — an op coalescing withheld is not part of the history we share with anyone, and advertising it would make every peer chase a hole that can never be filled. That distinction IS the bug documented on sharedHistory().

The frontier is a CACHE of that summary, maintained incrementally so we do not rescan the history on every sync. A cache that disagrees with its source is not a performance detail; it is a peer lying to the network about what it holds. Claim too much and you never receive the op you are missing (silent data loss); claim too little and every round resends an actor's entire history (a silent O(history) amplifier on the flakiest connections, which is precisely where you can least afford it).

sync-adapter.spec.ts asserts this equality after a full hostile session, which is what makes it a checked invariant rather than a comment.

  • join(): void — Announce ourselves and ask for what we are missing.

hello carries our frontier, so an existing peer can push us the history we lack without a second round trip — a joining peer should see the document on the first message, not the third.

  • leave(): void — Say goodbye and stop. Best-effort — the peers' TTL is what actually guarantees it.
  • sync(): void — An anti-entropy round: "here is my frontier; send me what I lack, and tell me yours."

Called on join, on RECONNECT, and (optionally) on a timer. The timer is not paranoia: a transport that can drop a message can drop an op, and the ONLY thing that ever repairs that is asking again.

  • flush(): void — Push any queued local ops onto the wire now.
  • publish(op: Op): void — Our own edits. Wired to Replica.onLocalOp — see {@link createSyncSession}.

The local op is ALREADY in our log and already applied (the Replica did both before calling us). Our job is only to get it to the others, so a failure to send is not a failure to edit — it is a divergence the next sync round will close.

  • sharedHistory(): Op[] — THE LOG AS THE NETWORK SEES IT — our history, minus everything coalescing withheld.

THE BUG THIS EXISTS TO FIX, WHICH WAS MINE, AND WHICH ONLY THE BROWSER FOUND

A 20-frame drag puts ONE op on the wire and leaves TWENTY in the local log — that is coalescing working exactly as designed, and the two peers' documents agree perfectly.

But anti-entropy compares FRONTIERS DERIVED FROM LOGS. So on the next sync round my frontier said "I hold 20 ops from alice" and the peer's said "I hold 1", and the digest — which cannot tell a withheld op from a lost one — declared a HOLE and repaired it by resending my entire history. Measured: opsSent went from 1 to 21 on the first sync after a single drag. And it never settles, because the peer can NEVER obtain the 19 ops I have deliberately decided never to send. Every sync round, forever, for the rest of the session, on the most common interaction in the product.

It is invisible to a convergence oracle — the document is perfectly correct throughout — and it was invisible to my own frontier-invariant test, which flushed after every single op and therefore never once coalesced anything while syncing. It took driving a real drag through a real browser to compose the two.

THE FIX is a definition, not a patch: THE FRONTIER DESCRIBES THE SHARED LOG. An op we chose never to transmit is not part of the history we share with anyone, so it is not in our frontier and it is not in the catch-up delta we serve — to ANY peer, including one that joins tomorrow. Every peer therefore holds the same shared set, every digest agrees, and the fast path stays fast.

  • setAwareness(patch: Partial<AwarenessState>): void — Publish our cursor / selection / name.

THROTTLED, not debounced. A debounce would send nothing at all while the mouse keeps moving — the cursor would only appear once you stopped, which is the exact opposite of the feature. A throttle sends the newest sample at a bounded rate and drops the ones in between, which is precisely correct for a value where only the latest matters.

  • dispose(): void

Was this page helpful?

SyncAdapter — Grafloria