CRDT fields
Collaborative text fields backed by Loro. Clients send only their own edits, the server folds them into one canonical document, and the field itself stays plain, queryable text.
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. Every peer converges on the same result.
Sp00ky uses Loro under the hood. You annotate a field, and you get real-time collaborative editing with cursor presence. Each client sends only its own edits, through the same outbox and sync pipeline as every other write. The server folds those edits into the canonical document and writes the field back as plain text, so lists, search and filters keep working on it.
1. Annotate your schema
Put -- @crdt <kind> above a TYPE string field, and -- @cursor if you want remote cursors:
| Annotation | What you get |
|---|---|
@crdt prosemirror | A loro-prosemirror tree. For rich text in ProseMirror-based editors (TipTap, Milkdown, …). |
@crdt text | One LoroText named after the field. For plain-text inputs. @crdt alone means text. |
@cursor | Per-session presence for the field, so editors can show each other’s cursors. |
The rules, all checked by spky generate:
- The field must be
TYPE string(oroption<string>) with noVALUEorASSERTclause. The server owns its value. - Top-level fields only, and not on
TYPE RELATIONtables. - The
surrealismdeploy mode is refused: the fold runs in the SSP, and that mode has none. - The names
<table>__crdt_log,<table>__crdt_presence,_00_crdt_docand fields ending in__crdtare reserved.
Then run spky generate and apply the schema (spky migrate / spky deploy). Who may read a document’s edits, and who may write them, is exactly who may read and update the record: the generated tables reuse the parent table’s select and update permissions.
2. The field is plain text
The annotated column no longer holds anything a client writes. It is the document’s plain text, written by the server each time it folds the edit log. Read it like any other column: render it in a list, search it, filter or order on it.
- Write it only when creating the record. A
db.createmay set the initial text, so a new record shows it before the first fold (put the same text in the field’s draft document). After that a client write keeps the old value; leave the field out ofdb.update. - It trails live edits. The column changes when the server folds, typically a few seconds after the last edit (at most one fold per field every 10 s by default) (see the fold). Inside an open editor you read the live document instead.
prosemirrorrenders as lines. Block nodes (paragraphs, headings, list items) become lines, hard breaks become newlines, and formatting is dropped.- Keep it out of the sync engine with
@opaque. Add-- @opaquenext to@crdtwhen the text is large or sensitive and you never filter on it; see@opaque.
Each fold also writes <field>__crdt on the record, a stamp of the folded version (its rev and the Loro version it covers). You don’t need to read it.
Annotating a field that already holds text does not import that text: the document starts empty, and the first fold after someone edits replaces the column with the document’s text. CRDT state written by earlier canaries (inline snapshots on the record) is not read either.
3. Open the field
useCrdtField opens the field’s document, keeps it syncing, and closes it on cleanup. It reopens when the record id changes.
| Prop | Type | Default | Description |
|---|---|---|---|
| table | string | - | Table name |
| recordId | () => string | undefined | - | Reactive getter for the record id. The hook reopens when it changes and waits while it is undefined. |
| field | string | - | The `@crdt` field name |
It returns an accessor that is null until the field is open, then a CrdtField. It must live inside a <Sp00kyProvider>, and has the same shape in @spooky-sync/client-solid and @spooky-sync/client-solid2.
Without Solid, open and close the field on the client yourself. Every open of the same field in a tab shares one document, so pair each open with a close:
closeCrdtField resolves once the edits not sent yet are handed to the outbox, so awaiting it before navigating away loses nothing. Offline it resolves at once and the edits stay on the device until the field is next opened online; on a connection that has not noticed it is down it waits at most 4 seconds.
When a device holds nothing for a field yet, the open first fetches the server’s published snapshot and waits briefly (at most 4 seconds, not at all offline) for the log’s first answer, so the editor starts from the real document rather than an empty one. An open with a draft skips both: the record was just created there. Set crdt: true in the client config to load the Loro engine at startup instead of on the first open.
4. Create a record with content
A “new thread” form needs an editor before the record exists. Give it a draft:
createCrdtDraft returns a detached CrdtField that sends nothing. Passing it to openCrdtField after db.create merges its edits into the record’s document, where they go out as the record’s first edits. The example app’s CreateThreadDialog.tsx does exactly this.
5. Bind an editor
CrdtField.getDoc() returns a standard Loro LoroDoc.
prosemirror fields
Loro ships ProseMirror plugins in loro-prosemirror, so any editor built on ProseMirror works:
| 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. |
A minimal TipTap editor. Disable TipTap’s own history and let LoroUndoPlugin handle undo, so undo only ever reverts your own edits:
Show the plain-text column while the field opens:
For a full-featured version with cursor presence, single-line mode and a placeholder, see CollaborativeEditor.tsx in the example app.
text fields
The document holds one LoroText named after the field. Bind it to an input by diffing into it, so concurrent edits merge instead of replacing each other:
spky recipe crdt-text-field --table <table> --field <field> renders this as a ready-made component.
6. Cursors (@cursor)
A @cursor field carries one presence blob per editing session. The CrdtField moves the bytes; what they mean is up to the editor. With TipTap, loro-prosemirror’s CursorEphemeralStore and LoroEphemeralCursorPlugin do the drawing:
Presence is the latest state only: it is not queued offline, it is sent at most every 250 ms, and a session’s row is removed when its field closes (the SSP sweeps rows a crashed tab left behind once they are older than the presence TTL).
The CrdtField API
How it syncs
Nothing about a CRDT field bypasses the sync pipeline. For a table with @crdt fields, spky generate adds:
| Table | Holds | Written by |
|---|---|---|
<table>__crdt_log | Append-only edit log: one row per batch of one client’s edits (doc, field, author, peer, data, size, created_at). | Clients create rows (where the parent’s update rule allows), the SSP deletes them once folded. No updates. |
<table>__crdt_presence | One presence row per session and field (@cursor only). | Each session its own row. |
_00_crdt_doc | The canonical snapshot of each (record, field), with its rev. | The SSP only. Never synced: a device reads it on demand, when it opens a field it holds nothing for, or when log rows depend on edits already folded away. |
_00_crdt_peer | Which user owns which Loro peer id. | Clients claim ids, nobody changes or deletes them. |
Up. Typing changes the tab’s local Loro document right away. 50 ms after the first keystroke (crdtDebounceMs), and then every 200 ms for as long as the typing goes on (crdtSendIntervalMs), the tab exports only its own new operations as one Loro update and creates one <table>__crdt_log row through the ordinary outbox, so edits survive offline, reloads and retries like any other write. Each tab edits under a random Loro peer id that it claims for the signed-in user in _00_crdt_peer; the database refuses a log row whose peer the writer does not own.
Down. An open field is an ordinary query on its log rows, so other clients’ edits arrive through the normal sync pipeline on the same SurrealDB connection, with no extra LIVE query, and are merged into the open document.
The acceptance rule. Before any client or the server imports a log row, it checks that the blob is a Loro update (not a snapshot), at most 256 KiB, and that every operation in it was made by the row’s peer. A row that fails never enters any document, anywhere, so a client cannot inject edits under someone else’s name or replace history with a crafted snapshot.
Locally. The device keeps its merged document and how far each of its peers’ edits got into the outbox in _00_crdt_local. Edits made offline, or just before a reload, are sent later under the peer they were made with. Signing out does not send them under the next user: they wait in the old account’s local store.
The server fold
The SSP is the only writer of the canonical document. To keep its CPU flat it folds rarely: a field is folded once its log has been idle for a while, or once enough rows or bytes are pending, and never more often than a minimum gap. A fold takes a lease on the field’s _00_crdt_doc row, imports the accepted log rows into the snapshot, writes the new snapshot and the record’s plain-text column in one transaction, and then deletes the rows it folded. Several SSPs in a cluster are safe: the lease and a compare-and-swap on rev mean exactly one of them publishes each version.
Nothing waits on a fold. A late fold only delays the plain-text column and the snapshot new devices start from; live editors exchange edits through the log directly.
The thresholds are SSP environment variables:
| Variable | Default | Effect |
|---|---|---|
SPKY_CRDT_FOLD_IDLE_SECS | 5 | Fold a field once its log has had no new rows for this long. |
SPKY_CRDT_FOLD_MAX_ROWS | 200 | …or once this many rows are pending, however active it is. |
SPKY_CRDT_FOLD_MAX_BYTES | 262144 | …or once this many bytes are pending. |
SPKY_CRDT_FOLD_MIN_GAP_SECS | 10 | Never fold one field more often than this. |
SPKY_CRDT_FOLD_WORKERS | 2 | Folds running at once on one SSP. |
SPKY_CRDT_FOLD_TICK_MS | 5000 | How often the SSP looks for fields due a fold. |
SPKY_CRDT_PRESENCE_TTL_SECS | 60 | Presence rows older than this are swept. |
SPKY_CRDT_CACHE_SIZE | 1024 | Hot documents kept in memory between folds. |
Self-hosted, set them on the SSP process (Self-hosting). On Sp00ky Cloud, set them for the ssp role in deployment.env. The SSP’s /info reports fold counters under crdt (SSP API).
Limitations
- Two kinds.
textandprosemirror. The plain-text column renders those shapes only. - The column trails the editor. List previews update when the server folds, also for your own edits.
- Every fold rewrites the record. The column and its
__crdtstamp are an update of the parent row, soVALUE time::now()fields such asupdated_atmove on each fold, and the row syncs to everyone watching it. - Impersonation is read-only here. An impersonation session cannot write edits or presence.
- JavaScript clients only. The Dart client (
spooky_core, Flutter) throwsUnimplementedErrorfor CRDT fields. - API may change while experimental.