Vuer RTC

Advanced Features

This page documents expert-level APIs for custom use cases. These features provide low-level access to vuer-rtc's internal data structures and operations.

These APIs are intended for advanced users who need fine-grained control over CRDT internals, custom conflict resolution, or performance optimization. For most use cases, the standard GraphStore API is recommended.


RangeTree - B-tree Implementation

The RangeTree is a B-tree optimized for string CRDT operations. It stores items with O(log n) lookups by position and maintains character counts in each node.

When to Use

  • Building custom text editors with specific performance requirements
  • Implementing alternative text CRDT algorithms
  • Debugging text CRDT behavior at the tree level
  • Performance analysis and benchmarking

Structure

typescript
import { RangeTree, type Item, type ItemId } from '@vuer-ai/vuer-rtc';

const tree = new RangeTree();

// Item structure
interface Item {
  id: ItemId;           // Unique identifier { agent, seq }
  content: string;      // Text content
  isDeleted: boolean;   // Tombstone flag
  parentId: ItemId | null;  // Parent item for CRDT ordering
  seq: number;          // Sequence number
}

Key Operations

typescript
// Insert an item
tree.insert({
  id: { agent: 'alice', seq: 1 },
  content: 'Hello',
  isDeleted: false,
  parentId: null,
  seq: 1,
});

// Find by position (O(log n))
const result = tree.findByPosition(5);
// => { leaf: LeafNode, itemIndex: 2, offset: 1 }

// Find by ID (O(log m) where m = unique agents)
const item = tree.findById({ agent: 'alice', seq: 1 });

// Delete (tombstone)
tree.delete({ agent: 'alice', seq: 1 });

// Get full content
const text = tree.getContent();

// Get metadata
const length = tree.length;      // Total visible characters
const count = tree.itemCount;    // Total items (including deleted)

Pitfalls

  • Manual count updates: After modifying items directly, you must call recalcAllCounts() to update character counts
  • Memory overhead: Tombstones are never removed, only marked as deleted. Use compaction at the higher level.
  • No automatic splitting: The tree splits leaf nodes automatically, but you cannot control the branching factor (fixed at 32)
  • No parent pointers: Items don't maintain back-references, making parent-based traversal O(n)

createGraphFromServer() - Server Snapshot Initialization

Initialize a client graph store from a server-provided snapshot and journal history.

When to Use

  • Client reconnection after disconnect
  • Loading archived sessions
  • Server-side rendering with hydration
  • Multi-device sync on session join

Usage

typescript
import { createGraphFromServer } from '@vuer-ai/vuer-rtc';

const store = createGraphFromServer({
  sessionId: 'client-abc',
  snapshot: serverSnapshot,  // Server's current snapshot
  journal: serverJournal,    // Server's journal entries
  onSend: (msg) => websocket.send(JSON.stringify(msg)),
  onStateChange: (state) => console.log('State updated'),
});

// The store is now initialized with the server's state
// All journal entries are replayed in causal order

vs. Regular createGraph()

FeaturecreateGraph()createGraphFromServer()
Initial stateEmpty or local snapshotServer snapshot + journal
Journal replayNoneAutomatic, causally ordered
Use caseNew sessionReconnect/join existing
Vector clockStarts at {sessionId: 0}Merged from server

Pitfalls

  • Clock skew: Server snapshot's vector clock must be consistent with the journal entries. Mismatched clocks cause duplicate operations.
  • Journal ordering: Entries are sorted by (lamportTime, sessionId) before replay. Out-of-order server journals are handled automatically.
  • Compaction loss: Journal entries older than the snapshot watermark are lost. Cannot undo operations that were compacted server-side.
  • Memory spike: Replaying large journals (10k+ ops) can cause temporary memory pressure during initialization.

compactToWatermark() - Advanced Compaction

Fold locally acknowledged entries covered by a server vector-clock watermark into the snapshot. This is a low-level client action; the room server sends the watermark only after its durable snapshot and journal deletion have committed.

When to Use

  • Applying a durable server checkpoint to a connected client
  • Removing journal entries only when their operations are known to be represented

Usage

typescript
import { compactToWatermark } from '@vuer-ai/vuer-rtc/client';

