Github|...

CRDT Fields

Experimental

CRDT Fields are experimental. The API may change between canary releases.

Two users. Same thread. Both typing at the same time.

With a normal database, somebody’s changes get silently overwritten. Locking makes the UX terrible. Server-side merging is a nightmare for character-level edits.

CRDTs fix this. A Conflict-free Replicated Data Type guarantees that any two replicas merge automatically, no matter what order edits arrive in. No central authority. No conflicts. Every peer converges on the same result.

Sp00ky uses Loro under the hood. You add a comment to your schema, and you get real-time collaborative editing with cursor presence. That’s it.

1. Annotate your schema

Add -- @crdt text above any field:

sql
-- @crdt text
DEFINE FIELD title ON TABLE thread TYPE string
    ASSERT $value != NONE AND string::len($value) > 0;

-- @crdt text
DEFINE FIELD content ON TABLE thread TYPE string
    ASSERT $value != NONE AND string::len($value) > 0;

Then run spky to regenerate.

TypeWhat it does
textCharacter-level text merging with full editor support
Note

SurrealDB ignores the comment. Sp00ky’s parser picks it up and wires everything for you. Today only text has built-in CrdtField wiring; for map/list/counter containers you can call field.getDoc() and drive the underlying Loro APIs directly.

2. Get the CrdtField

useCrdtField gives you a reactive CrdtField. It loads existing CRDT state, starts syncing, and rebinds when the record ID changes.

TypeScript
const titleField = useCrdtField(
  'thread',
  () => thread()?.id,    // reactive record ID
  'title',
  () => thread()?.title  // fallback for pre-CRDT records
);
Prop Type Default Description
table string - Table name
recordIdAccessor () => string | undefined - Reactive getter for the record ID. Hook rebinds when it changes.
field string - The CRDT-enabled field name
fallbackAccessor () => string | undefined - Current plain text value. Seeds the editor for pre-CRDT records.
Note

Must live inside a <Sp00kyProvider>.

TypeScript
import { Sp00kyProvider } from '@spooky-sync/client-solid';
import { dbConfig } from './db';

<Sp00kyProvider config={dbConfig}>
  {/* useCrdtField must be called inside this provider */}
</Sp00kyProvider>

3. Pick an editor

CrdtField.getDoc() returns a standard Loro LoroDoc. Loro ships ProseMirror plugins via loro-prosemirror, so any editor built on ProseMirror works out of the box:

EditorWhy pick it
TipTapMost popular. Headless, extensible, great DX.
MilkdownPlugin-driven Markdown editor on ProseMirror.
NovelNotion-style block editor.
ProseMirrorFull control, no abstractions.

Not using ProseMirror? You can still use the LoroDoc directly with any editor that supports external state. Call doc.getText('field') to read, subscribe to changes, and import/export snapshots yourself.

TipTap example

Here’s a minimal collaborative editor using TipTap + Loro. Disable TipTap’s built-in history and let LoroUndoPlugin handle undo/redo so it works correctly across peers.

TypeScript
import { onMount, onCleanup } from 'solid-js';
import { Editor, Extension } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { LoroSyncPlugin, LoroUndoPlugin, undo, redo } from 'loro-prosemirror';
import { textToHtml, type CrdtField } from '@spooky-sync/core';
import { keymap } from '@tiptap/pm/keymap';

export function CollaborativeEditor(props: { field: CrdtField; content?: string }) {
  let ref: HTMLDivElement | undefined;
  let editor: Editor | undefined;

  onMount(() => {
    const doc = props.field.getDoc();

    editor = new Editor({
      element: ref,
      extensions: [
        StarterKit.configure({ history: false }), // Loro handles undo
        Extension.create({
          name: 'loroSync',
          addProseMirrorPlugins: () => [
            LoroSyncPlugin({ doc }),
            LoroUndoPlugin({ doc }),
            keymap({ 'Mod-z': undo, 'Mod-y': redo, 'Mod-Shift-z': redo }),
          ],
        }),
      ],
    });

    // Seed the doc for records that existed before CRDT was enabled
    if (!props.field.hasContent() && props.content) {
      editor.commands.setContent(textToHtml(props.content));
    }
  });

  onCleanup(() => editor?.destroy());
  return <div ref={ref} />;
}

For a full-featured version with cursor presence, single-line mode, and placeholder support, see CollaborativeEditor.tsx in the example app.

4. Go deeper (optional)

Building a custom editor, or wiring map/list/counter? Use the CrdtField API directly:

TypeScript
import type { CrdtField } from '@spooky-sync/core';

const field: CrdtField = titleField();

field.getDoc();                // LoroDoc - the underlying Loro document
field.hasContent();            // boolean - true once saved CRDT state has loaded
field.pushCursorState(bytes);  // Promise<void> - broadcast local cursor
field.onCursorUpdate = (data) => { /* remote cursor bytes */ };

getDoc() gives you a standard LoroDoc. Plug in any Loro-compatible binding.

For cursor presence in a custom editor:

  • Push: Call pushCursorState(encoded) when the local cursor moves
  • Receive: Set field.onCursorUpdate = (data) => { ... } to get remote cursors. The latest pending update is replayed when you attach the callback.

Helpers

TypeScript
import { cursorColorFromName, CURSOR_COLORS, textToHtml } from '@spooky-sync/core';

const color = cursorColorFromName('ada');  // deterministic per-user color
const html = textToHtml('hello\nworld');  // plain text -> HTML paragraphs

Under the hood

CRDT state lives inline on the same row as the rest of your record: a @crdt-only field stores the snapshot bytes directly, and a @crdt @cursor field stores { state, cursors } so each session’s cursor blob sits next to the snapshot. There is no separate _00_crdt sidecar table.

When useCrdtField mounts, the CrdtManager ensures a single LIVE SELECT * FROM <table> is open for that table (shared across every open field on that table), imports the existing snapshot, and pushes local changes back debounced at crdtDebounceMs (default 500 ms, configurable on Sp00kyConfig). Cross-user propagation also rides the list_ref sync path so updates arrive even when SurrealDB’s cross-session LIVE-permission gap drops the parent-table notification (see Architecture).

Limitations

  • text has ready-made ProseMirror integration. map/list/counter work via getDoc() and the Loro API directly.
  • SolidJS only. Flutter and Vanilla don’t have useCrdtField yet.
  • Needs live queries. Without LIVE SELECT, edits reconcile on reload.
  • API may change while experimental.