Skip to content
D
Documentation

Collab

reference
11 min readUpdated

Import these from @grafloria/engine.

On their own pages

Functions

applyEntitySet

Write one property register of an entity that is ALREADY RESOLVED.

Split out of applySet so that referential integrity can drive the identical write into an entity it is holding OUTSIDE the diagram (a link quarantined because its endpoint node is gone, see integrity.ts). Same mutators, same idempotence guard, same everything — a quarantined entity that took a DIFFERENT write path would drift from a live one, and that drift would only surface on resurrection, long after the cause.

ts
function applyEntitySet(
  entity: Entity,
  path: string,
  value: OpValue | undefined
): boolean

applyOp

Apply ONE op. Returns true if it changed anything.

A NO-OP IS NOT AN ERROR, and this is load-bearing rather than lenient. In a distributed log you will legitimately receive: • a set on an entity a concurrent remove already deleted, • a remove for something already removed (duplicate delivery, or two peers deleting the same node), • an add for an id that already exists (the same op delivered twice). Every one of those is normal traffic, not corruption. Throwing on them would mean a single duplicated packet takes down the session. So we IGNORE them and say so by returning false — while still throwing loudly on an op that is genuinely malformed (unknown kind, missing id), because that IS a bug and hiding it helps nobody.

ts
function applyOp(diagram: DiagramModel, op: Op): boolean

compareOps

TOTAL ORDER over ops. Every peer must sort the same log into the same sequence, or "replay converges" is meaningless.

(clock, actor) is a total order because clocks are integers and actor ids are distinct strings. The actor tiebreak is arbitrary — it does NOT mean the lexicographically-larger actor is "righter" — but it is the same arbitrary answer on every peer, which is the only thing required.

ts
function compareOps(a: Op, b: Op): number

opId

Identity of an op, for idempotent delivery: the same op received twice is one op.

ts
function opId(op: Op): string

replay

Replay an ordered log into a diagram.

Sorts defensively rather than trusting the caller: replay determinism is the property every later card stands on, and it would be silly to lose it because someone handed us an array in arrival order.

And it ENFORCES THE INVARIANT at the end, because a peer that joins by replaying a log must arrive exactly where a peer that was in the room the whole time already is. If integrity only ran in Replica.receive(), a log containing "delete a node that had links" would replay into a document with a dangling link, and the newcomer would be the only one holding it. One sweep, once, over the final state — the invariant is a function of that state and of nothing on the way to it.

NOTE this is the DOCUMENT, not a live peer: the quarantine it builds is discarded with the temporary registry. A peer that intends to go on editing should be seeded through Replica.receive(history), which keeps it — and can therefore still bring an orphaned link back if someone undoes the delete.

ts
function replay(diagram: DiagramModel, ops: readonly Op[]): number

Classes

LamportClock

A Lamport clock.

tick() for a local event. observe(remoteClock) on receipt, which jumps this clock past anything the remote had seen — that is what makes the resulting order respect causality across peers rather than merely within one.

ts
class LamportClock

Methods

  • constructor(private readonly actor: ActorId, start = 0)
  • tick(): number — The next clock value for a local event.
  • observe(seen: number): void — Fold in a clock we have just seen. After this, our next tick() is guaranteed to be greater than seen — so any op we issue AFTER observing theirs sorts AFTER it, which is exactly the causality guarantee.
  • peek(): number
  • get actorId(): ActorId

LwwRegistry

Which write currently owns each register.

The key is (target, id, path) for a property, and (target, id, presence) for whether the entity exists at all — because an add/remove race is the same kind of race as a concurrent property write, and deserves the same machinery rather than a special case.

ts
class LwwRegistry

Methods

  • admit(op: Op): boolean — Should this op be applied?

False when a NEWER write already owns the register — the op is not late, it is SUPERSEDED, and applying it would move the register backwards. This is the single check that makes arrival-order application equal to total-order replay.

Records the stamp as a side effect when the answer is yes: an op that wins now owns its register.

  • owner(op: Op): Stamp | undefined — Who currently owns a register — for debugging, and for a UI that shows attribution.
  • presenceOf(target: Op['target'], id: string): Stamp | undefined — The stamp that decided whether an entity exists. Undefined if never seen.
  • get size(): number

OpApplyError

Thrown only for a MALFORMED op — never for a merely-losing one.

ts
class OpApplyError extends Error

Properties

NameTypeDefaultDescription
namestring
messagestring
stack?string