// Use the watermark carried by the server's compaction-watermark message.
state = compactToWatermark(state, message.watermark);

The client folds only an acknowledged, canonically ordered safe prefix. A gap, missing causal dependency, or legacy actor-counter gap stops compaction; a later-arriving predecessor can make the prefix eligible on a subsequent watermark. Text tombstones and character identities are retained. Operations already folded into the server checkpoint are no longer available to generic journal-based undo.

vs. Regular compact()

Featurecompact()compactToWatermark()
WatermarkImplicit (all acked)Explicit vector clock
Undo preservationFolds acknowledged entriesFolds only safe acknowledged entries covered by the durable watermark
Use caseLocal acknowledged journal cleanupApplying the server's durable checkpoint
Memory impactLocal journal reductionSafe reduction; unresolved gaps remain retained

Pitfalls

  • Do not construct a watermark locally: use the server's committed watermark or state-transfer snapshot.
  • Legacy counter gaps: unexplained counters remain in the journal until a fresh state transfer establishes a new snapshot baseline.
  • Undo window: a server-compacted operation cannot be undone by sending a generic journal undo; applications needing longer undo must retain their own inverse operations.
  • Text identity: client watermark compaction retains tombstones and anchors; do not call rope compaction while peers may hold references.

initFromServer() - Server Initialization Patterns

Low-level action for initializing client state from server snapshot and journal. Used internally by createGraphFromServer().

When to Use

  • Custom store initialization outside createGraph()
  • Server-side state reconciliation
  • Manual journal replay with custom filtering
  • Testing and debugging CRDT behavior

Usage

typescript
import { initFromServer } from '@vuer-ai/vuer-rtc/client';

const state = initFromServer(
  'my-session-id',
  serverSnapshot,
  serverJournal
);

// Returns a ClientState with:
// - graph: rebuilt from snapshot + journal
// - journal: local copy of server journal
// - vectorClock: merged from all entries
// - snapshot: server snapshot as base

Internals

  1. Clone snapshot graph: Deep-clones nodes, children arrays, and TextRope instances (avoids shared mutable state)
  2. Sort journal: Orders entries by (lamportTime, sessionId) for causal replay
  3. Rebuild graph: Applies each journal entry to the cloned graph
  4. Merge clocks: Merges vector clocks from all journal entries
  5. Return state: Returns a fresh ClientState object

Pitfalls

  • No edit buffer: The returned state has an empty edit buffer. Pending local edits must be reapplied.
  • Snapshot mutation: The input snapshot is cloned, but if you pass the same snapshot to multiple initFromServer() calls, ensure the server snapshot itself is immutable.
  • Journal ownership: The returned state stores a copy of the journal array. Mutating the input array after initFromServer() won't affect the state.
  • No deduplication: Duplicate journal entries (same id) are replayed multiple times. The server must deduplicate before sending.

EditBufferImpl - Low-Level Edit Buffer

The edit buffer accumulates uncommitted operations during gestures (e.g., dragging) and merges additive operations to reduce message size.

When to Use

  • Custom gesture handling with operation merging
  • Building higher-level transaction APIs
  • Debugging edit buffer behavior
  • Performance profiling of operation batching

Usage

typescript
import { EditBufferImpl, isAdditiveOp, mergeValues } from '@vuer-ai/vuer-rtc/client';

const buffer = new EditBufferImpl();

// Add operations (automatically merged if additive)
buffer.add({ ot: 'vector3.add', key: 'cube', path: 'position', value: [1, 0, 0] });
buffer.add({ ot: 'vector3.add', key: 'cube', path: 'position', value: [0, 1, 0] });
// => Merged to: { ot: 'vector3.add', key: 'cube', path: 'position', value: [1, 1, 0] }

// Get operations in order
const ops = buffer.getOps();

// Clear buffer
buffer.clear();

// Check status
const empty = buffer.isEmpty();
const count = buffer.size();

Operation Deduplication

Operations are deduplicated by a key computed from (ot, key, path, target):

typescript
// Property operations: key:path
'cube:position' // vector3.set on cube.position

// Node operations include target node
'scene:children:insert:cube-1'  // Insert cube-1 under scene
'scene:children:remove:cube-1'  // Remove cube-1 from scene

