vectis-crdt
v0.1.3
Published
CRDT library for ordered collections of mutable objects. RGA/YATA sequence for deterministic z-order + LWW-Register per property. Binary wire format, causal delivery buffer, incremental GC, viewport culling. Compiled to WebAssembly.
Downloads
445
Maintainers
Readme
vectis-crdt
A CRDT library for ordered collections of mutable objects with Strong Eventual Consistency. It maintains a deterministic z-order over items and independently-mutable properties per item — concurrent edits from any number of peers always converge to the same state without a server coordinator.
Designed for sequences of richly-attributed objects such as vector strokes, canvas elements, slide objects, or any domain where items have a defined order and independently editable properties.
Two conflict-free structures work together:
- RgaArray (YATA-style) — deterministic total order over items. Concurrent inserts from any peer converge to the same sequence via Lamport timestamps + actor ID tiebreak.
- LwwRegister per property — Last-Write-Wins for color, width, opacity, and affine transform. Concurrent edits to different properties of the same item are always preserved.
Operations use a compact binary format, delta sync via vector clocks, and a causal delivery buffer for out-of-order packets.
Installation
npm install vectis-crdtQuick start
import { Document } from "vectis-crdt";
// actor_id: a unique u64 per client, passed as BigInt
const doc = new Document(1n);
// Insert a stroke: flat Float32Array [x, y, pressure, x, y, pressure, ...]
// tool: 0=Pen 1=Eraser 2=Marker 3=Laser 4=Shape 5=Arrow
// color: 0xRRGGBBAA stroke_width: f32 opacity: 0.0–1.0
const strokeId = doc.insert_stroke(
new Float32Array([0, 0, 1.0, 10, 10, 0.8, 20, 5, 0.9]),
0, // tool: Pen
0xFF0000FF, // color: red, fully opaque
2.0, // stroke_width
1.0, // opacity
);
// strokeId → Uint8Array(16): lamport u64 LE + actor u64 LE
// Encode pending ops and send over your transport (WebSocket, WebRTC, etc.)
const update = doc.encode_pending_update();
// ws.send(update);
// Apply a binary update received from a peer
// doc.apply_update(receivedBytes);No await init() needed — the module initializes itself on import.
TypeScript
import { Document } from "vectis-crdt";
import { wasmMemory } from "vectis-crdt";
const doc = new Document(BigInt(myActorId));
const view = new DataView(wasmMemory.buffer, ptr, len);Full .d.ts types are included. No @types package needed.
Rendering
Viewport-culled render (recommended for large documents)
import { Document, wasmMemory } from "vectis-crdt";
const doc = new Document(1n);
// Inside requestAnimationFrame:
const ptr = doc.build_render_data_viewport(
camX, camY, // top-left in canvas coordinates
camX + viewW, camY + viewH, // bottom-right
maxStrokeWidth / 2, // padding to avoid clipping thick strokes at edge
);
const len = doc.get_render_data_len();
const view = new DataView(wasmMemory.buffer, ptr, len);Important: the pointer is invalidated by any subsequent call that mutates the document. Read the buffer in the same
requestAnimationFramecallback.
Full render (small documents)
const ptr = doc.build_render_data();
const len = doc.get_render_data_len();
const view = new DataView(wasmMemory.buffer, ptr, len);Render buffer layout (per stroke)
| Offset | Size | Field | |--------|------|-------| | 0 | 16 B | stroke_id (lamport u64 LE + actor u64 LE) | | 16 | 4 B | point_count (u32 LE) | | 20 | 1 B | tool (u8) | | 21 | 4 B | color (u32 LE, 0xRRGGBBAA) | | 25 | 4 B | stroke_width (f32 LE) | | 29 | 4 B | opacity (f32 LE) | | 33 | 24 B | transform (6 x f32 LE: [a, b, c, d, tx, ty]) | | 57 | point_count x 12 B | points: x f32, y f32, pressure f32 per point |
let offset = 0;
const strokeIdBytes = new Uint8Array(view.buffer, view.byteOffset + offset, 16);
offset += 16;
const pointCount = view.getUint32(offset, true); offset += 4;
const tool = view.getUint8(offset); offset += 1;
const color = view.getUint32(offset, true); offset += 4;
const strokeWidth = view.getFloat32(offset, true); offset += 4;
const opacity = view.getFloat32(offset, true); offset += 4;
const transform = [];
for (let i = 0; i < 6; i++) { transform.push(view.getFloat32(offset, true)); offset += 4; }
const points = [];
for (let i = 0; i < pointCount; i++) {
points.push({
x: view.getFloat32(offset, true),
y: view.getFloat32(offset + 4, true),
pressure: view.getFloat32(offset + 8, true),
});
offset += 12;
}Sync (delta)
const sv = doc.encode_state_vector();
// send sv to server → server replies with delta
doc.apply_update(deltaBytes);
const pending = doc.encode_pending_update();Full snapshot (reconnect / initial load)
// Sender
const snapshot = doc.encode_snapshot();
// Receiver
const peer = Document.from_snapshot(2n, snapshot);Undo
const deletedId = doc.undo(); // Uint8Array(16), empty slice if stack is empty
if (deletedId.length > 0) {
ws.send(doc.encode_pending_update());
}
console.log(doc.undo_depth());Cursors (ephemeral awareness)
// Broadcast local cursor (28-byte binary blob)
const cursorBytes = doc.encode_local_cursor(
mouseX, mouseY,
BigInt(Date.now()),
0x3B82F6FF, // color 0xRRGGBBAA
);
ws.send(cursorBytes);
// Apply a cursor update from a peer
doc.apply_cursor_update(receivedCursorBytes);
// All cursors: N x 28 bytes
// Layout per cursor: actor u64 LE, x f32, y f32, timestamp u64 LE, color u32 LE
const allCursors = doc.get_all_cursors();
doc.evict_stale_cursors(BigInt(Date.now())); // call on setInterval
doc.remove_cursor(actorId); // on peer disconnect
doc.set_cursor_ttl(10_000n); // default: 30 000 msStroke operations
// Delete (generates a CRDT op visible to all peers)
doc.delete_stroke(strokeId);
// Update a property (generates a LWW op)
// property_key: 0=color(u32 LE), 1=stroke_width(f32 LE),
// 2=opacity(f32 LE), 3=transform(6 x f32 LE = 24B)
const buf = new ArrayBuffer(4);
new DataView(buf).setFloat32(0, 3.0, true);
doc.update_stroke_property(strokeId, 1, new Uint8Array(buf));
// Query
const ids = doc.get_visible_stroke_ids(); // flat, 16 bytes each
const data = doc.get_stroke_data_owned(strokeId);
const bounds = doc.get_stroke_bounds(strokeId); // [min_x, min_y, max_x, max_y] as 4 x f32 LEPoint simplification (RDP)
doc.set_simplify_epsilon(0.5); // auto-simplify on insert (default: 0.5)
const removed = doc.simplify_stroke(strokeId, 1.0);Diagnostics
console.log(doc.stats()); // JSON string
console.log(doc.causal_buffer_len()); // ops waiting for out-of-order delivery
console.log(doc.lamport(), doc.actor_id());Safety limits
| Limit | Value | |-------|-------| | Points per stroke | 50 000 | | Strokes per document | 100 000 | | Actors tracked | 10 000 | | Causal buffer capacity | 10 000 ops | | Undo depth | 200 ops |