Methods

  • constructor(message: string, readonly op: Op)

OpCapture

Watches a live diagram and emits an Op for every real edit.

Re-entrancy: while applyRemote() is running, capture is SUPPRESSED. Without that, applying a peer's op would capture it as a local op and re-broadcast it, and the two peers would ping-pong the same edit forever, each amplifying the other. This is the single most important line in the file and it is three characters long.

ts
class OpCapture

Methods

  • constructor( private readonly diagram: DiagramModel, options: OpCaptureOptions )
  • get lamport(): LamportClock — The Lamport clock this capture issues from — shared with the sync layer.
  • applyRemote(ops: readonly Op[], apply: (op: Op) => void): void — Apply remote ops WITHOUT capturing them as local edits.

The clock observes each remote op first, so any op we issue after this sorts after theirs. That is what makes the resulting order respect causality between peers and not merely within one.

  • silently(fn: () => void): void — Run fn with capture SUPPRESSED — the model mutations it makes emit no ops.

For work that is DERIVED rather than authored: referential integrity moving an orphaned link out of the document and back again is a function of state that every peer computes for itself, so broadcasting it would put a redundant op on the wire that races with the ops it was derived from. Deriving it locally is not just cheaper, it is the only thing that converges.

Re-entrant: nesting must not clear the flag early (integrity reconciles INSIDE applyRemote), or a remote op's mutations would start echoing back mid-batch.

  • stop(): void

OpLog

An append-only, totally-ordered, de-duplicating op log.

Kept SORTED rather than merely appended, because "the order ops arrived in" is a property of the network and "the order ops apply in" must not be.

ts
class OpLog

Methods

  • append(op: Op): boolean — Add an op. Returns false if it was already present.

Idempotence is not a nicety here: every real transport redelivers (a WebSocket reconnect replays, a peer re-sends on timeout, two peers relay the same op to each other). An op log that applied a duplicate add twice, or double-counted a move, would diverge on nothing more exotic than a flaky wifi connection.

  • appendAll(ops: Iterable<Op>): Op[]
  • has(op: Op): boolean
  • toArray(): readonly Op[] — The ops, in total order.
  • get size(): number
  • since(clock: number): Op[] — Ops strictly after clock — the tail a peer needs to catch up.
  • maxClock(): number — The highest clock in the log — what a peer should observe on catch-up.

UndoStack

A per-actor, supersession-aware undo/redo stack.

Owns no model state. It reads the LOG to decide what an undo should do, and applies the result through the diagram so that capture mints a normal op for it.

ts
class UndoStack

Methods

  • constructor( private readonly log: OpLog, private readonly actor: ActorId, private readonly apply: (op: Op) => void )
  • get canUndo(): boolean
  • get canRedo(): boolean
  • get depth(): number — Depth of the undo stack — one entry per user-visible step.
  • record(op: Op, before: OpBefore): void — Record a local edit.

Ignored while replaying: the ops an undo emits are the undo, not new work to undo.

  • transact<T>(fn: () => T): T — Group everything fn does into ONE undo step.

Without this, a gesture that touches four registers costs four Ctrl-Zs — and deleting a node with three links (which the editor does as four ops) would take four. The grouping is a LOCAL, UI-level concern: it changes what one keypress undoes, never what is on the wire, and two peers may group differently without diverging.

  • undo(): Op[] — Undo my last step. Returns the ops it emitted — empty if every part of it was already superseded, which is a legitimate and silent outcome.

apply runs the inverse through the model with capture LIVE, so the resulting ops are minted, logged, gated and broadcast exactly like any other local edit.

  • redo(): Op[] — Redo the step undo last took back.

Interfaces

AddOp

Create an entity. Carries the full serialized form — an add has no prior state to diff against.

ts
interface AddOp extends OpBase

Properties

NameTypeDefaultDescription
op'add'
targetExclude<OpTarget, 'diagram'>
idstring
dataSerializedNode | SerializedLink | SerializedGroup | SerializedStroke
clocknumberLamport clock. Causality, NOT wall time. Advances past every clock this actor has observed, so caused-by always implies a strictly greater clock.
actorActorIdWho. Also the deterministic tiebreak when two ops share a clock.

OpCaptureOptions

ts
interface OpCaptureOptions

Properties

NameTypeDefaultDescription
actorActorIdWho this peer is. Must be unique across peers; used for the total-order tiebreak.
onOp(op: Op, before: OpBefore) => voidCalled for each captured op — hand it to a sync adapter, an autosave, a test.
startClock?numberResume a clock across sessions (e.g. from a persisted op-log tail).