Additive Operations

These operations are merged (not replaced) when the same dedup key is used:

  • vector3.add - Component-wise addition
  • number.add - Numeric addition
  • number.multiply - Numeric multiplication
  • quaternion.multiply - Quaternion composition

Pitfalls

  • LWW replacement: Non-additive ops (e.g., vector3.set) replace previous ops with the same dedup key, even if applied in different order
  • No undo: The edit buffer itself has no undo. Use GraphStore.undo() which operates on the journal.
  • Merge order matters: For quaternions, q1 * q2 ≠ q2 * q1. Merging preserves order, but concurrent quaternion ops may produce unexpected results.
  • No validation: Invalid operations (e.g., vector3.add with a string value) are stored without validation

findItemById() - Text CRDT Item Lookup

Low-level API for looking up text CRDT items by ID in the TextRope structure.

When to Use

  • Debugging text CRDT parent references
  • Custom text CRDT algorithms
  • Analyzing character-level causality
  • Performance profiling of ID lookups

Usage

typescript
import { findItemById, type ItemId } from '@vuer-ai/vuer-rtc';

// Get the TextRope from a node property
const rope = graph.nodes['editor'].content;

// Find an item by ID
const item = rope.findById({ agent: 'alice', seq: 5 });

if (item) {
  console.log('Content:', item.content);
  console.log('Deleted:', item.isDeleted);
  console.log('Parent:', item.parentId);
}

Agent Index

TextRope maintains an agent-indexed structure for fast lookups:

  • Span entries: Each agent's items are grouped into spans [startSeq, endSeq)
  • Binary search: Finding an item by seq is O(log m) where m = number of spans for that agent
  • Automatic updates: Inserting/deleting items updates the agent index

Pitfalls

  • Deleted items: Deleted items (tombstones) are still returned. Check isDeleted before using.
  • Parent references: parentId may reference an item that doesn't exist (out-of-order delivery). Handle null returns gracefully.
  • No content lookup: There is no reverse lookup by content. Finding "where is the word 'hello'" requires iterating all items.
  • Memory overhead: The agent index stores duplicate references to items. Large ropes with many agents consume more memory.

itemIdEquals() & itemIdCompare() - Item ID Utilities

Helper functions for comparing CRDT item IDs.

When to Use

  • Custom CRDT implementations
  • Sorting items by causal order
  • Deduplicating item references
  • Debugging parent-child relationships

Usage

typescript
import { itemIdEquals, itemIdCompare, type ItemId } from '@vuer-ai/vuer-rtc';

const id1: ItemId = { agent: 'alice', seq: 5 };
const id2: ItemId = { agent: 'alice', seq: 5 };
const id3: ItemId = { agent: 'bob', seq: 3 };

// Equality check
itemIdEquals(id1, id2);  // => true
itemIdEquals(id1, id3);  // => false
itemIdEquals(null, null); // => true
itemIdEquals(id1, null); // => false

// Comparison (for sorting)
itemIdCompare(id1, id2);  // => 0 (equal)
itemIdCompare(id1, id3);  // => -1 (alice < bob)
itemIdCompare(id3, id1);  // => 1 (bob > alice)

// Sort items by ID
items.sort((a, b) => itemIdCompare(a.id, b.id));

Ordering Rules

  1. Agent first: Compare agent strings lexicographically
  2. Sequence second: If agents match, compare seq numbers

This ensures deterministic ordering across clients regardless of arrival order.

Pitfalls

  • No null in compare: itemIdCompare() does not handle null. Use itemIdEquals() for nullable IDs.
  • String comparison: Agent IDs are compared as strings. 'alice10' &lt; 'alice2' (lexicographic). Use UUIDs or zero-padded numbers.
  • No causal ordering: This compares IDs, not causality. For causal order, use vector clocks.

VectorClockManager - Advanced Clock Operations

Manages vector clocks for causal ordering and conflict detection.

When to Use

  • Custom conflict resolution strategies
  • Implementing causal broadcast protocols
  • Debugging concurrency issues
  • Multi-session coordination logic

Usage

typescript
import { VectorClockManager, type VectorClock } from '@vuer-ai/vuer-rtc';

const manager = new VectorClockManager();

