# SyncAdapter

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**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `awareness` | `Awareness` |  |  |
| `stats` | `SyncStats` |  |  |

**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`