RemoveOp

Remove an entity.

ts
interface RemoveOp extends OpBase

Properties

NameTypeDefaultDescription
op'remove'
targetExclude<OpTarget, 'diagram'>
idstring
clocknumberLamport clock. Causality, NOT wall time. Advances past every clock this actor has observed, so caused-by always implies a strictly greater clock.
actorActorIdWho. Also the deterministic tiebreak when two ops share a clock.

ReplicaOptions

ts
interface ReplicaOptions

Properties

NameTypeDefaultDescription
actorActorIdThis peer's identity. MUST be unique across peers — the total order depends on it.
onLocalOp?(op: Op) => voidCalled with each op this peer produces locally. Hand it to a transport.
startClock?numberResume the Lamport clock from a persisted tail.

SetOp

Write one property register. The unit of concurrency.

ts
interface SetOp extends OpBase

Properties

NameTypeDefaultDescription
op'set'
targetOpTarget
idstring'' for the diagram itself, which is a singleton.
pathOpPath
value?OpValueThe value the register now holds. ABSENT when the op is a clear.
clear?trueExplicitly empty the register: the key is deleted, not set to anything. NOT null — null is a legitimate STORED value (a peer that stores null and a peer that cleared must not converge on the same document), so it cannot double as the clear sentinel.
clocknumberLamport clock. Causality, NOT wall time. Advances past every clock this actor has observed, so caused-by always implies a strictly greater clock.
actorActorIdWho. Also the deterministic tiebreak when two ops share a clock.

Stamp

The stamp of the write that currently owns a register.

ts
interface Stamp

Properties

NameTypeDefaultDescription
clocknumber
actorActorId

Types

ActorId

Also has every member of String, listed on its own entry.

Who made the edit. Stable for the lifetime of a session; distinct per peer.

ts
type ActorId = string;

Op

Also has every member of AddOp, listed on its own entry.

ts
type Op = AddOp | RemoveOp | SetOp;

Properties

NameTypeDefaultDescription
clocknumberLamport clock. Causality, NOT wall time. Advances past every clock this actor has observed, so caused-by always implies a strictly greater clock.
actorActorIdWho. Also the deterministic tiebreak when two ops share a clock.

OpBefore

What the register held BEFORE the op overwrote it.

Needs it and nothing else does, so it is handed to the capture callback ALONGSIDE the op rather than being put INSIDE it. An op is a wire format: it crosses a network and a disk, it is broadcast to every peer, and a peer does not need — and must not be trusted with — the sender's idea of the previous value. Undo is a LOCAL concern. Widening the op to carry it would have doubled the traffic to serve one machine.

ts
type OpBefore =

  | { kind: 'value'; value: OpValue | undefined }

  | { kind: 'entity'; data: SerializedNode | SerializedLink | SerializedGroup }

  | { kind: 'none' };

Properties

NameTypeDefaultDescription
kind'value'

OpPath

Also has every member of String, listed on its own entry.

A property path. Dot-separated for nested registers: 'position', 'size', 'metadata.label', 'state.locked'.

THE PATH IS THE REGISTER KEY. Two concurrent writes to the SAME path conflict and are resolved by last-writer-wins; two writes to DIFFERENT paths of the same entity do not conflict at all and both survive. This is the whole point (see the header).

ts
type OpPath = string;

OpTarget

Also has every member of String, listed on its own entry.

The entity kinds a diagram is made of, plus the diagram itself.

stroke joins the list. Ink is DOCUMENT CONTENT — two people drawing on the same board must converge — so a stroke is a first-class op target, not annotation smuggled through a diagram-level set. (It very nearly WAS: trackChange('strokes', …) is a diagram-level change event, and without stroke in this union the capture layer's fall-through emitted set(diagram, strokes, <live StrokeModel>) — a class instance on the wire, claiming the whole collection. See capture.ts / apply-op.ts.)

ts
type OpTarget = 'node' | 'link' | 'group' | 'stroke' | 'diagram';

OpValue

JSON-safe value. Ops must survive a network hop and a disk round-trip.

ts
type OpValue = null | boolean | number | string | OpValue[] | { [k: string]: OpValue };

Members

  • toString(): string — Returns a string representation of a string.
  • valueOf(): string — Returns the primitive value of the specified object.
  • toLocaleString(): string — Returns a date converted to a string using the current locale.

Was this page helpful?

Collab — Grafloria