CRDT & Collaboration
Conflict-free collaborative editing — declared on a row field, merged server-side, surfaced on the client with one hook. No clobbering, no per-keystroke round- trip, no extra backend.
#Declarative on the engine
Tell the engine which fields are CRDTs. Writes on those fields then merge on actions.insert/update instead of overwriting, and an upsert mutation named "<table>:merge" is auto-registered for the client hook.
import { rgaText } from '@absolutejs/sync/crdt';
// Declare 'body' a CRDT field on the issues table. The engine MERGES it on
// every actions.insert/update (instead of overwriting), and auto-registers a
// 'issues:merge' upsert mutation the client hook calls.
engine.registerCrdt('issues', { body: rgaText });#The client hook
useCollaborativeText subscribes to the row's CRDT field, merges every replica's edits into a local replica, and broadcasts via the auto merge mutation. The local text is the source of truth between keystrokes — no per-keystroke server round-trip.
import { useCollaborativeText } from '@absolutejs/sync/react';
const Description = ({ issueId }: { issueId: string }) => {
const doc = useCollaborativeText({
url: 'ws://localhost:3000/sync/ws',
collection: 'issues',
field: 'body',
id: issueId,
});
return (
<textarea
value={doc.text}
onChange={(e) => doc.setText(e.target.value)}
/>
);
};The same hook exists for Vue (useCollaborativeText), Svelte (createCollaborativeTextStore), and Angular (SyncCollectionService.collaborativeText). All four ship under @absolutejs/sync/{react,vue,svelte,angular}.
#Authoritative hydration without lost keystrokes
Document-scoped params and a shared ready state prevent early local edits from forking an empty replica before the authoritative row arrives.
- Scope
Subscribe with collection params that identify and authorize one document. - Buffer
Accept keystrokes locally while the transport and hydrate are still starting. - Ready
Hydrate the authoritative CRDT row and expose ready across React, Vue, Svelte, and Angular. - Reconcile
Reconcile buffered edits onto authoritative state, then upload only the resulting delta.
#The CRDT kit
@absolutejs/sync/crdt is a small, zero-dependency, isomorphic CRDT library — every type is state-based (CvRDT) and JSON-serialisable, so they ride the engine's change feed as row fields:
import {
counter, // PN-counter (concurrent +/-)
lww, // LWW register
orSet, // observed-remove set (add-wins on concurrent add/remove)
lwwMap, // per-key LWW map (delete = tombstone, can lose to a later set)
createList, // ordered-list RGA over arbitrary items
createTextCrdt, // RGA collaborative text (used by useCollaborativeText)
textOf,
mergeTextState,
compact,
tombstoneCount,
type TextState,
} from '@absolutejs/sync/crdt';
// Every type is a pure CvRDT: merge is commutative + associative + idempotent.
// They serialise as JSON, so they ride the engine's change feed as row fields.
let cart = orSet.create<string>();
cart = orSet.add(cart, 'item-1');
cart = orSet.add(cart, 'item-2');
console.log(orSet.values(cart)); // ['item-1', 'item-2']
let prefs = lwwMap.create<string>();
prefs = lwwMap.set(prefs, 'theme', 'dark', 'replica-a', Date.now());
console.log(lwwMap.get(prefs, 'theme')); // 'dark'#Delta uploads — O(edit), not O(doc)
The collaborative-text controller uploads only the new ops since the last sync (takeDelta()) instead of the whole document. Partial states merge exactly like full states (union), so the server keeps full state for trivial late-joiner hydration while clients send just deltas:
// Without delta uploads: every keystroke broadcasts the WHOLE document.
// With delta uploads: every keystroke broadcasts O(edit) — just the new ops.
// The controller does this automatically; the data structure exposes takeDelta:
const doc = createTextCrdt('replica-a');
doc.setText('hello world');
const delta = doc.takeDelta(); // only the new elements
const remote = createTextCrdt('replica-b');
remote.merge(delta); // partial state merges exactly like full
console.log(remote.text()); // 'hello world'
// Measured: at 10,000 chars, full-state upload is ~877 KB; delta is ~105 B —
// an 8,350× reduction. See the bench in absolutejs/benchmarks#sync.#Tombstone compaction
RGA keeps tombstones for deleted characters so concurrent edits stay convergent. compact drops the ones nothing live anchors to (visible text is unchanged), and the linearizer is orphan-safe — a stale client briefly referencing a compacted tombstone re-roots its element deterministically rather than losing content.
// RGA tombstones accumulate as text is deleted. compact() drops tombstones
// that no live element anchors to (visible text unchanged):
const state = doc.state();
console.log(tombstoneCount(state)); // 6,234
const smaller = compact(state);
console.log(tombstoneCount(smaller)); // 0 (for unreferenced tombstones)
// Safe on the canonical server state. The linearizer also re-roots orphans
// deterministically, so a stale client referencing a compacted tombstone
// never loses content.#Collaborative cursors
A caret position survives concurrent edits when it's anchored to a CRDT element id instead of an integer index. Pair it with the presence hub for live remote carets.
// A caret at a raw integer index drifts when others edit before it. Anchor it
// to a CRDT element id and it tracks the right spot through concurrent edits.
const anchor = doc.anchorAt(textarea.selectionStart);
// broadcast over presence:
presence.set({ name, anchor });
// render remote carets:
for (const member of presence.members) {
const col = doc.indexOfAnchor(member.state.anchor);
paint(member.state.name, col);
}#Yjs / Automerge / Loro — first-party adapters
The in-package RGA handles offline-merge and moderate collaboration. For production-scale collaborative text — efficient deltas, tombstone management, interleaving guarantees — the sync-adapters repo ships three battle-tested backends behind the same TextCrdtAdapter contract:
// Swap the first-party RGA for a production CRDT — same call sites.
import { yjsText } from '@absolutejs/sync-yjs';
// or '@absolutejs/sync-automerge', '@absolutejs/sync-loro'
engine.registerCrdt('issues', { body: yjsText });
import { createYjsText } from '@absolutejs/sync-yjs';
const doc = useCollaborativeText({
url, collection: 'issues', field: 'body', id,
create: createYjsText, // <- the only client change
});
// Yjs also supports delta uploads via the same takeDelta surface — the adapter
// implements it through Y.encodeStateAsUpdate(doc, lastVector) and advances
// the vector on merge, so remote ops aren't re-broadcast.