CRDT Fields
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:
Then run spky to regenerate.
| Type | What it does |
|---|---|
text | Character-level text merging with full editor support |
map | Key/value with last-write-wins per key |
list | Ordered collections with stable insertion |
counter | Monotonic numeric counters |
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.
| 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. |
Must live inside a <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:
| Editor | Why pick it |
|---|---|
| TipTap | Most popular. Headless, extensible, great DX. |
| Milkdown | Plugin-driven Markdown editor on ProseMirror. |
| Novel | Notion-style block editor. |
| ProseMirror | Full 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.
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:
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
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
texthas ready-made ProseMirror integration.map/list/counterwork viagetDoc()and the Loro API directly.- SolidJS only. Flutter and Vanilla don’t have
useCrdtFieldyet. - Needs live queries. Without
LIVE SELECT, edits reconcile on reload. - API may change while experimental.