// Create a clock for a session
const clock1 = manager.create('alice');
// => { alice: 0 }

// Increment on local operation
const clock2 = manager.increment(clock1, 'alice');
// => { alice: 1 }

// Merge clocks (on receiving remote operation)
const remoteClock = { bob: 5 };
const merged = manager.merge(clock2, remoteClock);
// => { alice: 1, bob: 5 }

// Compare clocks
const cmp = manager.compare(clock1, clock2);
// => -1 (clock1 causally before clock2)

// Check concurrency
const concurrent = manager.areConcurrent(clock1, clock2);
// => false (clock1 < clock2)

Comparison Results

typescript
compare(clockA, clockB) returns:
  1  if clockA causally after clockB (A dominates)
 -1  if clockA causally before clockB (B dominates)
  0  if concurrent (neither dominates)

Causal Rules

  • A < B: All of A's counters ≤ B's counters, and at least one is strictly less
  • A > B: All of A's counters ≥ B's counters, and at least one is strictly greater
  • A || B: (concurrent) Some of A's counters are greater, some of B's are greater

Pitfalls

  • Immutable operations: All methods return new clocks. Don't mutate clocks in place.
  • Missing sessions: Missing sessions are treated as 0. { alice: 1 } and { alice: 1, bob: 0 } are equivalent.
  • No garbage collection: Old sessions remain in the clock forever. Large multi-session systems accumulate entries.
  • No total order: Concurrent operations have no defined order. Use Lamport time for total ordering.

ConflictResolver - Custom Conflict Resolution

Merges concurrent property updates based on data type and operation semantics.

When to Use

  • Implementing custom merge strategies
  • Debugging conflict resolution behavior
  • Building higher-level CRDT abstractions
  • Analyzing concurrent edit patterns

Usage

typescript
import { ConflictResolver, type ValueWithMeta } from '@vuer-ai/vuer-rtc';

const resolver = new ConflictResolver();

// Merge a single property with concurrent updates
const values: ValueWithMeta[] = [
  { value: [1, 0, 0], lamportTime: 100, sessionId: 'alice' },
  { value: [0, 1, 0], lamportTime: 101, sessionId: 'bob' },
];

const merged = resolver.mergeProperty('vector3.add', values);
// => [1, 1, 0] (additive merge)

// Merge multiple properties
const updates = [
  {
    properties: { position: [1, 0, 0], visible: true },
    otypes: { position: 'vector3.add', visible: 'boolean.set' },
    lamportTime: 100,
    sessionId: 'alice',
  },
  {
    properties: { position: [0, 1, 0], color: '#ff0000' },
    otypes: { position: 'vector3.add', color: 'color.set' },
    lamportTime: 101,
    sessionId: 'bob',
  },
];

const result = resolver.mergeProperties(updates);
// => { position: [1, 1, 0], visible: true, color: '#ff0000' }

Supported Operations

See the DType System below for all supported data types and operations.

Pitfalls

  • Otype required: Missing otype defaults to 'any.set' (LWW). Always specify explicit otypes.
  • Unknown dtypes: Unrecognized dtypes fall back to LWW with a console warning.
  • No schema validation: Values are not type-checked. Passing a string to vector3.add will cause runtime errors.
  • Lamport time ties: When lamport times are equal, sessionId lexicographic order is used. Ensure Lamport clocks are properly incremented.

DType System - Data Type Abstraction Layer

The DType system defines merge behaviors for concurrent updates. Each data type supports multiple operations.

Structure

typescript
export const DType = {
  number: {
    set: (values) => /* LWW */,
    add: (values) => /* sum */,
    multiply: (values) => /* product */,
    min: (values) => /* minimum */,
    max: (values) => /* maximum */,
  },
  vector3: {
    set: (values) => /* LWW */,
    add: (values) => /* component-wise sum */,
    multiply: (values) => /* component-wise product */,
  },
  // ... more types
};

All Supported Types

DTypeOperationsDescription
numberset, add, multiply, min, maxNumeric values
stringset, concatText values
booleanset, or, andBoolean flags
vector3set, add, multiply3D vectors [x, y, z]
quaternionset, multiplyRotations [x, y, z, w]
colorset, blendHex colors
arrayset, union, appendLists
objectset, lwwPerKey, mergeObjects
immutablesetWrite-once values

