Import these from @grafloria/engine.
On their own pages
ReferentialIntegrity: Holds the diagram to its one hard invariant, and buffers the ops that cannot be appliedReplica: One peer.
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.
tsfunction 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.
tsfunction 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.
tsfunction compareOps(a: Op, b: Op): number
opId
Identity of an op, for idempotent delivery: the same op received twice is one op.
tsfunction 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.
tsfunction 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.
tsclass 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 thanseen— so any op we issue AFTER observing theirs sorts AFTER it, which is exactly the causality guarantee.peek(): numberget 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.
tsclass 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.
tsclass OpApplyError extends Error
Properties
| Name | Type | Default | Description |
|---|---|---|---|
name | string | ||
message | string | ||
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.
tsclass 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— Runfnwith 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.
tsclass 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): booleantoArray(): readonly Op[]— The ops, in total order.get size(): numbersince(clock: number): Op[]— Ops strictly afterclock— 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.
tsclass UndoStack
Methods
constructor( private readonly log: OpLog, private readonly actor: ActorId, private readonly apply: (op: Op) => void )get canUndo(): booleanget canRedo(): booleanget 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 everythingfndoes 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.
tsinterface AddOp extends OpBase
Properties
| Name | Type | Default | Description |
|---|---|---|---|
op | 'add' | ||
target | Exclude<OpTarget, 'diagram'> | ||
id | string | ||
data | SerializedNode | SerializedLink | SerializedGroup | SerializedStroke | ||
clock | number | Lamport clock. Causality, NOT wall time. Advances past every clock this actor has observed, so caused-by always implies a strictly greater clock. | |
actor | ActorId | Who. Also the deterministic tiebreak when two ops share a clock. |
OpCaptureOptions
tsinterface OpCaptureOptions
Properties
| Name | Type | Default | Description |
|---|---|---|---|
actor | ActorId | Who this peer is. Must be unique across peers; used for the total-order tiebreak. | |
onOp | (op: Op, before: OpBefore) => void | Called for each captured op — hand it to a sync adapter, an autosave, a test. | |
startClock? | number | Resume a clock across sessions (e.g. from a persisted op-log tail). |
RemoveOp
Remove an entity.
tsinterface RemoveOp extends OpBase
Properties
| Name | Type | Default | Description |
|---|---|---|---|
op | 'remove' | ||
target | Exclude<OpTarget, 'diagram'> | ||
id | string | ||
clock | number | Lamport clock. Causality, NOT wall time. Advances past every clock this actor has observed, so caused-by always implies a strictly greater clock. | |
actor | ActorId | Who. Also the deterministic tiebreak when two ops share a clock. |
ReplicaOptions
tsinterface ReplicaOptions
Properties
| Name | Type | Default | Description |
|---|---|---|---|
actor | ActorId | This peer's identity. MUST be unique across peers — the total order depends on it. | |
onLocalOp? | (op: Op) => void | Called with each op this peer produces locally. Hand it to a transport. | |
startClock? | number | Resume the Lamport clock from a persisted tail. |
SetOp
Write one property register. The unit of concurrency.
tsinterface SetOp extends OpBase
Properties
| Name | Type | Default | Description |
|---|---|---|---|
op | 'set' | ||
target | OpTarget | ||
id | string | '' for the diagram itself, which is a singleton. | |
path | OpPath | ||
value? | OpValue | The value the register now holds. ABSENT when the op is a clear. | |
clear? | true | Explicitly 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. | |
clock | number | Lamport clock. Causality, NOT wall time. Advances past every clock this actor has observed, so caused-by always implies a strictly greater clock. | |
actor | ActorId | Who. Also the deterministic tiebreak when two ops share a clock. |
Stamp
The stamp of the write that currently owns a register.
tsinterface Stamp
Properties
| Name | Type | Default | Description |
|---|---|---|---|
clock | number | ||
actor | ActorId |
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.
tstype ActorId = string;
Op
Also has every member of AddOp, listed on its own entry.
tstype Op = AddOp | RemoveOp | SetOp;
Properties
| Name | Type | Default | Description |
|---|---|---|---|
clock | number | Lamport clock. Causality, NOT wall time. Advances past every clock this actor has observed, so caused-by always implies a strictly greater clock. | |
actor | ActorId | Who. 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.
tstype OpBefore =
| { kind: 'value'; value: OpValue | undefined }
| { kind: 'entity'; data: SerializedNode | SerializedLink | SerializedGroup }
| { kind: 'none' };
Properties
| Name | Type | Default | Description |
|---|---|---|---|
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).
tstype 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.)
tstype OpTarget = 'node' | 'link' | 'group' | 'stroke' | 'diagram';
OpValue
JSON-safe value. Ops must survive a network hop and a disk round-trip.
tstype 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?