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
mapKey/value with last-write-wins per key
listOrdered collections with stable insertion
counterMonotonic numeric counters
Note

SurrealDB ignores the comment. Sp00ky’s parser picks it up and wires everything for you.

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 in a _00_crdt sidecar table, completely separate from your records. When useCrdtField mounts it loads existing state, opens a LIVE SELECT to stream updates, and pushes local changes back (debounced at 300ms). Cursors ride the same channel under _cursor_<field> keys. Failed pushes retry twice before logging. Works with any backend that supports live queries.

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.