Custom Merge Functions

typescript
import { DType, type ValueWithMeta, type MergeFn } from '@vuer-ai/vuer-rtc';

// Access built-in merge functions
const addFn: MergeFn<number> = DType.number.add;

const values: ValueWithMeta<number>[] = [
  { value: 10, lamportTime: 100, sessionId: 'alice' },
  { value: 20, lamportTime: 101, sessionId: 'bob' },
];

const sum = addFn(values);
// => 30

// Use for custom logic
function customMerge(values: ValueWithMeta<number>[]): number {
  const baseSum = DType.number.add(values);
  return Math.min(baseSum, 100); // Clamp at 100
}

When to Use

  • Implementing custom data types
  • Building domain-specific merge logic
  • Testing CRDT merge behavior
  • Analyzing conflict patterns

Pitfalls

  • No type checking: DType.vector3.add accepts any array. Runtime errors if values aren't [number, number, number].
  • Immutable is first-write-wins: Unlike LWW, immutable.set takes the earliest value, not latest.
  • Color blend is averaging: color.blend averages RGB values, not perceptual blending. May produce muddy colors.
  • Object.lwwPerKey is shallow: Only top-level keys are merged. Nested objects use LWW for the entire nested object.
  • No custom dtypes: The DType registry is closed. To add custom types, fork the library or wrap in object.lwwPerKey.

Performance Considerations

RangeTree

  • Best case: O(log n) position lookups, O(log m) ID lookups
  • Worst case: O(n) when tree is degenerate (all items in one leaf)
  • Memory: ~64 bytes per item + tree node overhead

TextRope

  • Best case: O(log n) insert/delete, O(log m) ID lookup
  • Worst case: O(n) when agent index has many small spans
  • Memory: ~100 bytes per item (includes agent index, parent refs)

Compaction

  • Time: O(j + n) where j = journal length, n = graph size
  • Memory spike: 2x graph size during rebuild (old + new)
  • Recommendation: Compact every 100-1000 operations, not on every ack

Vector Clocks

  • Time: O(s) where s = number of sessions
  • Memory: O(s) per message
  • Growth: Unbounded as sessions join. Consider pruning old sessions.

Common Patterns

Custom Text Editor

typescript
import { RangeTree, type Item } from '@vuer-ai/vuer-rtc';

class CustomEditor {
  private tree = new RangeTree();

  insert(position: number, text: string, agentId: string, seq: number) {
    const result = this.tree.findByPosition(position);
    const parentId = result ? result.leaf.items[result.itemIndex].id : null;

    const item: Item = {
      id: { agent: agentId, seq },
      content: text,
      isDeleted: false,
      parentId,
      seq,
    };

    this.tree.insert(item);
  }

  getText(): string {
    return this.tree.getContent();
  }
}

Session Watermark Compaction

typescript
import { VectorClockManager } from '@vuer-ai/vuer-rtc';

function compactOldSessions(store: GraphStore, maxAge: number) {
  const state = store.getState();
  const now = Date.now();
  const watermark: VectorClock = {};

  // Build watermark from old entries
  for (const entry of state.journal) {
    const age = now - entry.msg.timestamp;
    if (age > maxAge) {
      const clock = entry.msg.clock[entry.msg.sessionId];
      watermark[entry.msg.sessionId] = Math.max(
        watermark[entry.msg.sessionId] || 0,
        clock
      );
    }
  }

  store.compactToWatermark(watermark);
}

Custom Conflict Resolver

typescript
import { ConflictResolver } from '@vuer-ai/vuer-rtc';

class GameConflictResolver extends ConflictResolver {
  mergeProperty(otype: string, values: ValueWithMeta[]): any {
    // Custom logic for game-specific types
    if (otype === 'health.damage') {
      // Damage is additive, but clamped at 0
      const total = values.reduce((sum, v) => sum + v.value, 0);
      return Math.max(0, total);
    }

    // Fall back to default
    return super.mergeProperty(otype, values);
  }
}

Further Reading

These advanced APIs are subject to change in minor versions. Pin your version and test thoroughly when upgrading.